Загрузка данных
#requires -version 5.1
<#
.SYNOPSIS
Удаление лишних клиентов MTS Link через SCCM.
.DESCRIPTION
ОСТАВЛЯЕТ:
- MTS Link Meetings 1.6.0.0
- MTSLinkMeetings.InstallerMSI 1.6.0.0
(внутренний MSI-компонент Meetings 1.6.0.0)
УДАЛЯЕТ:
- старые MTS Link Meetings
- MTS Link Outlook Plugin
- МТС Линк
- MTS Link
- MTSLink...
- пользовательские установки MTS Link
Особенности:
- НИЧЕГО НЕ УСТАНАВЛИВАЕТ.
- НИКОГДА не вызывает Restart-Computer / shutdown / reboot.
- MSI удаляются с:
/qn /norestart REBOOT=ReallySuppress
- код MSI 3010 считается успехом, но наружу SCCM не возвращается.
- код 1641 НЕ считается нормальным.
- пользовательские /currentuser uninstall запускаются
в контексте соответствующего залогиненного пользователя.
- Win32_Product НЕ используется.
- после удаления выполняется одна итоговая проверка.
.RECOMMENDED
SCCM:
Run whether or not a user is logged on
Run with administrative rights
Prefer 64-bit PowerShell
.EXITCODES
0 = успешно, лишние MTS Link удалены
10 = после удаления остались лишние MTS Link
20 = пропал защищённый Meetings 1.6.0.0 / его компонент
30 = какой-либо uninstall вернул 1641 (restart initiated)
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Continue'
# =====================================================================
# CONFIG
# =====================================================================
$OutputDirectory = 'C:\ProgramData\MTSLink-Remediation'
$KeepMeetingsName = 'MTS Link Meetings'
$KeepMeetingsVersion = '1.6.0.0'
# Внутренний MSI сохранённой версии Meetings.
$KeepInternalName = 'MTSLinkMeetings.InstallerMSI'
$KeepInternalVersion = '1.6.0.0'
# Небольшая пауза перед финальной проверкой реестра.
$VerificationDelaySeconds = 5
# =====================================================================
# NATIVE SYSTEM32
#
# Если SCCM случайно запустил 32-bit PowerShell на 64-bit Windows,
# используем Sysnative для системных утилит.
# =====================================================================
if ($env:PROCESSOR_ARCHITEW6432) {
$NativeSystemDirectory =
Join-Path $env:SystemRoot 'Sysnative'
}
else {
$NativeSystemDirectory =
Join-Path $env:SystemRoot 'System32'
}
$NativeMsiexec =
Join-Path $NativeSystemDirectory 'msiexec.exe'
$NativeRegExe =
Join-Path $NativeSystemDirectory 'reg.exe'
# Для Scheduled Task, запускаемой самим Windows Task Scheduler,
# используем обычный System32.
$TaskMsiexec =
Join-Path $env:SystemRoot 'System32\msiexec.exe'
# =====================================================================
# OUTPUT / LOG
# =====================================================================
try {
[System.IO.Directory]::CreateDirectory(
$OutputDirectory
) | Out-Null
}
catch {
Write-Output "FATAL: cannot create $OutputDirectory"
Write-Output $_.Exception.Message
exit 1
}
$Timestamp =
Get-Date -Format 'yyyyMMdd_HHmmss'
$LogPath =
Join-Path `
$OutputDirectory `
"MTSLink_Remediation_$Timestamp.log"
$InitialCsvPath =
Join-Path `
$OutputDirectory `
"MTSLink_Before_$Timestamp.csv"
$FinalCsvPath =
Join-Path `
$OutputDirectory `
"MTSLink_After_$Timestamp.csv"
$script:RebootRequiredReported = $false
$script:RestartInitiatedCode = $false
function Write-Log {
param(
[string]$Message,
[ValidateSet(
'INFO',
'WARNING',
'ERROR',
'SUCCESS'
)]
[string]$Level = 'INFO'
)
$line =
'{0} [{1}] {2}' -f `
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),
$Level,
$Message
try {
Add-Content `
-LiteralPath $LogPath `
-Value $line `
-Encoding UTF8 `
-ErrorAction SilentlyContinue
}
catch {
}
Write-Host $line
}
Write-Log '============================================================'
Write-Log 'MTS Link cleanup started'
Write-Log "Computer: $env:COMPUTERNAME"
Write-Log (
'Running as: ' +
[Security.Principal.WindowsIdentity]::GetCurrent().Name
)
Write-Log 'Installation of new software: DISABLED / NOT PRESENT'
Write-Log 'Automatic OS reboot: DISABLED'
Write-Log '============================================================'
# =====================================================================
# REGISTRY HELPERS
# =====================================================================
function Get-RegValue {
param(
[Microsoft.Win32.RegistryKey]$Key,
[string]$Name
)
try {
return $Key.GetValue(
$Name,
$null,
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
)
}
catch {
return $null
}
}
function Get-MsiGuid {
param(
[string]$KeyName,
[string]$CommandText
)
if (
$KeyName -match
'^\{[0-9A-Fa-f-]{36}\}$'
) {
return $KeyName
}
if (
$CommandText -match
'\{[0-9A-Fa-f-]{36}\}'
) {
return $Matches[0]
}
return $null
}
# =====================================================================
# PRODUCT CLASSIFICATION
# =====================================================================
function Test-IsMtsProduct {
param(
[string]$DisplayName
)
if (
[string]::IsNullOrWhiteSpace(
$DisplayName
)
) {
return $false
}
# MTS Link...
# MTSLink...
# МТС Линк...
if (
$DisplayName -match
'(?i)^(MTS\s*Link|МТС\s*Линк)'
) {
return $true
}
return $false
}
function Test-IsProtectedProduct {
param(
$App
)
# ---------------------------------------------------------
# Главный продукт Meetings 1.6.0.0
# ---------------------------------------------------------
if (
$App.DisplayName -ieq
$KeepMeetingsName -and
$App.DisplayVersion -eq
$KeepMeetingsVersion
) {
return $true
}
# ---------------------------------------------------------
# Его внутренний MSI.
#
# В нашем аудите:
#
# MTSLinkMeetings.InstallerMSI
# 1.6.0.0
# {F43F17EB-80C9-4471-B411-97CF3D88D05E}
#
# Удалять отдельно его НЕЛЬЗЯ.
# ---------------------------------------------------------
if (
$App.DisplayName -ieq
$KeepInternalName -and
$App.DisplayVersion -eq
$KeepInternalVersion
) {
return $true
}
return $false
}
# =====================================================================
# REG.EXE
# =====================================================================
function Invoke-RegExe {
param(
[string]$Arguments
)
$result = [ordered]@{
ExitCode = -1
StdOut = ''
StdErr = ''
}
try {
$psi =
New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName =
$NativeRegExe
$psi.Arguments =
$Arguments
$psi.UseShellExecute =
$false
$psi.CreateNoWindow =
$true
$psi.RedirectStandardOutput =
$true
$psi.RedirectStandardError =
$true
$process =
New-Object System.Diagnostics.Process
$process.StartInfo =
$psi
[void]$process.Start()
$stdout =
$process.StandardOutput.ReadToEnd()
$stderr =
$process.StandardError.ReadToEnd()
$process.WaitForExit()
$result.ExitCode =
$process.ExitCode
$result.StdOut =
$stdout.Trim()
$result.StdErr =
$stderr.Trim()
$process.Dispose()
}
catch {
$result.StdErr =
$_.Exception.Message
}
return [PSCustomObject]$result
}
# =====================================================================
# USER HIVES
# =====================================================================
function Test-HiveLoaded {
param(
[string]$SID
)
$base = $null
$key = $null
try {
$base =
[Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::Users,
[Microsoft.Win32.RegistryView]::Registry64
)
$key =
$base.OpenSubKey(
$SID
)
return (
$null -ne $key
)
}
catch {
return $false
}
finally {
if ($key) {
$key.Dispose()
}
if ($base) {
$base.Dispose()
}
}
}
# =====================================================================
# USER PROFILES
# =====================================================================
function Get-RealUserProfiles {
$profiles =
New-Object System.Collections.Generic.List[object]
$base = $null
$list = $null
try {
$base =
[Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64
)
$list =
$base.OpenSubKey(
'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
)
if (-not $list) {
Write-Log `
'ProfileList registry key not found.' `
'ERROR'
return
}
foreach (
$sid in $list.GetSubKeyNames()
) {
# -------------------------------------------------
# SYSTEM / Local Service / Network Service
# -------------------------------------------------
if (
$sid -in @(
'S-1-5-18',
'S-1-5-19',
'S-1-5-20'
)
) {
continue
}
$profileKey = $null
try {
$profileKey =
$list.OpenSubKey(
$sid
)
if (-not $profileKey) {
continue
}
$profilePath =
[string]$profileKey.GetValue(
'ProfileImagePath'
)
if (
[string]::IsNullOrWhiteSpace(
$profilePath
)
) {
continue
}
$profilePath =
[Environment]::ExpandEnvironmentVariables(
$profilePath
)
# -------------------------------------------------
# Не лезем в:
# C:\Windows\ServiceProfiles
# и прочие системные профили Windows.
# -------------------------------------------------
if (
$profilePath.StartsWith(
$env:SystemRoot,
[System.StringComparison]::OrdinalIgnoreCase
)
) {
continue
}
$ntUserDat =
Join-Path `
$profilePath `
'NTUSER.DAT'
$isLoaded =
Test-HiveLoaded `
$sid
if (
-not $isLoaded -and
-not (
Test-Path `
-LiteralPath $ntUserDat
)
) {
continue
}
[void]$profiles.Add(
[PSCustomObject]@{
SID =
$sid
ProfilePath =
$profilePath
NTUserDat =
$ntUserDat
}
)
}
catch {
Write-Log (
"Cannot enumerate profile ${sid}: " +
$_.Exception.Message
) 'WARNING'
}
finally {
if ($profileKey) {
$profileKey.Dispose()
}
}
}
}
catch {
Write-Log (
'Profile enumeration failed: ' +
$_.Exception.Message
) 'ERROR'
}
finally {
if ($list) {
$list.Dispose()
}
if ($base) {
$base.Dispose()
}
}
$profiles
}
# =====================================================================
# READ ONE UNINSTALL ROOT
# =====================================================================
function Read-UninstallRoot {
param(
[Microsoft.Win32.RegistryHive]$Hive,
[Microsoft.Win32.RegistryView]$View,
[string]$RegistryPath,
[string]$DisplayRegistryPath,
[ValidateSet(
'Machine',
'User'
)]
[string]$Scope,
[string]$SID,
[string]$ProfilePath,
[string]$RegistryViewText
)
$items =
New-Object System.Collections.Generic.List[object]
$base = $null
$root = $null
try {
$base =
[Microsoft.Win32.RegistryKey]::OpenBaseKey(
$Hive,
$View
)
$root =
$base.OpenSubKey(
$RegistryPath
)
if (-not $root) {
return
}
foreach (
$subKeyName
in $root.GetSubKeyNames()
) {
$app = $null
try {
$app =
$root.OpenSubKey(
$subKeyName
)
if (-not $app) {
continue
}
$displayName =
[string](
Get-RegValue `
-Key $app `
-Name 'DisplayName'
)
if (
-not (
Test-IsMtsProduct `
$displayName
)
) {
continue
}
$displayVersion =
[string](
Get-RegValue `
-Key $app `
-Name 'DisplayVersion'
)
$publisher =
[string](
Get-RegValue `
-Key $app `
-Name 'Publisher'
)
$installLocation =
[string](
Get-RegValue `
-Key $app `
-Name 'InstallLocation'
)
$uninstallString =
[string](
Get-RegValue `
-Key $app `
-Name 'UninstallString'
)
$quietUninstallString =
[string](
Get-RegValue `
-Key $app `
-Name 'QuietUninstallString'
)
$modifyPath =
[string](
Get-RegValue `
-Key $app `
-Name 'ModifyPath'
)
$windowsInstaller =
Get-RegValue `
-Key $app `
-Name 'WindowsInstaller'
$systemComponent =
Get-RegValue `
-Key $app `
-Name 'SystemComponent'
$allCommands =
"$uninstallString " +
"$quietUninstallString " +
"$modifyPath"
# -----------------------------------------------------
# GUID uninstall key НЕ означает автоматически MSI.
#
# WiX Burn bundle тоже может иметь GUID.
#
# Считаем MSI только если:
# - WindowsInstaller = 1
# ИЛИ
# - зарегистрированная команда использует msiexec
# -----------------------------------------------------
$isMsi =
$false
if (
$windowsInstaller -eq 1
) {
$isMsi =
$true
}
if (
$allCommands -match
'(?i)\bmsiexec(?:\.exe)?\b'
) {
$isMsi =
$true
}
$productCode =
$null
if ($isMsi) {
$productCode =
Get-MsiGuid `
-KeyName $subKeyName `
-CommandText $allCommands
}
[void]$items.Add(
[PSCustomObject]@{
Scope =
$Scope
UserSID =
$SID
ProfilePath =
$ProfilePath
DisplayName =
$displayName
DisplayVersion =
$displayVersion
Publisher =
$publisher
RegistryView =
$RegistryViewText
RegistryPath =
"$DisplayRegistryPath\$subKeyName"
RegistryKeyName =
$subKeyName
InstallLocation =
$installLocation
UninstallString =
$uninstallString
QuietUninstallString =
$quietUninstallString
WindowsInstaller =
$windowsInstaller
SystemComponent =
$systemComponent
IsMSI =
$isMsi
MsiProductCode =
$productCode
}
)
}
catch {
Write-Log (
'Cannot read uninstall entry ' +
"$DisplayRegistryPath\$subKeyName : " +
$_.Exception.Message
) 'WARNING'
}
finally {
if ($app) {
$app.Dispose()
}
}
}
}
catch {
Write-Log (
'Cannot scan registry path ' +
"$DisplayRegistryPath : " +
$_.Exception.Message
) 'WARNING'
}
finally {
if ($root) {
$root.Dispose()
}
if ($base) {
$base.Dispose()
}
}
$items
}
# =====================================================================
# FULL INVENTORY
# =====================================================================
function Get-MtsInventory {
$all =
New-Object System.Collections.Generic.List[object]
# =================================================================
# HKLM 64-bit
# =================================================================
$machine64 =
@(
Read-UninstallRoot `
-Hive (
[Microsoft.Win32.RegistryHive]::LocalMachine
) `
-View (
[Microsoft.Win32.RegistryView]::Registry64
) `
-RegistryPath (
'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
) `
-DisplayRegistryPath (
'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
) `
-Scope 'Machine' `
-SID $null `
-ProfilePath $null `
-RegistryViewText '64-bit'
)
foreach ($item in $machine64) {
[void]$all.Add($item)
}
# =================================================================
# HKLM 32-bit
# =================================================================
$machine32 =
@(
Read-UninstallRoot `
-Hive (
[Microsoft.Win32.RegistryHive]::LocalMachine
) `
-View (
[Microsoft.Win32.RegistryView]::Registry32
) `
-RegistryPath (
'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
) `
-DisplayRegistryPath (
'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
) `
-Scope 'Machine' `
-SID $null `
-ProfilePath $null `
-RegistryViewText '32-bit'
)
foreach ($item in $machine32) {
[void]$all.Add($item)
}
# =================================================================
# USERS
# =================================================================
$profiles =
@(
Get-RealUserProfiles
)
$counter = 0
foreach ($profile in $profiles) {
$counter++
$sid =
$profile.SID
$profilePath =
$profile.ProfilePath
$ntUserDat =
$profile.NTUserDat
$loadedByScript =
$false
if (
Test-HiveLoaded `
$sid
) {
$mountedName =
$sid
}
else {
$mountedName =
"MTSLinkCleanup_${PID}_${counter}"
$loadResult =
Invoke-RegExe (
'load "HKU\{0}" "{1}"' -f
$mountedName,
$ntUserDat
)
if (
$loadResult.ExitCode -ne 0
) {
Write-Log (
"Cannot load NTUSER.DAT for SID $sid. " +
"ExitCode=$($loadResult.ExitCode); " +
"Error=$($loadResult.StdErr)"
) 'WARNING'
continue
}
$loadedByScript =
$true
}
try {
# ---------------------------------------------------------
# Обычный пользовательский uninstall root
# ---------------------------------------------------------
$userItems =
@(
Read-UninstallRoot `
-Hive (
[Microsoft.Win32.RegistryHive]::Users
) `
-View (
[Microsoft.Win32.RegistryView]::Registry64
) `
-RegistryPath (
"$mountedName\Software\Microsoft\Windows\CurrentVersion\Uninstall"
) `
-DisplayRegistryPath (
"HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Uninstall"
) `
-Scope 'User' `
-SID $sid `
-ProfilePath $profilePath `
-RegistryViewText 'User'
)
foreach ($item in $userItems) {
[void]$all.Add($item)
}
# ---------------------------------------------------------
# На всякий случай проверяем также явный WOW6432Node
# пользовательского hive.
# ---------------------------------------------------------
$userWowItems =
@(
Read-UninstallRoot `
-Hive (
[Microsoft.Win32.RegistryHive]::Users
) `
-View (
[Microsoft.Win32.RegistryView]::Registry64
) `
-RegistryPath (
"$mountedName\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
) `
-DisplayRegistryPath (
"HKEY_USERS\$sid\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
) `
-Scope 'User' `
-SID $sid `
-ProfilePath $profilePath `
-RegistryViewText 'User-WOW6432'
)
foreach ($item in $userWowItems) {
[void]$all.Add($item)
}
}
finally {
if ($loadedByScript) {
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
Start-Sleep `
-Milliseconds 200
$unloadResult =
Invoke-RegExe (
'unload "HKU\{0}"' -f
$mountedName
)
if (
$unloadResult.ExitCode -ne 0
) {
Write-Log (
'Cannot unload temporary hive ' +
"HKU\$mountedName. " +
"ExitCode=$($unloadResult.ExitCode); " +
"Error=$($unloadResult.StdErr)"
) 'WARNING'
}
}
}
}
# =================================================================
# DEDUP
# =================================================================
$all |
Sort-Object `
Scope,
UserSID,
RegistryPath `
-Unique
}
# =====================================================================
# INTERACTIVE USERS
# =====================================================================
function Get-InteractiveUserMap {
$map = @{}
try {
$explorers =
Get-CimInstance `
Win32_Process `
-Filter "Name='explorer.exe'" `
-ErrorAction Stop
foreach ($process in $explorers) {
try {
$sidResult =
Invoke-CimMethod `
-InputObject $process `
-MethodName GetOwnerSid `
-ErrorAction Stop
$ownerResult =
Invoke-CimMethod `
-InputObject $process `
-MethodName GetOwner `
-ErrorAction Stop
if (
$sidResult.Sid -and
$ownerResult.User
) {
if (
$ownerResult.Domain
) {
$account =
"$($ownerResult.Domain)\$($ownerResult.User)"
}
else {
$account =
$ownerResult.User
}
$map[
[string]$sidResult.Sid
] = $account
}
}
catch {
# Один explorer.exe не должен ломать весь аудит.
}
}
}
catch {
Write-Log (
'Cannot enumerate interactive users: ' +
$_.Exception.Message
) 'WARNING'
}
return $map
}
# =====================================================================
# PARSE EXE COMMAND
# =====================================================================
function Split-UninstallCommand {
param(
[string]$Command
)
if (
[string]::IsNullOrWhiteSpace(
$Command
)
) {
return $null
}
$commandText =
[Environment]::ExpandEnvironmentVariables(
$Command.Trim()
)
# ---------------------------------------------------------
# "C:\Path With Spaces\uninstall.exe" /arg1 /arg2
# ---------------------------------------------------------
if (
$commandText -match
'^\s*"([^"]+)"\s*(.*)$'
) {
return [PSCustomObject]@{
FilePath =
$Matches[1]
Arguments =
$Matches[2].Trim()
}
}
# ---------------------------------------------------------
# msiexec.exe /x ...
# rundll32.exe ...
# ---------------------------------------------------------
if (
$commandText -match
'^\s*([^\s]+)\s*(.*)$'
) {
return [PSCustomObject]@{
FilePath =
$Matches[1]
Arguments =
$Matches[2].Trim()
}
}
return $null
}
# =====================================================================
# EXIT CODE HANDLING
# =====================================================================
function Test-SuccessExitCode {
param(
[int64]$ExitCode
)
# 0 = success
# 1605 = product not installed
# 1614 = product already uninstalled
# 3010 = success, reboot required
#
# 1641 специально НЕ включён:
# "restart initiated" для нас не является нормальным результатом.
if (
$ExitCode -in @(
0,
1605,
1614,
3010
)
) {
return $true
}
return $false
}
function Register-UninstallExitCode {
param(
[int64]$ExitCode,
[string]$Description
)
if (
$ExitCode -eq 3010
) {
$script:RebootRequiredReported =
$true
Write-Log (
"$Description returned 3010. " +
'Removal succeeded and reports that a reboot may be required, ' +
'but this script WILL NOT reboot the OS and WILL NOT return 3010 to SCCM.'
) 'WARNING'
return
}
if (
$ExitCode -eq 1641
) {
$script:RestartInitiatedCode =
$true
Write-Log (
"$Description returned 1641 (restart initiated). " +
'This is treated as an ERROR.'
) 'ERROR'
return
}
if (
Test-SuccessExitCode `
$ExitCode
) {
Write-Log (
"$Description returned exit code $ExitCode."
) 'SUCCESS'
}
else {
Write-Log (
"$Description returned exit code $ExitCode."
) 'WARNING'
}
}
# =====================================================================
# MSI REMOVE AS SYSTEM
# =====================================================================
function Invoke-MsiRemovalAsSystem {
param(
[string]$ProductCode,
[string]$Description
)
$safeGuid =
$ProductCode -replace '[{}-]', ''
$msiLog =
Join-Path `
$OutputDirectory `
(
'MSI_{0}_{1}.log' -f
$safeGuid,
(Get-Date -Format 'yyyyMMdd_HHmmss')
)
# ---------------------------------------------------------
# КРИТИЧНО:
#
# /norestart
# REBOOT=ReallySuppress
#
# Никакой автоматической перезагрузки от Windows Installer.
# ---------------------------------------------------------
$arguments =
'/x {0} /qn /norestart REBOOT=ReallySuppress /L*v "{1}"' -f
$ProductCode,
$msiLog
Write-Log "MSI REMOVE: $Description"
Write-Log "$NativeMsiexec $arguments"
try {
$process =
Start-Process `
-FilePath $NativeMsiexec `
-ArgumentList $arguments `
-Wait `
-PassThru `
-WindowStyle Hidden `
-ErrorAction Stop
$exitCode =
[int64]$process.ExitCode
Register-UninstallExitCode `
-ExitCode $exitCode `
-Description $Description
return $exitCode
}
catch {
Write-Log (
"Cannot execute MSI uninstall for ${Description}: " +
$_.Exception.Message
) 'ERROR'
return -1
}
}
# =====================================================================
# EXE REMOVE AS SYSTEM
# =====================================================================
function Invoke-ExeRemovalAsSystem {
param(
[string]$Command,
[string]$Description
)
$parsed =
Split-UninstallCommand `
$Command
if (-not $parsed) {
Write-Log (
"Cannot parse uninstall command for $Description"
) 'ERROR'
return -1
}
$filePath =
$parsed.FilePath
$arguments =
$parsed.Arguments
# ---------------------------------------------------------
# Если это WiX Burn bootstrapper, дополнительно принудительно
# добавляем /quiet и /norestart.
# ---------------------------------------------------------
$isBurn =
(
$Command -match
'(?i)/burn\.clean\.room'
) -or
(
$filePath -match
'(?i)bootstrapper'
)
if ($isBurn) {
if (
$arguments -notmatch
'(?i)(?:^|\s)/quiet(?:\s|$)'
) {
$arguments =
"$arguments /quiet".Trim()
}
if (
$arguments -notmatch
'(?i)(?:^|\s)/norestart(?:\s|$)'
) {
$arguments =
"$arguments /norestart".Trim()
}
}
Write-Log "EXE REMOVE as SYSTEM: $Description"
Write-Log (
'"' +
$filePath +
'" ' +
$arguments
)
try {
$startParams = @{
FilePath =
$filePath
Wait =
$true
PassThru =
$true
WindowStyle =
'Hidden'
ErrorAction =
'Stop'
}
if (
-not [string]::IsNullOrWhiteSpace(
$arguments
)
) {
$startParams.ArgumentList =
$arguments
}
$process =
Start-Process @startParams
$exitCode =
[int64]$process.ExitCode
Register-UninstallExitCode `
-ExitCode $exitCode `
-Description $Description
return $exitCode
}
catch {
Write-Log (
"Cannot execute EXE uninstall for ${Description}: " +
$_.Exception.Message
) 'ERROR'
return -1
}
}
# =====================================================================
# EXE/MSI REMOVE AS INTERACTIVE USER
# =====================================================================
function Invoke-ProcessAsInteractiveUser {
param(
[string]$SID,
[string]$Account,
[string]$FilePath,
[string]$Arguments,
[string]$Description
)
$taskName =
'MTSLinkCleanup_' +
[Guid]::NewGuid().ToString('N')
try {
Write-Log (
"USER REMOVE: $Description"
)
Write-Log (
"Run as: $Account [$SID]"
)
Write-Log (
'"' +
$FilePath +
'" ' +
$Arguments
)
# ---------------------------------------------------------
# Scheduled Task используется только как безопасный способ
# стартовать процесс под уже залогиненным пользователем.
#
# Никаких паролей не требуется.
# ---------------------------------------------------------
if (
[string]::IsNullOrWhiteSpace(
$Arguments
)
) {
$action =
New-ScheduledTaskAction `
-Execute $FilePath
}
else {
$action =
New-ScheduledTaskAction `
-Execute $FilePath `
-Argument $Arguments
}
$principal =
New-ScheduledTaskPrincipal `
-UserId $Account `
-LogonType Interactive `
-RunLevel Limited
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Principal $principal `
-Force `
-ErrorAction Stop |
Out-Null
$startMark =
Get-Date
Start-ScheduledTask `
-TaskName $taskName `
-ErrorAction Stop
$deadline =
(Get-Date).AddMinutes(10)
$finished =
$false
do {
Start-Sleep `
-Seconds 1
$task =
Get-ScheduledTask `
-TaskName $taskName `
-ErrorAction Stop
$taskInfo =
Get-ScheduledTaskInfo `
-TaskName $taskName `
-ErrorAction Stop
$hasActuallyRun =
(
$taskInfo.LastRunTime -ge
$startMark.AddSeconds(-2)
)
if (
$hasActuallyRun -and
$task.State -notin @(
'Running',
'Queued'
)
) {
$finished =
$true
break
}
} while (
(Get-Date) -lt $deadline
)
if (-not $finished) {
Write-Log (
"Timeout waiting for user uninstall: $Description"
) 'ERROR'
Stop-ScheduledTask `
-TaskName $taskName `
-ErrorAction SilentlyContinue
return -1
}
$taskInfo =
Get-ScheduledTaskInfo `
-TaskName $taskName `
-ErrorAction Stop
$exitCode =
[int64]$taskInfo.LastTaskResult
Register-UninstallExitCode `
-ExitCode $exitCode `
-Description $Description
return $exitCode
}
catch {
Write-Log (
"Cannot run uninstall as ${Account}: " +
$_.Exception.Message
) 'ERROR'
return -1
}
finally {
Unregister-ScheduledTask `
-TaskName $taskName `
-Confirm:$false `
-ErrorAction SilentlyContinue
}
}
# =====================================================================
# REMOVE ONE PRODUCT
# =====================================================================
function Remove-MtsProduct {
param(
$App,
[hashtable]$InteractiveUsers
)
# =================================================================
# HARD PROTECTION
# =================================================================
if (
Test-IsProtectedProduct `
$App
) {
Write-Log (
'KEEP / PROTECTED: ' +
"$($App.DisplayName) " +
"$($App.DisplayVersion)"
) 'SUCCESS'
return
}
Write-Log '------------------------------------------------------------'
$description =
'{0} {1}' -f
$App.DisplayName,
$App.DisplayVersion
Write-Log "REMOVE: $description"
Write-Log (
"Scope: $($App.Scope)"
)
Write-Log (
"Registry: $($App.RegistryPath)"
)
if ($App.UserSID) {
Write-Log (
"UserSID: $($App.UserSID)"
)
}
# =================================================================
# MACHINE INSTALLATION
# =================================================================
if (
$App.Scope -eq 'Machine'
) {
# -------------------------------------------------------------
# MSI
#
# Даже если ARP содержит:
#
# MsiExec.exe /I{GUID}
#
# намеренно используем:
#
# /x {GUID} /qn /norestart REBOOT=ReallySuppress
# -------------------------------------------------------------
if (
$App.IsMSI -and
$App.MsiProductCode
) {
[void](
Invoke-MsiRemovalAsSystem `
-ProductCode $App.MsiProductCode `
-Description $description
)
return
}
# -------------------------------------------------------------
# Non-MSI.
#
# В первую очередь используем официальный
# QuietUninstallString из Windows.
# -------------------------------------------------------------
$command =
$null
if (
-not [string]::IsNullOrWhiteSpace(
$App.QuietUninstallString
)
) {
$command =
$App.QuietUninstallString
}
elseif (
-not [string]::IsNullOrWhiteSpace(
$App.UninstallString
) -and
(
$App.UninstallString -match
'(?i)(/burn\.clean\.room|bootstrapper)'
)
) {
# Для Burn можем безопасно добавить
# /quiet /norestart самостоятельно.
$command =
$App.UninstallString
}
if (
[string]::IsNullOrWhiteSpace(
$command
)
) {
Write-Log (
'No safe silent uninstall command found. ' +
'Product will NOT be forcibly removed.'
) 'ERROR'
return
}
[void](
Invoke-ExeRemovalAsSystem `
-Command $command `
-Description $description
)
return
}
# =================================================================
# USER INSTALLATION
# =================================================================
$sid =
[string]$App.UserSID
if (
[string]::IsNullOrWhiteSpace(
$sid
)
) {
Write-Log (
"User installation has no SID: $description"
) 'ERROR'
return
}
# -------------------------------------------------------------
# /currentuser от SYSTEM запускать НЕ будем.
#
# Нужно найти именно того пользователя, которому принадлежит
# установка.
# -------------------------------------------------------------
if (
-not $InteractiveUsers.ContainsKey(
$sid
)
) {
Write-Log (
"User SID $sid is not currently logged on with explorer.exe. " +
'The per-user uninstall will NOT be executed as SYSTEM. ' +
'This product will remain and final verification will report it.'
) 'WARNING'
return
}
$account =
$InteractiveUsers[$sid]
# =================================================================
# USER MSI
# =================================================================
if (
$App.IsMSI -and
$App.MsiProductCode
) {
$arguments =
'/x {0} /qn /norestart REBOOT=ReallySuppress' -f
$App.MsiProductCode
[void](
Invoke-ProcessAsInteractiveUser `
-SID $sid `
-Account $account `
-FilePath $TaskMsiexec `
-Arguments $arguments `
-Description $description
)
return
}
# =================================================================
# USER EXE
# =================================================================
if (
[string]::IsNullOrWhiteSpace(
$App.QuietUninstallString
)
) {
Write-Log (
'Per-user product has no QuietUninstallString. ' +
'Interactive/non-silent uninstall will NOT be started.'
) 'ERROR'
return
}
$parsed =
Split-UninstallCommand `
$App.QuietUninstallString
if (-not $parsed) {
Write-Log (
"Cannot parse QuietUninstallString for $description"
) 'ERROR'
return
}
$arguments =
$parsed.Arguments
# Если вдруг пользовательская запись тоже WiX Burn.
$isBurn =
(
$App.QuietUninstallString -match
'(?i)/burn\.clean\.room'
) -or
(
$parsed.FilePath -match
'(?i)bootstrapper'
)
if ($isBurn) {
if (
$arguments -notmatch
'(?i)(?:^|\s)/quiet(?:\s|$)'
) {
$arguments =
"$arguments /quiet".Trim()
}
if (
$arguments -notmatch
'(?i)(?:^|\s)/norestart(?:\s|$)'
) {
$arguments =
"$arguments /norestart".Trim()
}
}
[void](
Invoke-ProcessAsInteractiveUser `
-SID $sid `
-Account $account `
-FilePath $parsed.FilePath `
-Arguments $arguments `
-Description $description
)
}
# =====================================================================
# PRODUCT IDENTITY
# =====================================================================
function Get-ProductIdentity {
param(
$App
)
return (
'{0}|{1}|{2}|{3}|{4}' -f
$App.Scope,
$App.UserSID,
$App.RegistryPath,
$App.DisplayName,
$App.DisplayVersion
)
}
# =====================================================================
# INITIAL INVENTORY
# =====================================================================
Write-Log 'Taking initial inventory...'
$InitialInventory =
@(
Get-MtsInventory
)
Write-Log (
'Initial MTS Link entries found: ' +
$InitialInventory.Count
)
foreach ($app in $InitialInventory) {
if (
Test-IsProtectedProduct `
$app
) {
$status =
'KEEP'
}
else {
$status =
'REMOVE'
}
Write-Log (
'[{0}] {1} {2} | {3} | {4}' -f
$status,
$app.DisplayName,
$app.DisplayVersion,
$app.Scope,
$app.RegistryPath
)
}
# =====================================================================
# SAVE INITIAL INVENTORY
# =====================================================================
try {
$InitialInventory |
Select-Object `
@{N='Action';E={
if (
Test-IsProtectedProduct $_
) {
'KEEP'
}
else {
'REMOVE'
}
}},
DisplayName,
DisplayVersion,
Scope,
UserSID,
ProfilePath,
RegistryView,
RegistryPath,
IsMSI,
MsiProductCode,
UninstallString,
QuietUninstallString |
Export-Csv `
-LiteralPath $InitialCsvPath `
-Delimiter ';' `
-NoTypeInformation `
-Encoding UTF8
Write-Log (
"Initial CSV: $InitialCsvPath"
)
}
catch {
Write-Log (
'Cannot save initial CSV: ' +
$_.Exception.Message
) 'WARNING'
}
# =====================================================================
# REMEMBER EXACT PROTECTED ENTRIES
# =====================================================================
$InitialProtected =
@(
$InitialInventory |
Where-Object {
Test-IsProtectedProduct $_
}
)
$InitialProtectedIds =
@(
$InitialProtected |
ForEach-Object {
Get-ProductIdentity $_
}
)
foreach ($item in $InitialProtected) {
Write-Log (
'PROTECTED: ' +
"$($item.DisplayName) " +
"$($item.DisplayVersion) | " +
$item.RegistryPath
) 'SUCCESS'
}
# =====================================================================
# TARGETS TO REMOVE
# =====================================================================
$Targets =
@(
$InitialInventory |
Where-Object {
-not (
Test-IsProtectedProduct $_
)
}
)
Write-Log (
'Products selected for removal: ' +
$Targets.Count
)
# =====================================================================
# INTERACTIVE USERS
# =====================================================================
$InteractiveUsers =
Get-InteractiveUserMap
if (
$InteractiveUsers.Count -gt 0
) {
foreach (
$sid in $InteractiveUsers.Keys
) {
Write-Log (
'Interactive user: ' +
"$($InteractiveUsers[$sid]) [$sid]"
)
}
}
else {
Write-Log (
'No interactive users detected.'
) 'WARNING'
}
# =====================================================================
# REMOVAL
# =====================================================================
foreach ($app in $Targets) {
Remove-MtsProduct `
-App $app `
-InteractiveUsers $InteractiveUsers
}
# =====================================================================
# FINAL VERIFICATION
# =====================================================================
Write-Log '------------------------------------------------------------'
Write-Log (
"Waiting $VerificationDelaySeconds seconds before final verification..."
)
Start-Sleep `
-Seconds $VerificationDelaySeconds
Write-Log 'Taking final inventory...'
$FinalInventory =
@(
Get-MtsInventory
)
$FinalUnwanted =
@(
$FinalInventory |
Where-Object {
-not (
Test-IsProtectedProduct $_
)
}
)
$FinalProtected =
@(
$FinalInventory |
Where-Object {
Test-IsProtectedProduct $_
}
)
$FinalIds =
@(
$FinalInventory |
ForEach-Object {
Get-ProductIdentity $_
}
)
# =====================================================================
# VERIFY PROTECTED PRODUCTS
# =====================================================================
$ProtectedMissing =
New-Object System.Collections.Generic.List[string]
foreach (
$protectedId
in $InitialProtectedIds
) {
if (
$FinalIds -notcontains
$protectedId
) {
[void]$ProtectedMissing.Add(
$protectedId
)
Write-Log (
'CRITICAL: protected Meetings 1.6.0.0 entry disappeared: ' +
$protectedId
) 'ERROR'
}
}
# =====================================================================
# FINAL CSV
# =====================================================================
try {
$FinalInventory |
Select-Object `
@{N='Status';E={
if (
Test-IsProtectedProduct $_
) {
'KEEP'
}
else {
'UNWANTED'
}
}},
DisplayName,
DisplayVersion,
Scope,
UserSID,
ProfilePath,
RegistryView,
RegistryPath,
IsMSI,
MsiProductCode,
UninstallString,
QuietUninstallString |
Export-Csv `
-LiteralPath $FinalCsvPath `
-Delimiter ';' `
-NoTypeInformation `
-Encoding UTF8
Write-Log (
"Final CSV: $FinalCsvPath"
)
}
catch {
Write-Log (
'Cannot save final CSV: ' +
$_.Exception.Message
) 'WARNING'
}
# =====================================================================
# FINAL REPORT
# =====================================================================
Write-Log '============================================================'
Write-Log 'FINAL RESULT'
Write-Log '============================================================'
foreach ($item in $FinalProtected) {
Write-Log (
'KEEP OK: ' +
"$($item.DisplayName) " +
"$($item.DisplayVersion) | " +
$item.RegistryPath
) 'SUCCESS'
}
foreach ($item in $FinalUnwanted) {
Write-Log (
'UNWANTED REMAINS: ' +
"$($item.DisplayName) " +
"$($item.DisplayVersion) | " +
"$($item.Scope) | " +
"SID=$($item.UserSID) | " +
$item.RegistryPath
) 'ERROR'
}
if ($script:RebootRequiredReported) {
Write-Log (
'At least one uninstall returned 3010. ' +
'No reboot was executed and 3010 will not be returned to SCCM.'
) 'WARNING'
}
# =====================================================================
# EXIT CODE
# =====================================================================
if (
$script:RestartInitiatedCode
) {
Write-Log (
'RESULT: FAILED - an uninstaller returned 1641 ' +
'(restart initiated).'
) 'ERROR'
Write-Log 'EXIT CODE: 30'
Write-Log '============================================================'
exit 30
}
if (
$ProtectedMissing.Count -gt 0
) {
Write-Log (
'RESULT: FAILED - protected MTS Link Meetings 1.6.0.0 ' +
'component disappeared.'
) 'ERROR'
Write-Log 'EXIT CODE: 20'
Write-Log '============================================================'
exit 20
}
if (
$FinalUnwanted.Count -gt 0
) {
Write-Log (
'RESULT: FAILED - unwanted MTS Link products remain.'
) 'ERROR'
Write-Log 'EXIT CODE: 10'
Write-Log '============================================================'
exit 10
}
Write-Log (
'RESULT: SUCCESS - all unwanted MTS Link products are gone.'
) 'SUCCESS'
if (
$InitialProtected.Count -gt 0
) {
Write-Log (
'Protected MTS Link Meetings 1.6.0.0 components are intact.'
) 'SUCCESS'
}
Write-Log (
'No OS reboot was requested by this script.'
) 'SUCCESS'
Write-Log 'EXIT CODE: 0'
Write-Log '============================================================'
exit 0