Files
management-scripts/Tools.ps1
T

1697 lines
77 KiB
PowerShell

# Define the MSP name
[string]$Global:MSPName = "Emberkom"
# This section will define several global variables that can be used in scripts that source this one
[string]$Global:RootDirectory
[string]$Global:ScriptsDirectory
[string]$Global:OutputDirectory
[string]$Global:ToolsDirectory
[string]$Global:LogsDirectory
[string]$Global:LogFile
# Define the root path for the MSP in the Windows Registry
[string]$Global:MSPRegistryRoot = "HKEY_LOCAL_MACHINE\SOFTWARE\${MSPName}"
# Create a global variable to keep a ticket and billing related variables if they are needed later
$Global:TicketNumber = $null
$Global:TimeAttributedToUser = ""
# Setup the MSP management directories
Function Setup-MSPDirectories {
If ( Test-Path ${Env:ProgramData} ) { $Global:RootDirectory = "${Env:ProgramData}\${MSPName}" } Else { $Global:RootDirectory = "${Env:SystemDrive}\${MSPName}" }
$Global:ScriptsDirectory = "${RootDirectory}\Scripts"
$Global:OutputDirectory = "${RootDirectory}\Output"
$Global:ToolsDirectory = "${RootDirectory}\Tools"
$Global:LogsDirectory = "${RootDirectory}\Logs"
# Make sure all the management directories exist
$RootDirectory, $ScriptsDirectory, $OutputDirectory, $ToolsDirectory, $LogsDirectory | ForEach-Object {
If ( !(Test-Path $_) ) {
Try { New-Item -Path $_ -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null }
Catch { Write-Output $_.Exception.Message }
}
}
}
# Function to get a datestamp for right now in a long format
Function Get-LongDateTime {
Return $(Get-Date -Format "yyyyMMddHHmmssffff")
}
# Function to get a datestanp for the current day
Function Get-LongDate {
Return $(Get-Date -Format "yyyyMMdd")
}
# Setup the log file for messages generated when this script is run
Function Setup-LogFile {
$LogFilePrefix = "ManagementScript"
$LogFilePostfix = Get-LongDateTime
$Global:LogFile = "${LogsDirectory}\${LogFilePrefix}_${LogFilePostfix}.log"
# Delete log files older than 120 days
$LogFileAllowedAge = (Get-Date).AddDays(-120)
Try {
Get-ChildItem -Path "${LogsDirectory}\${LogFilePrefix}_*.*" -Filter '*.log' | `
Where-Object {$_.LastWriteTime -lt $LogFileAllowedAge} | `
Remove-Item -Force -ErrorAction SilentlyContinue | Out-Null
}
Catch { Write-Output $_.Exception.Message }
Try { If ( !(Test-Path $LogFile) ) { New-Item -Path $LogFile -ItemType File -Force | Out-Null } }
Catch { Write-Output $_.Exception.Message }
}
# Log a message to the log file
# IMPORTANT: In Powershell 'echo' is an alias for Write-Output, which is overriden when this Tools.ps1 file is called before your script.
# To prevent unwanted behavior, if you need to echo something do the following instead:
# Start-Process "cmd.exe" -ArgumentList '/C echo y | some-command'
Function Write-Output {
param([Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$Message)
Add-Content -Path $Global:LogFile -Value "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - ${Message}"
Microsoft.Powershell.Utility\Write-Output $Message
}
# Log an error to the log file
Function Write-Error {
param([Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$Message)
Add-Content -Path $Global:LogFile -Value "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - Error: ${Message}"
Microsoft.Powershell.Utility\Write-Error $Message
}
# Function to determine if the current user context is an Administrator
Function IsAdmin {
If ( ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) )
{ Return $true } Else { Return $false }
}
# Run a script from the live repo or from a local path
Function Run-Script {
param(
# Parameters for live-ps scripts
[Parameter(Mandatory=$false,ParameterSetName='live-ps')]
[string]$Type='management-scripts',
[Parameter(Mandatory=$true,ParameterSetName='live-ps')]
[string]$LivePSScript,
# Parameters for local scripts
[Parameter(Mandatory=$true,ParameterSetName='local')]
[string]$Path,
### $NoWait will prevent waiting for the specified script to finish running
[Parameter(Mandatory=$false,ParameterSetName='local')]
[switch]$NoWait=$false,
# Parameters for all scripts
[Parameter(Mandatory=$false,ParameterSetName='live-ps')]
[Parameter(Mandatory=$false,ParameterSetName='local')]
[string]$Arguments='null',
### $HaltIfRunning is a collection of app names which, if running, will prevent the script from running
[Parameter(Mandatory=$false,ParameterSetName='live-ps')]
[Parameter(Mandatory=$false,ParameterSetName='local')]
[string[]]$HaltIfRunning='null',
### $ForceClose will forcefully close all apps specified in $HaltIfRunning
[Parameter(Mandatory=$false,ParameterSetName='live-ps')]
[Parameter(Mandatory=$false,ParameterSetName='local')]
[switch]$ForceClose=$false
)
If ( $Arguments -eq "null" ) { $Arguments = $null }
If ( $HaltIfRunning -eq "null" ) { $HaltIfRunning = $null }
If ( $LivePSScript ) {
$URL = "https://dev.emberkom.com/emberkom/${Type}/raw/branch/master/${LivePSScript}.ps1"
If ( !(IsURLValid -URL $URL) ) { Write-Output "The specified script does not exist: ${LivePSScript}" ; Exit }
}
If ( $Path ) { If ( !(Test-Path $Path) ) { Write-Output "The specified script does not exist: ""${Path}""" ; Exit } }
If ( ($null -ne $HaltIfRunning ) -and ($ForceClose) -and ( !(IsAdmin) ) ) { Write-Output "Cannot close dependent apps because this task does not have administrative privileges" ; Exit }
If ( $HaltIfRunning ) {
$Halt = $false
Write-Output "Checking for running instances of the following dependent apps:"
ForEach ( $name in $HaltIfRunning) {
$RunningInstances = Get-Process | Where-Object { $_.Name -like "${name}" }
If ( $RunningInstances ) {
If ( $ForceClose -eq $true ) {
Try { $name | Stop-Process -Force ; Write-Output " ""${name}"" (force closed)" }
Catch { Write-Error $_.Exception.Message ; Exit }
}
Else { $Halt = $true ; Write-Output " ""${name}"" (running)" }
}
Else { Write-Output " ""${name}"" (not running)" }
}
If ( $Halt -eq $true ) { Write-Output "Dependent apps are currently in use and are preventing the specified script from running" ; Exit }
}
switch ($PSCmdlet.ParameterSetName) {
'live-ps' {
Write-Output "Retrieving : ${LivePSScript}.ps1"
If ( $null -eq $Arguments ) {
$ScriptFile = Download-File -URL $URL
Write-Output "Sourcing: ${LivePSScript}.ps1"
. $ScriptFile
If ( Test-Path $ScriptFile ) { Remove-Item $ScriptFile -Force -ErrorAction SilentlyContinue }
} Else {
Try { $ScriptBlock = [Scriptblock]::Create((New-Object Net.WebClient).DownloadString($URL)) }
Catch { Write-Error $_.Exception.Message ; Exit }
Write-Output "Executing: ${LivePSScript}.ps1 ${Arguments}"
Try { Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $Arguments }
Catch { Write-Error $_.Exception.Message ; Exit }
}
}
'local' {
Write-Output "Executing: ${Path} ${Arguments}"
If ( $NoWait ) {
Try { Start-Process "${Path}" -ArgumentList "{$Arguments}" }
Catch { Write-Error $_.Exception.Message }
}
Else {
Write-Output " Waiting for script to finish..."
Try { Start-Process "${Path}" -ArgumentList "{$Arguments}" -Wait }
Catch { Write-Error $_.Exception.Message }
Write-Output " Finished"
}
}
}
}
# Return a Powershell version object from a string
Function Get-Version {
param([Parameter(Mandatory=$true)][string]$Version)
[int]$Sets = 4
$VersionArray = $Version.Split('.')
If ( $VersionArray.Count -le $Sets ) { $Sets = $VersionArray.Count }
For ($i = 0; $i -le ($Sets - 1); $i++) {
$Return = '{0}.{1}' -f $Return, $VersionArray[$i] }
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)
Try { $status = [System.Net.WebRequest]::Create($URL).GetResponse().StatusCode }
Catch { Write-Error $_.Exception.Message ; Return $false }
If ( $status -eq 404 ) { Return $false }
Return $true
}
Function IsURL {
param([Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$Value)
Return $value -match "https?:\/\/"
}
# Downloads a file from a URL
Function Download-File {
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$URL,
[Parameter(Mandatory=$false,ValueFromPipeline=$false)][string]$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) }
# 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(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$Path,
[Parameter(Mandatory=$false)][string]$Arguments = '',
[Parameter(Mandatory=$false)][switch]$Delete = $false
)
If ( Test-Path "${Path}" ) {
Write-Output "Running installer: ""${Path}"" ${Arguments}"
Try {
If ( $Arguments -eq '' ) { Start-Process "${Path}" -Wait }
Else { Start-Process "${Path}" -ArgumentList "${Arguments}" -Wait }
}
Catch { Write-Error $_.Exception.Message }
Write-Output "Installer finished"
If ( $Delete ) {
Write-Output "Deleting installer: ""${Path}"""
Try { Remove-Item -Path "${Path}" -Force }
Catch { Write-Error $_.Exception.Message }
}
}
Else { Write-Output "The specified file could not be found: ""${Path}""" }
}
# Function to silently install an MSI package either locally or after downloading it from a URL
Function Install-MSI {
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$Path,
[Parameter(Mandatory=$false)][System.Collections.ArrayList]$Properties = @(),
[Parameter(Mandatory=$false)][string]$Log = $Global:LogFile,
[Parameter(Mandatory=$false)][switch]$DeleteMSI = $false,
[Parameter(Mandatory=$false)][int]$MaxRunTimeSeconds = 594
)
# If $Path is a URL, then try to download the file
If ( IsURL -Value $Path ) { $Path = Download-File -URL $Path }
# Make sure $FilePath exists
If ( !(Test-Path $Path ) ) { Write-Error "The specified file doesn't exist: ""${Path}""" ; Return }
# Make sure $MaxRuntimeMinutes is a positive integer
If ( $MaxRunTimeSeconds -lt 0 ) {
$MaxRunTimeSeconds = 594
Write-Output "The specified maximum run time is invalid and has been changed to ${MaxRunTimeSeconds} seconds"
}
# Build the install command
[System.Collections.ArrayList]$MSIParameters = @('/i',"""${Path}""")
If ( ![string]::IsNullOrEmpty($Log) ) {
If ( Test-Path $Log ) { $MSIParameters.Add('/l+!*') | Out-Null } Else { $MSIParameters.Add('/l!*') | Out-Null }
$MSIParameters.Add("""${Log}""") | Out-Null
}
If ( ![string]::IsNullOrEmpty($Properties) ) { $MSIParameters.Add($Properties) | Out-Null }
# Run a job to install the MSI
Write-Output "Waiting ${MaxRunTimeSeconds} seconds for the following command: msiexec.exe ${MSIParameters}"
$InstallMSI = Start-Job -ScriptBlock { PROCESS{ msiexec.exe $_ } } -InputObject $MSIParameters
# Wait for the job to end or the timeout to be reached
$endTime = (Get-Date).AddSeconds($MaxRunTimeSeconds)
While ( $(Get-Date) -le $endTime ) {
If ( (Get-Job -Name $InstallMSI.Name).JobStateInfo.State -eq "Completed" ) { Break }
Start-Sleep -Seconds 1
}
# Stop the installation if it's still running
If ( (Get-Job -Name $InstallMSI.Name).JobStateInfo.State -ne "Completed" ) {
Write-Output "Timeout reached, stopping installation process"
Stop-Job -Job $InstallMSI
}
# Clean up the job
$result = Receive-Job -Job $InstallMSI
Remove-Job -Job $InstallMSI -Force
If ( [string]::IsNullOrEmpty($result) ) { Write-Output "Installation finished!" } Else { Write-Output "Installation finished with the following output:`n${result}" }
# Delete the MSI
If ( $DeleteMSI ) {
Write-Output "Deleting ""${Path}"""
Try { Remove-Item -Path $Path -Force -ErrorAction SilentlyContinue }
Catch { Write-Error $_.Exception.Message }
}
}
# Compare current Windows version with a supplied version
# The value supplied must be a string and must include at least 1 decimal
Function IsWindowsVersion {
param(
[Parameter(Mandatory=$true,ParameterSetName='gt')][string]$gt,
[Parameter(Mandatory=$true,ParameterSetName='ge')][string]$ge,
[Parameter(Mandatory=$true,ParameterSetName='lt')][string]$lt,
[Parameter(Mandatory=$true,ParameterSetName='le')][string]$le,
[Parameter(Mandatory=$true,ParameterSetName='eq')][string]$eq
)
[version]$WindowsVersion = [System.Environment]::OSVersion.Version
switch ($PSCmdlet.ParameterSetName) {
'gt' { If ( $WindowsVersion -gt (Get-Version $gt) ) { Return $true } Else { Return $false } }
'ge' { If ( $WindowsVersion -ge (Get-Version $ge) ) { Return $true } Else { Return $false } }
'lt' { If ( $WindowsVersion -lt (Get-Version $lt) ) { Return $true } Else { Return $false } }
'le' { If ( $WindowsVersion -le (Get-Version $le) ) { Return $true } Else { Return $false } }
'eq' { If ( $WindowsVersion -eq (Get-Version $eq) ) { Return $true } Else { Return $false } }
}
}
# Compare current Windows build with a supplied version
# The value supplied must be an int
Function IsWindowsBuild {
param(
[Parameter(Mandatory=$true,ParameterSetName='gt')][int]$gt,
[Parameter(Mandatory=$true,ParameterSetName='ge')][int]$ge,
[Parameter(Mandatory=$true,ParameterSetName='lt')][int]$lt,
[Parameter(Mandatory=$true,ParameterSetName='le')][int]$le,
[Parameter(Mandatory=$true,ParameterSetName='eq')][int]$eq
)
[int]$WindowsBuild = [System.Environment]::OSVersion.Version.Build
switch ($PSCmdlet.ParameterSetName) {
'gt' { If ( $WindowsBuild -gt $gt ) { Return $true } Else { Return $false } }
'ge' { If ( $WindowsBuild -ge $ge ) { Return $true } Else { Return $false } }
'lt' { If ( $WindowsBuild -lt $lt ) { Return $true } Else { Return $false } }
'le' { If ( $WindowsBuild -le $le ) { Return $true } Else { Return $false } }
'eq' { If ( $WindowsBuild -eq $eq ) { Return $true } Else { Return $false } }
}
}
# Get the path to the TEMP folder (context dependent)
Function Get-TempPath { Return [System.IO.Path]::GetTempPath() }
# Get a random file name with random extension
Function Get-RandomFileName { Return [System.IO.Path]::GetRandomFileName() }
# Create a new temp file in the current TEMP folder
Function New-TemporaryFile {
$file = [System.IO.Path]::Combine($(Get-TempPath), $(Get-RandomFileName))
New-Item -Path $file -ItemType File -Force -ErrorAction SilentlyContinue | Out-Null
Return Get-Item -Path $file
}
# Create a new temp directory in the current TEMP folder
Function New-TemporaryDirectory {
$directory = [System.IO.Path]::Combine($(Get-TempPath), $(Get-RandomFileName))
New-Item -Path $directory -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
Return Get-Item -Path $directory
}
# Determine if the system is 64-bit or not
Function Is64bit {
switch ( $(Get-CimInstance -ClassName Win32_OperatingSystem -Property OSArchitecture | Select-Object -ExpandProperty OSArchitecture) )
{ '64-bit' { Return $true } '32-bit' { Return $false } }
}
# Determine if the Powershell process is 64-bit or not
Function Is64bitShell { If ( [System.Environment]::Is64BitProcess ) { Return $true } Else { Return $false } }
# Determine if a user is in a built-in role or specified group
Function IsMemberOf {
param(
[Parameter(Mandatory=$false)]
[switch]$Builtin=$false,
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[string]$Group
)
If ( $Builtin ) {
Switch ( $Group ) {
("Administrator" -or "Administrators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::Administrator ; Break }
("Backup Operator" -or "Backup Operators" -or "BackupOperator" -or "BackupOperators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::BackupOperator ; Break }
("System Operator" -or "System Operators" -or "SystemOperator" -or "SystemOperators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::SystemOperator ; Break }
("Account Operator" -or "Account Operators" -or "AccountOperator" -or "AccountOperators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::AccountOperator ; Break }
("Print Operator" -or "Print Operators" -or "PrintOperator" -or "PrintOperators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::PrintOperator ; Break }
("Replicator" -or "Replicators")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::Replicator ; Break }
("Power User" -or "Power Users" -or "PowerUser" -or "PowerUsers")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::PowerUser ; Break }
("User" -or "Users")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::User ; Break }
("Guest" -or "Guests")
{ $Group = [System.Security.Principal.WindowsBuiltInRole]::Guest ; Break }
default { ; Break }
}
}
Return ([Security.Principal.WindowsPrincipal][System.Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole($Group)
}
# Start, stop, restart, or delete a list of services
Function Control-Service
{
param(
[Parameter(Mandatory=$true,ParameterSetName='start')]
[switch]$Start,
[Parameter(Mandatory=$true,ParameterSetName='stop')]
[switch]$Stop,
[Parameter(Mandatory=$true,ParameterSetName='restart')]
[switch]$Restart,
[Parameter(Mandatory=$true,ParameterSetName='delete')]
[switch]$Delete,
[Parameter(Mandatory=$true,ParameterSetName='disable')]
[switch]$Disable,
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ParameterSetName='start')]
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ParameterSetName='stop')]
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ParameterSetName='restart')]
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ParameterSetName='delete')]
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ParameterSetName='disable')]
[string[]]$Name,
[Parameter(Mandatory=$false,ParameterSetName='start')]
[Parameter(Mandatory=$false,ParameterSetName='stop')]
[Parameter(Mandatory=$false,ParameterSetName='restart')]
[Parameter(Mandatory=$false,ParameterSetName='delete')]
[Parameter(Mandatory=$false,ParameterSetName='disable')]
[switch]$Match=$false
)
switch ($PSCmdlet.ParameterSetName) {
'start' {
$statuscondition = 'Running'
$verb = 'start'
$verbing = 'Starting'
}
'stop' {
$statuscondition = 'Stopped'
$verb = 'stop'
$verbing = 'Stopping'
}
'restart' {
$ArgString = $MyInvocation.Line
$StopCmd = $ArgString -replace ' -Restart ', ' -Stop '
$StartCmd = $ArgString -replace ' -Restart ', ' -Start '
Invoke-Expression $StopCmd
Invoke-Expression $StartCmd
Exit
}
'delete' {
# Delete the service
}
'disable' {
$ArgString = $MyInvocation.Line
$StopCmd = $ArgString -replace ' -Disable ', ' -Stop '
Invoke-Expression $StopCmd
$statuscondition = 'Disabled'
$verb = 'disable'
$verbing = 'Disabling'
}
}
ForEach ( $item in $Name ) {
$service = Get-Service -Name $item -ErrorAction SilentlyContinue
If ( !($service) ) {
Write-Output "Cannot find the service '${item}'"
If ( $Match ) {
Write-Output "Attempting to match '${item}' to service display names"
ForEach ( $svc in ((Get-Service | Where-Object { $_.DisplayName -match $item }).Name) ) {
Switch ( $verb) {
'start' { Control-Service -Start -Name $svc }
'stop' { Control-Service -Stop -Name $svc }
'disable' { Control-Service -Disable -Name $svc }
}
}
}
}
Else {
$name = $service.Name
$fullname = $service.DisplayName
If ( ([string]::IsNullOrEmpty($fullname)) -or ($fullname -eq $name) ) {
$displayname = "'${name}'"
}
Else {
$displayname = """${fullname}"" (${name})"
}
If ( $service.Status -ne $statuscondition ) {
Write-Output "${verbing} ${displayname}"
Try {
Switch ( $verb) {
'start' { Start-Service -Name $name }
'stop' { Stop-Service -Name $name -Force }
'disable' { Set-Service -Name "${name}" -StartupType Disabled }
}
}
Catch { Write-Error $_.Exception.Message }
}
}
}
}
# Stop a process
Function End-Process
{
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string[]]$Name,
[Parameter(Mandatory=$false)][switch]$Match=$false,
[Parameter(Mandatory=$false)][switch]$Force=$false
)
ForEach ( $item in $Name ) {
$process = Get-Process -Name $item -ErrorAction SilentlyContinue
If ( !($process) ) {
Write-Output "Cannot find the process '${item}'"
If ( $Match ) {
Write-Output "Attempting to match '${item}' to all running process names"
ForEach ( $proc in ((Get-Process | Where-Object { $_.Name -match $item }).Name) ) {
If ( $Force ) { End-Process -Name $proc -Force } Else { End-Process -Name $proc }
}
}
}
Else {
$name = $process.Name
If ( $Force ) {
Write-Output "Forcefully stopping '${name}' process"
Stop-Process $process -Force -ErrorAction SilentlyContinue
}
Else {
Write-Output "Attempting to gracefully close '${name}' process"
$process.CloseMainWindow() | Out-Null
}
}
}
}
Function New-Shortcut {
param(
[Parameter(Mandatory=$true)][string]$Path,
[Parameter(Mandatory=$true)][string]$TargetPath,
[Parameter(Mandatory=$false)][string]$Arguments,
[Parameter(Mandatory=$false)][string]$Description,
[Parameter(Mandatory=$false)][string]$Icon = '',
[Parameter(Mandatory=$false)][string]$WorkingDirectory = ''
)
If ( Test-Path $TargetPath ) {
If ( Test-Path $Path ) {
Write-Output "Deleting existing shortcut at ""${Path}"""
Try { Remove-Item $Path -Force -ErrorAction SilentlyContinue }
Catch { Write-Error $_.Exception.Message }
}
If ( $WorkingDirectory -eq '' ) { $WorkingDirectory = Split-Path $TargetPath -Parent }
If ( $Icon -eq '' ) { $Icon = "${TargetPath}, 0" }
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut($Path)
$Shortcut.Arguments = $Arguments
$Shortcut.Description = $Description
$Shortcut.IconLocation = $Icon
$Shortcut.WorkingDirectory = $WorkingDirectory
$Shortcut.TargetPath = $TargetPath
Write-Output "Creating shortcut to ""${TargetPath}"" at ""${Path}"""
Try { $Shortcut.Save() }
Catch { Write-Error $_.Exception.Message }
}
Else { Write-Output "Cannot create shortcut because the target path does not exist" }
}
# Enable a Windows Event Log
Function Enable-Log {
param ([Parameter(Mandatory = $true,ValueFromPipeline=$true)][string]$Name)
Try {
$log = New-Object System.Diagnostics.Eventing.Reader.EventLogConfiguration $Name
Write-Output "Enabing ${Name} event log"
$log.IsEnabled = $true
$log.SaveChanges()
}
Catch { Write-Error $_.Exception.Message }
}
# Disable a Windows Event Log
Function Disable-Log {
param ([Parameter(Mandatory = $true,ValueFromPipeline=$true)][string]$Name)
Try {
$log = New-Object System.Diagnostics.Eventing.Reader.EventLogConfiguration $Name
Write-Output "Disabing ${Name} event log"
$log.IsEnabled = $false
$log.SaveChanges()
}
Catch { Write-Error $_.Exception.Message }
}
# Function to install the MSP360 Powershell module
Function Import-MSP360Module {
Try {
$Module = Get-Module -ListAvailable -Name MSP360,msp360 | Sort-Object Version -Descending | Select-Object -First 1
If ( $Module ) { Import-Module -Name $Module.Name -Force -ErrorAction Stop ; Return }
If ( -not (IsAdmin) ) { Write-Output "The MSP360 Powershell module needs to be installed, but the current account is not an administrative user" ; Return }
Write-Output "Installing MSP360 Powershell module"
Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Scope Process -Force
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/msp360/MSP360-PSModule/master/private/Install-MSP360Module.ps1')
Install-MSP360Module
Import-Module -Name MSP360 -Force -ErrorAction Stop
}
Catch { Write-Error "Could not import or install MSP360 PowerShell module: $($_.Exception.Message)" }
}
# Function to ZIP a file or directory, returns the newly created zip file
# TODO: updating an archive with files that already exist inside will create duplicates and cause problems during extraction
Function Compress-Archive {
param(
[Parameter(ValueFromPipeline=$true,Mandatory=$true)][string]$Path,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][string]$DestinationPath,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$Force=$false
)
BEGIN {
If ( !(Test-Path $Path ) ) { Write-Error "The specified path does not exist: ""${Path}""" ; Exit }
If ( [string]::IsNullOrEmpty($DestinationPath) ) {
Switch ( Get-Item -Path $Path ) {
{$_ -is [System.IO.DirectoryInfo]} { $DestinationPath = '{0}.zip' -f ($Path.TrimEnd('\'))}
{$_ -is [System.IO.FileInfo]} { $DestinationPath = '{0}\{1}.zip' -f (Split-Path $Path -Parent), ([System.IO.Path]::GetFileNameWithoutExtension($Path)) }
}
}
If ( ($Force) -and (Test-Path $DestinationPath) ) {
Write-Output "Deleting existing archive: ""${DestinationPath}"""
Remove-Item -Path $DestinationPath -Force -ErrorAction Stop
}
}
PROCESS {
If ( !($Force) -and (Test-Path $DestinationPath) ) { $CreateNewArchive = $false } Else { $CreateNewArchive = $true }
Try {
Add-Type -Assembly "System.IO.Compression"
Add-Type -Assembly "System.IO.Compression.FileSystem"
Switch ( $(Get-Item -Path $Path) ) {
{$_ -is [System.IO.DirectoryInfo]} {
If ( $CreateNewArchive ) {
Write-Output "Creating new zip archive: ""${DestinationPath}"""
[System.IO.Compression.ZipFile]::CreateFromDirectory($Path, $DestinationPath, [System.IO.Compression.CompressionLevel]::Optimal, $true)
} Else {
If ( (Get-Command Microsoft.Powershell.Archive\Compress-Archive -ErrorAction SilentlyContinue) ) {
Write-Output "Calling Microsoft util"
Microsoft.Powershell.Archive\Compress-Archive -Path $Path -DestinationPath $DestinationPath -Update -CompressionLevel Optimal
} Else {
Write-Error "Updating an existing zip archive with a folder structure is not currently supported"
}
}
}
{$_ -is [System.IO.FileInfo]} {
If ( $CreateNewArchive ) {
Write-Output "Creating new zip archive: ""${DestinationPath}"""
$ArchiveMode = [System.IO.Compression.ZipArchiveMode]::Create
} Else { $ArchiveMode = [System.IO.Compression.ZipArchiveMode]::Update }
[System.IO.Compression.ZipArchive]$DestinationArchive = [System.IO.Compression.ZipFile]::Open($DestinationPath, $ArchiveMode)
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($DestinationArchive, $Path, (Split-Path $Path -Leaf)) | Out-Null
}
}
}
Catch { Write-Error $_.Exception.Message }
Finally { If ( $DestinationArchive ) { $DestinationArchive.Dispose() } }
}
END {
If ( !$CreateNewArchive ) { Write-Output "Finished updating existing archive: ""${DestinationPath}""" }
If ( Test-Path $DestinationPath ) { Return Get-Item -Path $DestinationPath } Else { Return $null }
}
}
# Function to unzip a file or directory
# TODO: extracting an archive will fail if the destination file(s) already exists
Function Expand-Archive {
param(
[Parameter(ValueFromPipeline=$true,Mandatory=$true)][string]$Path,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][string]$DestinationPath,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$Force=$false
)
If ( !(Test-Path $Path) ) { Write-Output "The specified zip archive does not exist: ""${Path}""" ; Exit }
If ( [System.IO.Path]::GetExtension($Path) -ne ".zip" ) { Write-Output "The path specified is not a zip file: ""${Path}""" ; Exit }
If ( [string]::IsNullOrEmpty($DestinationPath) ) { $DestinationPath = Split-Path -Parent $Path }
Try {
Add-Type -Assembly "System.IO.Compression"
Add-Type -Assembly "System.IO.Compression.FileSystem"
Write-Output "Extracting ""${Path}"" to ""${DestinationPath}"""
[System.IO.Compression.ZipArchive]$SourceArchive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Read)
[System.IO.Compression.ZipFileExtensions]::ExtractToDirectory($SourceArchive, $DestinationPath)
}
Catch { Write-Error $_.Exception.Message }
Finally { If ( $SourceArchive ) { $SourceArchive.Dispose() } }
}
# Function to get a copy of the LiquidFiles CLI agent
Function Get-LiquidFilesCLI {
$url = 'https://lfupdate.s3.amazonaws.com/clients/windows_cli/LiquidFilesCLI-2.0.128.zip'
$zip_filename = Split-Path -Leaf $url
$zip_path = "${Global:ToolsDirectory}\${zip_filename}"
$exe_path = '{0}\{1}.exe' -f $Global:ToolsDirectory,[System.IO.Path]::GetFileNameWithoutExtension($zip_filename)
If ( Test-Path $zip_path ) {
Write-Output "Deleting existing zip archive: ${zip_path}"
Try { Remove-Item -Path $zip_path -Force }
Catch { Write-Error $_.Exception.Message }
}
If ( Test-Path $exe_path ) {
Write-Output "Deleting existing file: ${exe_path}"
Try { Remove-Item -Path $exe_path -Force }
Catch { Write-Error $_.Exception.Message }
}
Download-File -URL $url -File $zip_path | Out-Null
Expand-Archive $zip_path
Return $exe_path
}
# Function to determine if an app is running
Function IsRunning ([Parameter(ValueFromPipeline=$true)][string]$Name) {
$RunningInstances = Get-Process | Where-Object { $_.Name -ilike $Name }
If ( $RunningInstances ) { Return $true } Else { Return $false }
}
# Function to create a local user account
Function New-LocalAccount {
param(
[Parameter(Mandatory=$true)][string]$Username,
[Parameter(Mandatory=$true)][string]$Password,
[Parameter(Mandatory=$true)][string]$FullName,
[Parameter(Mandatory=$true)][string]$Description,
[Parameter(Mandatory=$false)][switch]$MakeAdmin=$false,
[Parameter(Mandatory=$false)][switch]$Hide=$false
)
$SecurePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
$Password = $null
If ( -not (IsAdmin) ) {
Write-Output "Cannot create or modify a local account without administrative privileges"
Return
}
If ( $(Get-CimInstance -ClassName Win32_OperatingSystem -Property ProductType | Select-Object -ExpandProperty ProductType) -eq "2" ) {
Write-Output "Cannot create or modify local accounts on a domain controller"
Return
}
If ( $(Get-LocalUser -Name $Username -ErrorAction SilentlyContinue) ) {
Write-Output "Updating user: ${Username}"
Try { Set-LocalUser -Name "${Username}" -Password $SecurePassword -FullName "${FullName}" -Description "${Description}" -AccountNeverExpires -PasswordNeverExpires | Out-Null }
Catch { Write-Error "Could not update user`n"+$_.Exception.Message ; Exit }
}
Else {
Write-Output "Creating user: ${Username}"
Try { New-LocalUser -Name "${Username}" -Password $SecurePassword -FullName "${FullName}" -Description "${Description}" -AccountNeverExpires -PasswordNeverExpires | Out-Null }
Catch { Write-Error "Could not create user`n"+$_.Exception.Message ; Exit }
}
If ( (-not($(Get-LocalGroupMember -Group "Administrators" -Member $Username -ErrorAction SilentlyContinue))) -and $MakeAdmin ) {
Write-Output "Adding ""${Username}"" to local Administrators group"
Try { Add-LocalGroupMember -Name "Administrators" -Member $(Get-LocalUser -Name "${Username}") }
Catch { Write-Error "Could not add user to Administrators group`n"+$_.Exception.Message ; Exit }
}
If ( $Hide ) {
$RegPath = "HKLM:SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList"
$HideUser = $true
If ( -not (Test-Path $RegPath) ) {
New-Item -Path $RegPath -Force | Out-Null
} Else {
$UserList = Get-ItemProperty -Path $RegPath
ForEach ( $User in $UserList.PSObject.Properties ) {
If ( $User.Name -ieq $Username ) {
Write-Output "The user ""${Username}"" is already hidden from the login screen"
$HideUser = $false
Break
}
}
}
If ( $HideUser ) { New-ItemProperty -Path $RegPath -Name $Username -Value 0 -PropertyType DWord -Force | Out-Null }
}
}
# Returns the number of MB a process is consuming
Function Get-MemoryUsage ([string]$AppName) {
[math]::Round((Get-Process -Name $AppName -ErrorAction SilentlyContinue | Measure-Object -Property WorkingSet -Sum).Sum / 1MB)
}
# Kills a process if it's using more than the specified amount of memory
Function Stop-MemoryHog ([string]$AppName,[int]$MemoryLimit) {
$MemoryUsed = (Get-MemoryUsage -AppName $AppName)
If ( $MemoryUsed -gt $MemoryLimit ) {
$MemoryDifference = $MemoryUsed - $MemoryLimit
Write-Output "Killing ""${AppName}"" for using ${MemoryDifference}MB over the limit of ${MemoryLimit}MB"
Try { Get-Process -Name $AppName -ErrorAction SilentlyContinue | Stop-Process -Force }
Catch { Write-Error $_.Exception.Message }
}
}
# Returns a formatted list of the largest or smallest files in a directory
Function Get-FileSizes {
param(
[Parameter(ValueFromPipeline=$true)][string]$Path,
[switch]$Recurse,
[switch]$IncludeHidden,
[Parameter(ParameterSetName="largest")][int]$Largest,
[Parameter(ParameterSetName="smallest")][int]$Smallest,
[string]$Units,
[int]$Precision=2
)
# Fix drive case so output looks nicer
Switch ( $Path.Length ) {
1 { $Path = $Path.ToUpper() }
{ $_ -gt 1 } {
$split = $Path.Split(":")
$Path = $split[0].ToUpper() + ":"
If ( $split[1] ) { $Path += $split[1] }
}
}
# If no path is specified, set $Path to the directory containing user profiles
If (-not $Path ) {
$Path = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -Name ProfilesDirectory).ProfilesDirectory
$Recurse = $true
$IncludeHidden = $false
}
# If the path is not an absolute path, make it so
ElseIf ( -not (Test-Path $Path) ) {
$NewPath = $Path[0] + ":\"
If ( -not (Test-Path $NewPath) ) {
Write-Error "Could not find path: ${Path}"
Return
}
$Path = $NewPath
}
# Change the sort order depending on which parameter set was specified
Switch ( $PSCmdlet.ParameterSetName ) {
"largest" { $SortOrder = $true ; $Quantity = $Largest }
"smallest" { $SortOrder = $false ; $Quantity = $Smallest }
}
# Set the units to show sizes in output
Switch ( $Units.ToUpper() ) {
"KB" { $UnitLabel = "Kilobytes (KB)" ; $Divisor = 1KB }
"MB" { $UnitLabel = "Megabytes (MB)" ; $Divisor = 1MB }
"GB" { $UnitLabel = "Gigabytes (GB)" ; $Divisor = 1GB }
"TB" { $UnitLabel = "Terabytes (TB)" ; $Divisor = 1TB }
default { $UnitLabel = "Bytes" ; $Divisor = 1 }
}
# Get the file paths and sizes as specified
$FileSizes = Get-ChildItem -LiteralPath "\\?\${Path}" -Recurse:$Recurse -Force:$IncludeHidden -ErrorAction SilentlyContinue | `
Where-Object { ! $_.PSIsContainer -and ($_.FullName.Length -lt 260)} | `
Sort-Object -Descending:$SortOrder -Property Length | `
Select-Object -First $Quantity @{Name="File Path";Expression={[IO.Path]::Combine($_.DirectoryName, $_.Name)}}, @{Name=$UnitLabel;Expression={[Math]::Round($_.Length / $Divisor, $Precision)}}
# Set output prefix
$prefix = "The ${Quantity} " + $PSCmdlet.ParameterSetName + " files found at ""${Path}"""
# Output to console
$constr = "${prefix}" + $($FileSizes | Out-String)
Microsoft.Powershell.Utility\Write-Output $constr
# Output to log file
$logstr = "${prefix}" + $($FileSizes | Format-List | Out-String)
Write-Output $logstr
}
# Function to write text to a file in UTF8 encoding without BOM
Function WriteToFile_UTF8NoBOM {
param(
[Parameter(ValueFromPipeline=$false)][string]$FilePath,
[Parameter(ValueFromPipeline=$true)][string]$Value
)
$UTF8NoBOM = New-Object System.Text.UTF8Encoding $false
Try { [IO.File]::WriteAllText($FilePath, $CONFIG, $UTF8NoBOM) }
Catch { Write-Error $_.Exception.Message }
}
# Function to enumerate all user profile folders
Function Get-UserProfiles {
$ProfilePath = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -Name ProfilesDirectory).ProfilesDirectory
$UserProfilePaths = @()
ForEach ( $userProfile in (Get-ChildItem -Path $ProfilePath | Where-Object { $_.PSIsContainer }) ) {
$UserProfilePaths += ,@($userProfile.FullName)
}
Return $UserProfilePaths
}
# Function to test for the presense of a Registry property
Function Test-RegistryProperty {
param(
[Parameter(Mandatory=$true)][string]$Path,
[Parameter(Mandatory=$true)][string]$Name
)
Try {
Get-ItemProperty -Path $Path | Select-Object -ExpandProperty $Name -ErrorAction Stop | Out-Null
Return $true
}
Catch { Return $false }
}
## Function to format a quantity of bytes into a denomination
#Function Format-ByteQuantity {
# param(
# [int64]$TotalBytes,
# [int]$Precision=2,
# [string]$Units,
# [switch]$WithUnitLabel
# )
# Switch ( $Units.ToUpper() ) {
# "KB" { $UnitLabel = "KB" ; $Divisor = 1KB }
# "MB" { $UnitLabel = "MB" ; $Divisor = 1MB }
# "GB" { $UnitLabel = "GB" ; $Divisor = 1GB }
# "TB" { $UnitLabel = "TB" ; $Divisor = 1TB }
# default { $UnitLabel = "bytes" ; $Divisor = 1 }
# }
# Switch ( $WithUnitLabel ) {
# $true { Return (ZeroPad-Decimal -Amount $([Math]::Round($TotalBytes / $Divisor,$Precision)) -NeededDecimalPlaces $Precision) + "${UnitLabel}" }
# $false { Return (ZeroPad-Decimal -Amount $([Math]::Round($TotalBytes / $Divisor,$Precision)) -NeededDecimalPlaces $Precision) }
# }
#}
#
## Function to pad zeros onto the end of a decimal number
#Function ZeroPad-Decimal {
# param(
# $Amount,
# $NeededDecimalPlaces
# )
# $splitNumbers = $Amount.ToString().Split('.')
# $paddedDecimal = $splitNumbers[1].PadRight($NeededDecimalPlaces, '0')
# Return $splitNumbers[0] + '.' + $paddedDecimal
#}
# Function to follow a URL and return the absolute URI of the result (whether redirected or not)
Function Get-AbsoluteURI {
param([string]$URL)
If ( !(IsURLValid $URL) ) { Return }
Return [System.Net.HttpWebRequest]::Create($URL).GetResponse().ResponseUri.AbsoluteUri
}
Function Get-GUIDFromMSIUninstallString ([string]$str) {
$start = $str.IndexOf('{') + 1
$end = $str.IndexOf('}')
$retval = $str.Substring($start, $end - $start)
If ( $retval.Length -ne 36 ) { $retval = $null }
Return $retval
}
Function Get-GUIDFromMSIUninstallString ([string]$str) {
$start = $str.IndexOf('{') + 1
$end = $str.IndexOf('}')
$retval = $str.Substring($start, $end - $start)
If ( $retval.Length -ne 36 ) { $retval = $null }
Return $retval
}
# Function to uninstall an MSI package based on it's name
Function Uninstall-MSI {
param(
[Parameter(ValueFromPipeline=$true,Mandatory=$true)][string[]]$APP_NAMES,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$Match,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$IncludeNonMSI
)
BEGIN {
# Get all of the uninstall registry keys
$UNINSTALL_KEYS = Get-ChildItem -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall -ErrorAction SilentlyContinue
$UNINSTALL_KEYS += Get-ChildItem -Path HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall -ErrorAction SilentlyContinue
# Exit if we can't find any software at all
If ( $UNINSTALL_KEYS.Length -eq 0 ) { Write-Error "Could not find any installed apps in the Windows Registry" ; Return }
# Array to store custom objects about each installed MSI app
$INSTALLED_APPS = @()
Foreach ( $reg in $UNINSTALL_KEYS ) {
# Get the uninstall key
$REG_PROPERTY = Get-ItemProperty -Path $reg.PSPath
# Skip apps missing both the UninstallString and ModifyPath
If ( [string]::IsNullOrEmpty($REG_PROPERTY.UninstallString) -and [string]::IsNullOrEmpty($REG_PROPERTY.ModifyPath) ) { Continue }
# Determine if the app is installed via MSI
If ( $REG_PROPERTY.WindowsInstaller -eq 1 ) {
$MSI = $true
# Get the product code from the UninstallString
If ( ![string]::IsNullOrEmpty($REG_PROPERTY.UninstallString) ) { $PRODUCT_CODE = $(Get-GUIDFromMSIUninstallString -str $REG_PROPERTY.UninstallString) }
# Get the product code from the ModifyPath
ElseIf ( ![string]::IsNullOrEmpty($REG_PROPERTY.ModifyPath) ) { $PRODUCT_CODE = $(Get-GUIDFromMSIUninstallString -str $REG_PROPERTY.ModifyPath) }
# Don't add an app with an unknown product code
If ( [string]::IsNullOrEmpty($PRODUCT_CODE) ) { Continue }
} Else { $MSI = $false }
# Get a usable name for the entry
If ( ![string]::IsNullOrEmpty($REG_PROPERTY.DisplayName) ) {
$AppName = $REG_PROPERTY.DisplayName
} ElseIf ( ![string]::IsNullOrEmpty($REG_PROPERTY.Name) ) {
$AppName = $REG_PROPERTY.DisplayName
} Else {
$AppName = '[unknown]'
}
$customObject = [PSCustomObject]@{
MSI = $MSI
DisplayName = $AppName
UninstallString = $REG_PROPERTY.UninstallString
ModifyPath = $REG_PROPERTY.ModifyPath
ProductCode = $PRODUCT_CODE
}
# TODO: Cleanup object properties
$INSTALLED_APPS += $customObject
}
}
PROCESS {
Foreach ( $app in $APP_NAMES ) {
Foreach ( $installed_app in $INSTALLED_APPS ) {
$uninstall = $false
# Do not add apps whose name does not match the one we're looking for
Switch ( $Match ) {
$true { If ( $installed_app.DisplayName -match $app ) { $uninstall = $true } }
$false { If ( $installed_app.DisplayName -ilike $app ) { $uninstall = $true } }
}
# Print out uninstall string for unsupported apps
If ( ($uninstall) -and ($installed_app.MSI -eq $false) ) {
# Skip apps that have no display name or uninstall string
If ( ([string]::IsNullOrEmpty($DisplayName)) -and ([string]::IsNullOrEmpty($UninstallString)) ) { $uninstall = $false ; Continue }
Write-Output "Cannot uninstall ""${DisplayName}""`n UninstallString: ${UninstallString}"
$uninstall = $false
}
If ( $uninstall ) {
$DisplayName = $installed_app.DisplayName
$UninstallString = $installed_app.UninstallString
$ProductCode = $installed_app.ProductCode
# Uninstall the app
Write-Output "Uninstalling ""${DisplayName}"" with product code: ${ProductCode}"
$arguments = '/X{' + $ProductCode + '} /qn /norestart'
Try { Start-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait }
Catch { Write-Error $_.Exception.Message }
}
}
}
}
}
# Reboot an endpoint based on the amount of time it has been running without a reboot
Function Restart-EndpointOnUptime ([timespan]$MaxUptime) {
# Get the length of time the endpoint has been running without restart
Try { $LastBootTime = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime }
Catch { Write-Error $_.Exception.Message ; Exit }
# Get the difference between the endpoint uptime and the current time
$Delta = New-TimeSpan -Start $LastBootTime -End $(Get-Date)
# Stop the function if the endpoint has not exceeded $MaxUptime
If ( $Delta -lt $MaxUptime ) { Exit }
# Reboot the endpoint
Try { Restart-Computer -Force -ErrorAction Stop }
Catch { Write-Error $_.Exception.Message }
}
# Function to download and install local tools used by scripts
Function Install-LocalTools () {
param(
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$Force=$false
)
$ToolURLs = @(
'https://xfer.emberkom.com/shares/tools/folders/9b9988fb-c53a-4833-a4ad-68d01a3021bb/files/bluescreenview-x64-exe/download/BlueScreenView-x64.exe'
'https://xfer.emberkom.com/shares/tools/folders/9b9988fb-c53a-4833-a4ad-68d01a3021bb/files/bluescreenview-x86-exe/download/BlueScreenView-x86.exe'
'https://xfer.emberkom.com/shares/tools/folders/9b9988fb-c53a-4833-a4ad-68d01a3021bb/files/etl2pcapng-exe/download/etl2pcapng.exe'
)
Foreach ( $ToolURL in $ToolURLs ) {
$ToolName = Split-Path -Leaf $ToolURL
$ToolPath = $Global:ToolsDirectory + '\' + $ToolName
If ( (Test-Path $ToolPath) -and ($Force -eq $false) ) { Continue }
Write-Output "Installing ${ToolName}"
Download-File -URL $ToolURL -File $ToolPath
}
}
Function Source-PSScript ([string]$ScriptName) {
If ( $ScriptName.StartsWith('http://') -or $ScriptName.StartsWith('https://') ) {
$URL = $ScriptName
} Else {
$URL = "https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/${ScriptName}.ps1"
}
$ScriptFile = Download-File -URL $URL
Write-Output "Sourcing ${ScriptFile}"
. $ScriptFile
If ( Test-Path $ScriptFile ) { Write-Output "Removing ${ScriptFile}" ; Remove-Item $ScriptFile -Force -ErrorAction SilentlyContinue }
}
# Forcefully maps a network drive but requires user's credentials to already exist in Credential Manager
Function Create-NetworkDrive {
param (
[Parameter(Mandatory)]
[ValidatePattern("^[A-Z]:$")]
[string]$DriveLetter,
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string]$Label
)
$existingDrive = Get-CimInstance Win32_LogicalDisk | Where-Object { $_.DeviceID -eq $DriveLetter }
if (-not $existingDrive) {
Write-Output "Mapping ${Path} to drive letter ${DriveLetter}"
cmd.exe /c "net use $DriveLetter $Path /persistent:yes" | Out-Null
}
elseif ($existingDrive.ProviderName -ne $Path) {
Write-Output "Drive exists but points to a different path. Remapping..."
cmd.exe /c "net use $DriveLetter /delete /y" | Out-Null
cmd.exe /c "net use $DriveLetter $Path /persistent:yes" | Out-Null
}
else { Write-Verbose "Drive already mapped correctly." }
Start-Sleep -Seconds 2
try {
$dl = $DriveLetter.TrimEnd(':')
$vol = Get-Volume -DriveLetter $dl -ErrorAction Stop
if ($vol.FileSystemLabel -ne $Label) {
Write-Output "Setting ${DriveLetter} drive label to ""${Label}"""
Set-Volume -DriveLetter $dl -NewFileSystemLabel $Label
}
}
catch { Write-Verbose "Could not set volume label (possibly unsupported context)." }
}
function Install-SystemFont {
[CmdletBinding(SupportsShouldProcess=$true)]
param (
[Parameter(Mandatory=$true)]
[ValidateNotNullOrEmpty()]
[string]$FontPath
)
try {
$fontFile = Get-Item -LiteralPath $FontPath -ErrorAction Stop
}
catch {
Write-Error "Font file '$FontPath' could not be found or accessed: $($_.Exception.Message)"
return
}
if ($fontFile.PSIsContainer) {
Write-Error "Font path '$FontPath' points to a directory. Specify a .otf or .ttf file."
return
}
$extension = $fontFile.Extension.ToLowerInvariant()
if ($extension -notin @('.otf', '.ttf')) {
Write-Error "Only .otf and .ttf font files are supported."
return
}
# Read the font's embedded family and face names instead of relying on
# language- and Windows-version-dependent Shell property column numbers.
try {
Add-Type -AssemblyName PresentationCore -ErrorAction Stop
$glyphTypeface = New-Object System.Windows.Media.GlyphTypeface ([Uri]$fontFile.FullName)
$englishLanguage = [System.Globalization.CultureInfo]::GetCultureInfo('en-US')
$fontFamily = $null
$fontFace = $null
if (-not $glyphTypeface.FamilyNames.TryGetValue($englishLanguage, [ref]$fontFamily)) {
$fontFamily = $glyphTypeface.FamilyNames.Values | Select-Object -First 1
}
if (-not $glyphTypeface.FaceNames.TryGetValue($englishLanguage, [ref]$fontFace)) {
$fontFace = $glyphTypeface.FaceNames.Values | Select-Object -First 1
}
if ([string]::IsNullOrWhiteSpace($fontFamily)) {
throw "The font does not contain a readable family name."
}
if ([string]::IsNullOrWhiteSpace($fontFace) -or $fontFace -in @('Normal', 'Regular')) {
$fontName = $fontFamily
}
else {
$fontName = "$fontFamily $fontFace"
}
}
catch {
Write-Error "Font metadata could not be read from '$($fontFile.FullName)': $($_.Exception.Message)"
return
}
$fileName = $fontFile.Name
$fontsDirectory = Join-Path $env:SystemRoot 'Fonts'
$destinationPath = Join-Path $fontsDirectory $fileName
$registryValueName = if ($extension -eq '.otf') {
"$fontName (OpenType)"
}
else {
"$fontName (TrueType)"
}
try {
if (-not ('Emberkom.NativeFontMethods' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
namespace Emberkom
{
public static class NativeFontMethods
{
[DllImport("gdi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern int AddFontResource(string fileName);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr SendMessageTimeout(
IntPtr windowHandle,
uint message,
UIntPtr wParam,
IntPtr lParam,
uint flags,
uint timeout,
out UIntPtr result
);
}
}
'@ -ErrorAction Stop
}
}
catch {
Write-Error "Windows font installation support could not be initialized: $($_.Exception.Message)"
return
}
if (-not $PSCmdlet.ShouldProcess($destinationPath, "Install system font '$fontName'")) {
return
}
try {
$copyRequired = $true
if (Test-Path -LiteralPath $destinationPath) {
$sourceHash = (Get-FileHash -LiteralPath $fontFile.FullName -Algorithm SHA256 -ErrorAction Stop).Hash
$destinationHash = (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256 -ErrorAction Stop).Hash
$copyRequired = $sourceHash -ne $destinationHash
if (-not $copyRequired) {
Write-Output "Font file '$fileName' is already present in '$fontsDirectory'."
}
}
if ($copyRequired) {
Copy-Item -LiteralPath $fontFile.FullName -Destination $destinationPath -Force -ErrorAction Stop
Write-Output "Copied font file '$fileName' to '$fontsDirectory'."
}
$fontRegistryKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey(
'SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts',
$true
)
if ($null -eq $fontRegistryKey) {
throw "The system Fonts registry key could not be opened for writing."
}
try {
$fontRegistryKey.SetValue(
$registryValueName,
$fileName,
[Microsoft.Win32.RegistryValueKind]::String
)
}
finally {
$fontRegistryKey.Dispose()
}
Write-Output "Registered '$registryValueName' as a system font."
$fontsAdded = [Emberkom.NativeFontMethods]::AddFontResource($destinationPath)
if ($fontsAdded -eq 0) {
throw "Windows did not load '$destinationPath' into the current session."
}
Write-Output "Loaded '$fontName' into the current Windows session."
# Notify applications in this session without allowing a hung window to
# block an unattended RMM script indefinitely.
$broadcastHandle = [IntPtr]0xffff
$fontChangeMessage = 0x001D
$abortIfHung = 0x0002
$messageResult = [UIntPtr]::Zero
$notificationSent = [Emberkom.NativeFontMethods]::SendMessageTimeout(
$broadcastHandle,
$fontChangeMessage,
[UIntPtr]::Zero,
[IntPtr]::Zero,
$abortIfHung,
1000,
[ref]$messageResult
)
if ($notificationSent -eq [IntPtr]::Zero) {
Write-Output "Installed system font '$fontName', but no application acknowledged the font-change notification."
return
}
Write-Output "Installed system font '$fontName' successfully."
}
catch {
Write-Error "Failed to install font '$($fontFile.FullName)': $($_.Exception.Message)"
}
}
# Usage Example:
# Install-SystemFont -FontPath "C:\Path\To\YourFont.otf"
# Function to import the Syncro Module
Function Import-RMMModule () {
Try { Import-Module $env:SyncroModule -WarningAction SilentlyContinue -ErrorAction Stop }
Catch { Write-Error $_.Exception.Message ; Return }
}
# Function to create a new ticket
Function New-RMMTicket () {
param(
[Parameter(ValueFromPipeline=$false,Mandatory=$true)][string]$Subject,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][string]$IssueType="Remote Support",
[Parameter(ValueFromPipeline=$false,Mandatory=$true)][string]$InitialIssue
)
If ( !(Get-Command Create-Syncro-Ticket -ErrorAction SilentlyContinue) ) { Import-RMMModule}
Try { $Ticket = Create-Syncro-Ticket -Subject $Subject -IssueType $IssueType -Status "New" }
Catch { Write-Error $_.Exception.Message ; Return }
$Global:TicketNumber = $Ticket.ticket.id
New-TicketComment -TicketID $Global:TicketNumber -Subject "Issue" -Comment $InitialIssue -Hidden -DoNotEmail
}
# Function to create a new comment on a ticket
Function New-TicketComment () {
param(
[Parameter(ValueFromPipeline=$false,Mandatory=$false)]$TicketID=$null,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][string]$Subject="Update",
[Parameter(ValueFromPipeline=$true,Mandatory=$true)][string]$Comment,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$Hidden=$false,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$DoNotEmail=$false
)
If ( !(Get-Command Create-Syncro-Ticket-Comment -ErrorAction SilentlyContinue) ) { Import-RMMModule}
If ( ($null -eq $TicketID) -and ($null -eq $Global:TicketNumber) ) { Return }
If ( ($null -eq $TicketID) -and ($null -ne $Global:TicketNumber) ) { $TicketID = $Global:TicketNumber }
Try { Create-Syncro-Ticket-Comment -TicketIdOrNumber $TicketID -Subject $Subject -Body $Comment -Hidden $Hidden -DoNotEmail $DoNotEmail }
Catch { Write-Error $_.Exception.Message ; Return }
}
# Function to create a new billable time entry on a ticket
Function New-TicketTimerEntry () {
param(
[Parameter(ValueFromPipeline=$false,Mandatory=$false)]$TicketID=$null,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][int]$BillableMinutes=15,
[Parameter(ValueFromPipeline=$true,Mandatory=$true)][string]$Comment,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][string]$BillableUser=$Global:TimeAttributedToUser,
[Parameter(ValueFromPipeline=$false,Mandatory=$false)][switch]$ChargeLater=$false
)
If ( $BillableUser -eq "" ) { Write-Error "Billable user not specified" ; Return }
If ( !(Get-Command Create-Syncro-Ticket-TimerEntry -ErrorAction SilentlyContinue) ) { Import-RMMModule}
If ( $ChargeLater -eq $true ) { $ChargeTime = "false" } Else { $ChargeTime = "true" }
$StartAt = (Get-Date).AddMinutes(-$BillableMinutes).ToString("o")
Create-Syncro-Ticket-TimerEntry -TicketIdOrNumber $TicketID -StartTime $StartAt -DurationMinutes $BillableMinutes -Notes $Comment -UserIdOrEmail $BillableUser -ChargeTime $ChargeTime
}
# Function to get the SHA256 hash of a string
Function Get-StringHash() {
param([Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$String)
$stream = [IO.MemoryStream]::new([byte[]][char[]]$String)
$hashObj = Get-FileHash -InputStream $stream -Algorithm SHA256
$hashObj.Hash
}
# Function to enable or disable the profile prompt when launching Outlook
Function Set-OutlookProfilePrompt () {
param(
[Parameter(ValueFromPipeline=$false,Mandatory=$true,ParameterSetName='enable')][switch]$Enable,
[Parameter(ValueFromPipeline=$false,Mandatory=$true,ParameterSetName='disable')][switch]$Disable
)
If ( $Enable ) { $val = '1' ; $action = "Enabling"}
If ( $Disable ) { $val = '0' ; $action = "Disabling"}
Write-Output "${action} profile picker on Outlook launch"
[Microsoft.Win32.Registry]::SetValue('HKEY_CURRENT_USER\SOFTWARE\Microsoft\Exchange\Client\Options','PickLogonProfile',$val,[Microsoft.Win32.RegistryValueKind]::String)
$Profiles = (Get-ChildItem -Path 'HKCU:\SOFTWARE\Microsoft\Office\16.0\Outlook\Profiles' | Select-Object -ExpandProperty Name | Split-Path -Leaf | Sort-Object) -join "`n"
Write-Output "Available Outlook profiles:`n${Profiles}"
}
# Function to install and run script using Powershell 7
# Provided by j.mcbride from https://community.syncromsp.com/t/powershell-7-2/7425/5
Function Start-ScriptInPowershell7 () {
# 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-Error $_.Exception.Message }
Finally { Exit $LASTEXITCODE }
}
# Input the actual script below this line
}
# Upgrade default TLS version to 1.2
Try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }
Catch { Write-Error $_.Exception.Message }
# Initial setup of the MSP management directories and log file
Setup-MSPDirectories
Setup-LogFile
# Install the local tools
#Install-LocalTools
# Check the RMM runtime variable to see if a billable user was specified, if so then set the $TimeAttributedToUser
If ( $TimeAttributedToUser ) {
If ( $TimeAttributedToUser.Trim().Length -ge 6 ) { $Global:TimeAttributedToUser = $TimeAttributedToUser.Trim().ToLower() }
}
# Check the RMM runtime variable to see if a ticket number was specified, if so then set the $TicketID
If ( $TicketNumber ) {
If ( $TicketNumber.Trim().Length -gt 1 ) { $Global:TicketNumber = $TicketNumber.Trim() }
}