# 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() } }