07 April 2023

Trigger Windows Update in ConfigMgr without PowerShell

I've been meaning to share this blog post for some time now, and finally got around to it. If you need to trigger a Windows update without using PowerShell, there is a way to do it. In newer versions of Windows 10, usoclient.exe is used to initiate an update scan. However, most of its parameters no longer work except for startinteractivescan. The issue I faced was that it had to be executed as the system account or as the end user in ConfigMgr. Although ConfigMgr runs installations with the system account, it did not work with usoclient.exe. The solution was to use psexec.exe with the -s parameter to trigger it. I ran it as a task sequence, but you can also execute it under a package. I had already pushed psexec.exe to all systems in my environment, but you may need to include it in the package and run it from there. Don't forget to use the -accepteula parameter; otherwise, it will hang if it has never been executed on a machine before.


If you observe the Check for Updates window while executing the package in Software Center, you can see it initiate the update scan, as demonstrated below.



06 September 2022

Imaging using MDT with Autopilot

If you have a system that has been infected or the drive had to be replaced, you'll need to lay down a new bare OS and then have autopilot finish up the configuration of the machine. For this, we are using MDT as it is easy to maintain and very fast at imaging. 

I used this as a template to come up with the solution we are now using. The first thing I did was to use everything verbatim from the section of the blog named "How to get the Windows Autopilot payload". It is the next section How to customize the MDT Task Sequence for Autopilot that I diverged from. I could not get that section to work. The main problem was the task sequence. The blog says to use a custom task sequence when in reality, you must use a Standard Client Task Sequence. 

The first thing I did was to disable Postinstall and State Restore groups as we will not be joining a domain and the task sequence needs to stop in Postinstall mode. The next thing was to create a new Postinstall group as shown below. 


The next thing was to create the CustomSettings_Autopilot.ini as described in the referring blog. The Gather uses the CustomSettings_Autopilot.ini file as described in the blog. 



The Apply Autopilot Profile also uses what is described in the other blog. 

  • xcopy %SCRIPTROOT%\AutopilotConfigurationFile.json %OSDisk%\Windows\provisioning\AutoPilot\ /c

The next stip is to delete the unattend.xml file as shown below:


The next task places a file named autopilot.txt in the root directory of the c: drive. This is a flag to run the Intune package that will delete all MDT associated files and registry keys to terminate the MDT build. 


The next task will delete most of the MDT files on the system. 


Finally, the Restart task reboots the system at which point it will come up to the Autopilot user signon page. 

As far as the SetupCompleteAutopilot.cmd file, here is what I have inside it. I changed some of the script to use environmental variables and I added the deletion of the c:\autopilot.txt file and the creation of the same file under c:\windows\temp to signify the script executed and the system has been cleaned up so the Intune package registers the execution as a success. 

@echo off
:: // ***************************************************************************
:: //
:: //
:: // File:      SetupComplete.cmd
:: //
:: // Version:   1.0
:: //
:: // Purpose:   Cleanup after MDT Autopilot deployment
:: //
:: // ***************************************************************************

:: Copy to windows setup folder for application verification purposes in Intune
copy /V /Y \\prodfs01\All\ProdApps\Waller\SetupComplete\SetupCompleteAutopilot.cmd %WINDIR%\Setup\SetupCompleteAutopilot.cmd
:: Workaround for incorrectly-registered TS environment
reg delete HKCR\Microsoft.SMS.TSEnvironment /f > nul 2>&1
rmdir /Q /S %OSDisk%\MININT 
rmdir /Q /S %OSDisk%\_SMSTaskSequence
del /Q %OSDisk%\LTIBootstrap.vbs
del /Q %OSDisk%\autopilot.txt
echo Test > %WINDIR%\Temp\autopilot.txt


In Intune, I created the app called MDT Cleanup. Here is the program page. I used a dummy delete.cmd file as that part is not needed. 





That is all that is to the Intune package. Now the system will build a bare-bones OS and then transfer the rest of the build process to Intune. 

03 August 2022

Find Programs and Features Uninstall Registry Keys with PowerShell

 I am working on a new package to upgrade one of the applications. This time, it requires I uninstall the old app first before installing the new version. There are two different versions, so I needed to retrieve the uninstall strings for both. That is when I decided to write this script that will scan the registry for the application and list the key values as shown below. It scans both the x86 and x64 Uninstall registry entries. The script was also written to be able to scan for wildcard values if there is more than one entry you are looking for. 



You can download the script from my GitHub.


 <#  
      .SYNOPSIS  
           Uninstall Finder  
        
      .DESCRIPTION  
           This script will retrieve the x86 and x64 uninstall registry key(s) for a specific applicaton. This is very helpful for Configuration Manager admins when needing to create packages, especially uninstall packages.  
        
      .PARAMETER ApplicationName  
           Name of the application as it appears in Add/Remove Programs  
        
      .PARAMETER Like  
           Select this if using a partial name or wanting multiple listings to appear  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2022 v5.8.209  
           Created on:       8/3/2022 11:44 AM  
           Created by:       Mick Pletcher  
           Filename:         FindRegistryUninstall.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$ApplicationName,  
      [switch]$Like  
 )  
   
 If ($Like.IsPresent) {  
      Get-ChildItem -Path REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall, REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall | Get-ItemProperty | Where-Object { $_.DisplayName -like ('*' + $ApplicationName + '*') }  
 } else {  
      Get-ChildItem -Path REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall, REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall | Get-ItemProperty | Where-Object { $_.DisplayName -eq $ApplicationName }  
 }  
   

02 August 2022

Configuring Wake-On-LAN for Dell Systems

The firm I am at has recently upgraded all systems to the newest model Dells. In doing so, some of the settings for configuring WOL have changed in the BIOS. I have rewritten this script to cover all changes to the OS, BIOS, and NIC. The script uses the DellSMBios PowerShell module to configure the BIOS settings. Thanks to these sites for pertinent information that helped with writing this tool:
And yes, I do realize I could have consolidated some of the code into a function that would have worked repetitively against each setting. 

The things that must be done to get this to work are:
  • BIOS: Disable CStatesCtrl
  • BIOS: Enable Wake-On-LAN either LanWlan or LanOnly
  • BIOS: Disable DeepSleepCtrl
  • BIOS: Disable BlockS3
  • NIC: Disable Energy Efficient Ethernet
  • NIC: Turn on Wake on Magic Packet
  • OS: Turn off Hibernation
  • OS: Enable Allow the computer to turn off this device is configured
This is a screenshot of the script after it successfully ran against a Dell 7090 machine. 




Here is the script that I wrote and works in our environment on Dell Optiplexes and Latitudes. 

You can download it from my GitHub site


 <#  
      .SYNOPSIS  
           Wake-On-LAN  
        
      .DESCRIPTION  
           A description of the file.  
        
      .PARAMETER ConsoleTitle  
           Title for PowerShell console  
        
      .PARAMETER BIOSPassword  
           A description of the BIOSPassword parameter.  
        
      .NOTES  
           ===========================================================================  
           Created with:      SAPIEN Technologies, Inc., PowerShell Studio 2022 v5.8.209  
           Created on:       8/1/2022 8:16 AM  
           Created by:       Mick Pletcher  
           Filename:          WOL.ps1  
           ===========================================================================  
 #>  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$ConsoleTitle,  
      [string]$BIOSPassword  
 )  
   
 function Set-BIOS {  
 <#  
      .SYNOPSIS  
           Configure WOL in BIOS  
        
      .DESCRIPTION  
           Configure WOL in BIOS  
        
 #>  
        
      [CmdletBinding()]  
      param ()  
        
      #Import Dell BIOS Provider PowerShell Module  
      Try {  
           Import-Module -Name DellBIOSProvider  
      }  
      catch {  
           Find-Module -Name DellBIOSProvider | Install-Module -Force  
           Import-Module -Name DellBIOSProvider  
      }  
      #Set Wake-On-LAN to LanOnly  
      $BIOSItem = "PowerManagement\WakeOnLan"  
      $NewValue = "LanWlan"  
      #Check if LanWlan is available  
      If ($NewValue -notin ("DellSmBios:\" + $BIOSItem).PossibleValues) {  
           $NewValue = "LanOnly"  
      }  
      If (Get-Item -Path ("DellSmBios:\" + $BIOSItem) -ErrorAction SilentlyContinue) {  
           Write-Host ("BIOS" + [char]32 + $BIOSItem.split('\')[1] + ":" + [char]32) -NoNewline  
           If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ne $NewValue) {  
                If ($BIOSPassword) {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force -Password $BIOSPassword  
                } else {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force  
                }  
                If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -eq $NewValue) {  
                     Write-Host $NewValue -ForegroundColor Yellow  
                }  
                else {  
                     Write-Host (Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ForegroundColor Red  
                }  
           }  
           else {  
                Write-Host $NewValue -ForegroundColor Yellow  
           }  
      }  
        
      #Disable CState Control  
      $BIOSItem = "Performance\CStatesCtrl"  
      $NewValue = "Disabled"  
      #Test if CState exists  
      If (Get-Item -Path ("DellSmBios:\" + $BIOSItem) -ErrorAction SilentlyContinue) {  
           Write-Host ("BIOS" + [char]32 + $BIOSItem.split('\')[1] + ":" + [char]32) -NoNewline  
           If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ne $NewValue) {  
                If ($BIOSPassword) {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force -Password $BIOSPassword  
                }  
                else {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force  
                }  
                If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -eq $NewValue) {  
                     Write-Host $NewValue -ForegroundColor Yellow  
                }  
                else {  
                     Write-Host (Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ForegroundColor Red  
                }  
           }  
           else {  
                Write-Host $NewValue -ForegroundColor Yellow  
           }  
      }  
        
      #Disable Deep Sleep  
      $BIOSItem = "PowerManagement\DeepSleepCtrl"  
      $NewValue = "Disabled"  
      #Test if Deep Sleep exists  
      If (Get-Item -Path ("DellSmBios:\" + $BIOSItem) -ErrorAction SilentlyContinue) {  
           Write-Host ("BIOS" + [char]32 + $BIOSItem.split('\')[1] + ":" + [char]32) -NoNewline  
           If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ne $NewValue) {  
                If ($BIOSPassword) {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force -Password $BIOSPassword  
                }  
                else {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force  
                }  
                If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -eq $NewValue) {  
                     Write-Host $NewValue -ForegroundColor Yellow  
                }  
                else {  
                     Write-Host (Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ForegroundColor Red  
                }  
           }  
           else {  
                Write-Host $NewValue -ForegroundColor Yellow  
           }  
      }  
        
      #Disable Block S3  
      $BIOSItem = "PowerManagement\BlockS3"  
      $NewValue = "Disabled"  
      #Test if Block S3 exists  
      If (Get-Item -Path ("DellSmBios:\" + $BIOSItem) -ErrorAction SilentlyContinue) {  
           Write-Host ("BIOS" + [char]32 + $BIOSItem.split('\')[1] + ":" + [char]32) -NoNewline  
           If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ne $NewValue) {  
                If ($BIOSPassword) {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force -Password $BIOSPassword  
                }  
                else {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force  
                }  
                If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -eq $NewValue) {  
                     Write-Host $NewValue -ForegroundColor Yellow  
                }  
                else {  
                     Write-Host (Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ForegroundColor Red  
                }  
           }  
           else {  
                Write-Host $NewValue -ForegroundColor Yellow  
           }  
      }  
        
      #Disable C States  
      $BIOSItem = "PowerManagement\CStatesCtrl"  
      $NewValue = "Disabled"  
      #Test if CStatesCtrl exists  
      If (Get-Item -Path ("DellSmBios:\" + $BIOSItem) -ErrorAction SilentlyContinue) {  
           Write-Host ("BIOS" + [char]32 + $BIOSItem.split('\')[1] + ":" + [char]32) -NoNewline  
           If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ne $NewValue) {  
                If ($BIOSPassword) {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force -Password $BIOSPassword  
                }  
                else {  
                     Set-Item -Path ("DellSmBios:\" + $BIOSItem) -Value $NewValue -Force  
                }  
                If ((Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -eq $NewValue) {  
                     Write-Host $NewValue -ForegroundColor Yellow  
                }  
                else {  
                     Write-Host (Get-Item -Path ("DellSmBios:\" + $BIOSItem)).CurrentValue -ForegroundColor Red  
                }  
           }  
           else {  
                Write-Host $NewValue -ForegroundColor Yellow  
           }  
      }  
 }  
   
 Function Set-AdvancedNIC {  
        #Get the Ethernet NIC  
      $NIC = Get-NetAdapter | Where-Object {($_.PhysicalMediaType -eq '802.3') -and ($_.Status -eq 'Up')}  
        #Disable Energy Efficient Ethernet setting so NIC does not go to sleep  
   #Two variants of Energy Efficient Exist on different Dell models  
      #Check if Energy-Efficient Ethernet Exists  
      If (Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy-Efficient Ethernet' -ErrorAction SilentlyContinue) {  
           Write-Host 'NIC Energy-Efficient Ethernet: ' -NoNewline  
           Set-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy-Efficient Ethernet' -DisplayValue 'Disabled'  
           If ((Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy-Efficient Ethernet').DisplayValue -eq 'Disabled') {  
                Write-Host 'Disabled' -ForegroundColor Yellow  
           }  
           else {  
                Write-Host 'Enabled' -ForegroundColor Red  
           }  
      }  
      #Check if Energy Efficient Ethernet Exists  
      If (Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy Efficient Ethernet' -ErrorAction SilentlyContinue) {  
           Write-Host 'NIC Energy Efficient Ethernet: ' -NoNewline  
           Set-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy Efficient Ethernet' -DisplayValue 'Off'  
           If ((Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Energy Efficient Ethernet').DisplayValue -eq 'Off') {  
                Write-Host 'Off' -ForegroundColor Yellow  
           }  
           else {  
                Write-Host 'On' -ForegroundColor Red  
           }  
      }  
      #Turn on Wake on Magic Packet  
        #Check if Wake on Magic Packet Exists  
      If (Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Wake on Magic Packet' -ErrorAction SilentlyContinue) {  
           Write-Host 'NIC Wake on Magic Packet: ' -NoNewline  
           Set-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Wake on Magic Packet' -RegistryKeyword '*WakeOnMagicPacket' -RegistryValue 1  
           If ((Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Wake on Magic Packet').DisplayValue -eq 'Enabled') {  
                Write-Host 'Enabled' -ForegroundColor Yellow  
           }  
           else {  
                Write-Host 'Disabled' -ForegroundColor Red  
           }  
      }  
      #Shutdown WakeOnLAN  
      If (Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Shutdown Wake-On-Lan' -ErrorAction SilentlyContinue) {  
           Write-Host 'NIC Shutdown Wake-On-Lan: ' -NoNewline  
           Set-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Shutdown Wake-On-Lan' -DisplayValue 'Enabled'  
           If ((Get-NetAdapterAdvancedProperty -Name $NIC.Name -DisplayName 'Shutdown Wake-On-Lan').DisplayValue -eq 'Enabled') {  
                Write-Host 'Enabled' -ForegroundColor Yellow  
           }  
           else {  
                Write-Host 'Disabled' -ForegroundColor Red  
           }  
      }  
 }  
   
 function Set-PowerManagement {  
 <#  
      .SYNOPSIS  
           Enable Power Management  
        
      .DESCRIPTION  
           A detailed description of the Set-PowerManagement function.  
        
      .EXAMPLE  
                     PS C:\> Set-PowerManagement  
        
      .NOTES  
           Additional information about the function.  
 #>  
        
      [CmdletBinding()]  
      param ()  
        
      #Turn off Hibernation  
   Write-Host ("OS Hiberboot:" + [char]32) -NoNewline  
   If ((Get-ItemProperty -Path REGISTRY::"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Power").HiberbootEnabled -ne 0) {  
     Set-ItemProperty -Path REGISTRY::"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Power" -Name HiberbootEnabled -Value 0 -Force  
   }  
   If ((Get-ItemProperty -Path REGISTRY::"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Power").HiberbootEnabled -eq 0) {  
     Write-Host "Disabled" -ForegroundColor Yellow  
   } else {  
     Write-Host "Enabled" -ForegroundColor Red  
   }  
   
      #0 = Option 1 & 2 checked  
   #10 = Option 1 checked, 2 & 3 cleared  
   #24 = Option 1 unchecked  
   #256 = Option 1, 2, & 3 all checked  
   #264 = Option 2 & 3 Checked  
   #272 = Option 1 checked  
   #280 = Option 2 & 3 checked  
   $PNPValue = 256  
      $Adapter = Get-NetAdapter | Where-Object { ($_.Status -eq 'Up') -and ($_.PhysicalMediaType -eq '802.3') }  
      $KeyPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4D36E972-E325-11CE-BFC1-08002bE10318}\'  
      foreach ($Entry in (Get-ChildItem $KeyPath -ErrorAction SilentlyContinue).Name) {  
           If ((Get-ItemProperty REGISTRY::$Entry).DriverDesc -eq $Adapter.InterfaceDescription) {   
                $Value = (Get-ItemProperty REGISTRY::$Entry).PnPCapabilities  
                If ($Value -ne $PnPValue) {  
                     Set-ItemProperty -Path REGISTRY::$Entry -Name PnPCapabilities -Value $PnPValue -Force  
                     Disable-PnpDevice -InstanceId $Adapter.PnPDeviceID -Confirm:$false  
                     Enable-PnpDevice -InstanceId $Adapter.PnPDeviceID -Confirm:$false  
                     $Value = (Get-ItemProperty REGISTRY::$Entry).PnPCapabilities }  
                If ($Value -eq $PnPValue) {  
                     Write-Host 'Allow the computer to turn off this device is configured' -ForegroundColor Yellow  
                } else {   
                     Write-Host 'Allow the computer to turn off this device Failed' -ForegroundColor Red  
                     Exit 1  
                }  
           }  
      }  
 }  
   
   
 #Set Console Title  
 $host.ui.RawUI.WindowTitle = $ConsoleTitle  
 Set-BIOS  
 Set-AdvancedNIC  
 Set-PowerManagement  
   

28 July 2022

MECM System Cleanup

Recently, we started a cleanup of AD. Once the cleanup was completed, I wanted ConfigMgr cleaned up right away too. It is set to clean up old items, but it was not quick enough for me so I wrote the following tool that will query the All Systems collection via SQL and then reads the attributes in AD to see if the system is disabled. It will delete each disabled system from ConfigMgr at the end. Below is an example of it displaying a list of machines, to clean up, the count, and where it is deleting them. 



Before executing this script in your environment, I highly recommend commenting out the Remove-CMDevice cmdlet and verifying it is deleting the correct systems from ConfigMgr. 

You can download the script from my GitHub site


 <#  
      .SYNOPSIS  
           ConfigMgr Cleanup  
        
      .DESCRIPTION  
           This script will compare the All Systems list in ConfigMgr to systems in AD and delete systems from ConfigMgr that are disabled in AD. It will also report a list of systems that are greater than 30 days old since the last activity in AD.  
        
      .PARAMETER SQLServer  
           ConfigMgr SQL Server
        
      .PARAMETER SQLDatabase  
           Name of the ConfigMgr SQL Database
        
      .PARAMETER PSHCfgMgrModule  
           Path to ConfigurationManager.psd1 module  
        
      .PARAMETER Sitecode  
           Three character ConfigMgr site code  
        
      .PARAMETER SiteServer  
           FQDN of the Configuration Manager server  
        
      .PARAMETER DeleteSystems  
           Select to automatically delete systems from Configuration Manager  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2022 v5.8.208  
           Created on:       7/26/2022 8:00 AM  
           Created by:       Mick Pletcher  
           Filename:         MECMADCleanup.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLServer,  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLDatabase,  
      [string]$PSHCfgMgrModule,  
      [string]$SiteCode,  
      [string]$SiteServer,  
      [switch]$DeleteSystems  
 )  
   
 function Get-PSHModule {  
 <#  
      .SYNOPSIS  
           Import Module  
        
      .DESCRIPTION  
           Import specified module  
        
      .PARAMETER Module  
           Name of PowerShell Module  
        
      .PARAMETER NoInstall  
           Import only. Typically used for modules that are not in the PowerShell Gallery  
        
      .NOTES  
           Additional information about the function.  
 #>  
        
      [CmdletBinding()]  
      param  
      (  
           [ValidateNotNullOrEmpty()]  
           [string]$Module,  
           [switch]$NoInstall  
      )  
      If ($NoInstall.IsPresent) {  
           Import-Module -Name $Module  
      }  
      else {  
           Try {  
                Import-Module -Name $Module  
           }  
           Catch {  
                Find-Module -Name $Module | Install-Module -Force  
                Import-Module -Name $Module  
           }  
      }  
 }  
   
 #Import SQL Server PowerShell Module  
 Get-PSHModule -Module "SqlServer"  
 #Import AD PowerShell module  
 Get-PSHModule -Module "ActiveDirectory"  
 $Systems = @()  
 #Get All Systems list from ConfigMgr  
 $List = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query "SELECT NAME FROM dbo._RES_COLL_SMS00001 ORDER BY Name"  
 foreach ($System in $List) {  
      #Filter out built-in accounts  
      If (($System.Name -notlike '*Unknown*') -and ($System.Name -notlike '*Provisioning*')) {  
           #Return a list of all systems either not in AD or that have been disabled  
           Try {  
                $AD = Get-ADComputer $System.Name  
                If ($AD.Enabled -eq $false) {  
                     $Systems += $AD.Name  
                }  
           } catch {  
                $Systems += $System.Name  
           }  
      }  
 }  
 #Display the systems to be deleted from ConfigMgr
 $Systems  
 $Systems.Count  
 If ($Systems.Count -ne 0) {  
      If ($DeleteSystems.IsPresent) {  
           #Import ConfigMgr Module  
           Get-PSHModule -Module $PSHCfgMgrModule -NoInstall  
           If ((Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue) -eq $null) {  
                New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $SiteServer  
           }  
           Set-Location "$($SiteCode):\"  
           $Systems | ForEach-Object {  
                Write-Host ('Deleting ' + $_ + '.....') -NoNewline  
                Remove-CMDevice -Name $_ -Force  
                If ((Get-CMDevice -Name $_) -eq $null) {  
                     Write-Host 'Success' -ForegroundColor Yellow  
                } else {  
                     Write-Host 'Failed' -ForegroundColor Red  
                }  
           }  
      }  
 }  
        

27 July 2022

Configuration Manager PowerShell Module: An update to the existing console is available

I was recently writing a new PowerShell tool to clean up Configuration Manager of old systems. When I ran the import-module cmdlet, I got the message 


The module was being imported from the Configuration Manager server. I always keep ConfigMgr up-to-date with the latest version within days of release and I saw the console was 5.2203.1063.2400. Doing a little digging, I found this was coming from the console that was installed on my laptop and not from the server. I also found that even if you import the PowerShell module from the server where it is the latest version, it will revert back to the machine you are working on to import from if the console is installed there. I upgraded the console on my laptop and this message disappeared. 

01 July 2022

Bitlocker Recovery Password AD Backup and Cleanup

This PowerShell script not only backs up the Bitlocker recovery password to AD but also cleans out previous backups made that do not match the current password associated with the machine. 

The script retrieves the local bitlocker password along with all recovery passwords written to AD. It will then parse through the associated passwords and remove all that does not match the one associated with the local machine. This greatly helps keep AD clean of old passwords. This script can be set up to automatically execute on a machine through Azure Automation, Orchestrator, Intune, or ConfigMgr. It will need to be executed using an account with credentials to both read the bitlocker recovery password on the local machine and modify the AD Objects. You can download the script from my GitHub repository




NOTE: If you do not want the script to display the recovery password, you can comment out the write-host lines.



 <#       
      .NOTES  
      ===========================================================================  
       Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2022 v5.8.208  
       Created on:      7/1/2022 11:08 AM  
       Created by:      Mick Pletcher  
       Filename:        BitlockerRecoveryPasswordADBackupCleanup.ps1  
      ===========================================================================  
      .DESCRIPTION  
           This script will delete active directory entries that contain the Bitlocker recovery keys which do not match to the current one. It will then push up the new key to AD.  
 #>  
   
 [CmdletBinding()]  
 param ()  
   
 Clear-Host  
 #Get the local bitlocker password  
 $LocalPassword = ((manage-bde -protectors -get ($env:ProgramFiles).split('\')[0] -id ((Get-WmiObject -Namespace 'Root\cimv2\Security\MicrosoftVolumeEncryption' -Class 'Win32_EncryptableVolume').GetKeyProtectors(3).volumeKeyprotectorID)).trim() | Where-Object { $_.Trim() -ne '' })[-1]  
 $BitlockerID = (((manage-bde -protectors -get ($env:ProgramFiles).split('\')[0] -id ((Get-WmiObject -Namespace 'Root\cimv2\Security\MicrosoftVolumeEncryption' -Class 'Win32_EncryptableVolume').GetKeyProtectors(3).volumeKeyprotectorID)).trim() | Where-Object { $_.Trim() -ne '' })[-3]).split(":")[1].trim()  
 #Get all bitlocker entries from active directory  
 $ADEntries = (Get-ADObject -Filter { objectclass -eq 'msFVE-RecoveryInformation' } -SearchBase (Get-ADComputer $env:COMPUTERNAME).DistinguishedName -Properties 'msFVE-RecoveryPassword')  
 #Number of recovery key entries stored in AD  
 $EntryCount = 0  
 #Parse through all active directory entries removing ones that do not contain local bitlocker password  
 foreach ($Item in $ADEntries) {  
      If ($LocalPassword -ne $Item.'msFVE-RecoveryPassword') {  
           Remove-ADObject -Identity $Item.DistinguishedName -Confirm:$false  
      }  
      else {  
           $EntryCount += 1  
           If ($EntryCount -gt 1) {  
                Remove-ADObject -Identity $Item.DistinguishedName -Confirm:$false  
           }  
      }  
 }  
 $ADEntries = (Get-ADObject -Filter { objectclass -eq 'msFVE-RecoveryInformation' } -SearchBase (Get-ADComputer $env:COMPUTERNAME).DistinguishedName -Properties 'msFVE-RecoveryPassword')  
 #Backup the bitlocker password to active directory if it is not in any AD entries  
 If ($LocalPassword -notin $ADEntries.'msFVE-RecoveryPassword') {  
      #Backup recovery key to active directory  
      $Switches = "-protectors -adbackup c: -id" + [char]32 + $BitlockerID  
      Write-Host "Backing up to AD....." -NoNewline  
      $ErrCode = (Start-Process -FilePath $env:windir'\system32\manage-bde.exe' -ArgumentList $Switches -PassThru -Wait).ExitCode  
      If ($ErrCode -eq 0) {  
           Write-Host "Success" -ForegroundColor Yellow  
           $ADEntries = (Get-ADObject -Filter { objectclass -eq 'msFVE-RecoveryInformation' } -SearchBase (Get-ADComputer $env:COMPUTERNAME).DistinguishedName -Properties 'msFVE-RecoveryPassword')  
           Write-Host  
           Write-Host " Bitlocker ID:" -NoNewline  
           Write-Host $BitlockerID -ForegroundColor Yellow  
           Write-Host "Local Password:" -NoNewline  
           Write-Host $LocalPassword -ForegroundColor Yellow  
           Write-Host "  AD Password:" -NoNewline  
           Write-Host $ADEntries.'msFVE-RecoveryPassword' -ForegroundColor Yellow  
           If ($LocalPassword -eq $ADEntries.'msFVE-RecoveryPassword') {  
                Exit 0  
           }  
      }  
      elseif ($ErrCode -eq "-2147024809") {  
           $Status = [string]((manage-bde.exe -status).replace(' ', '')).split(":")[16]  
           If ($Status -eq "FullyDecrypted") {  
                Write-Host "Failed. System is not Bitlockered"  
                Exit 2  
           }  
           else {  
                Write-Host "Unspecified error"  
                Exit 3  
           }  
      }  
      else {  
           Write-Host "Failed with error code"$ErrCode -ForegroundColor Red  
           Write-Host  
           Write-Host " Bitlocker ID:" -NoNewline  
           Write-Host $BitlockerID -ForegroundColor Yellow  
           Write-Host "Local Password:" -NoNewline  
           Write-Host $LocalPassword -ForegroundColor Yellow  
           Write-Host "  AD Password:" -NoNewline  
           Write-Host $ADEntries.'msFVE-RecoveryPassword' -ForegroundColor Yellow  
           Exit 1  
      }  
 }  
 else {  
      Write-Host  
      Write-Host " Bitlocker ID:"$BitlockerID  
      Write-Host "Local Password:"$LocalPassword  
      Write-Host "  AD Password:"$ADEntries.'msFVE-RecoveryPassword'  
      Exit 0  
 }  
   

30 June 2022

Automating Dell TPM Configuration

Over the years, we have manually configured the TPM before imaging a system as part of our build process. Dell has since given the ability to automate the entire process after giving the option to automate clearing the TPM. That was always been the big stopper in full automation. 

I wrote a series of scripts that I put into the build process that do all of the necessary steps in readying the TPM for bitlocker as shown below. The Smart Reporting, and Wake-On-LAN are additional features I added that do not pertain to the TPM and Bitlocker. The Conditional Reboot is another PowerShell script I wrote that checks if the system is waiting for a reboot and reboots the system if necessary. If interested, this is in another blog of mine.


Here are the files to download. The names correspond with the list shown above so you know what sequence to put them in. I clear the BIOS password at the start and then reset it near the end as setting the PPI TPM Clear requires a BIOS password to be in place to check off that box via a script. These scripts were successfully tested on Dell Optiplex 7070 and Latitude 7420. There was nothing manual that had to be done to ready the TPM for bitocker. 

17 March 2022

Identify Machines a User is Logged Into using Carbon Black

If you have Carbon Black in your environment, you can use it to identify which machines a user account is logged into. Carbon Black collects a vast amount of data on machines and reports it to the cloud database. The following is how to use Carbon Black to list the machines:
  1. Log into the Carbon Black Cloud Portal
  2. Click the Investigate tab
  3. In the investigate search field at the top, enter the following: 
    1. Enter process_username:<username> in the search field at the top. <username> needs to be changed to the actual username you are searching for.
    2. Change the time field to the right to within one day or less
    3. Click the magnifying glass on the far right to search. 
  4. Under the filters field, scroll down to Device and it will show a list of devices the profile is currently logged into. 
As you can see in the screenshot under devices, it returned two machines my profile was logged into. 



16 March 2022

Last Server Reboot Reporting

Recently, we needed a report of the last boot time of all servers. I wrote this PowerShell script that queries AD for a list of all windows servers and then does a WMI query on each server for the LastBootUpTime. It then calculates the number of days and writes this to an object with the computer name and the number of days since the last reboot. It will write this info to a CSV file. 

You can download the script from my GitHub site.

 Import-Module -Name ActiveDirectory -Force  
 #Get list of windows based servers  
 $Servers = Get-ADComputer -Filter * -Properties * | Where-Object {$_.OperatingSystem -like '*windows server*'} | Select Name | Sort-Object -Property Name  
 #Create Report Array  
 $Report = @()  
 #Parse through server list  
 Foreach ($Server in $Servers) {  
   #Get the computer name  
   $ComputerName = ([String]$Server).Split("=")[1].Split("}")[0].Trim()  
   #Check if the system is online  
   If ((Test-Connection -ComputerName $ComputerName -Count 1 -Quiet) -eq $true) {  
     #Query last bootup time and use $Null if unobtainable  
     Try {  
       $LastBootTime = (Get-CimInstance -ClassName win32_operatingsystem -ComputerName $ComputerName -ErrorAction SilentlyContinue).LastBootUpTime  
       $LastBoot = (New-TimeSpan -Start $LastBootTime -End (Get-Date)).Days  
     } Catch {  
       $LastBoot = $null  
     }  
     #Add computername and last boot time to the object  
     If ($ComputerName -ne $null) {  
       $SystemObject = New-Object -TypeName psobject  
       $SystemObject | Add-Member -MemberType NoteProperty -Name ComputerName -Value $ComputerName  
       $SystemObject | Add-Member -MemberType NoteProperty -Name DaysSinceLastBoot -Value $LastBoot  
       $Report += $SystemObject  
     }  
   } else {  
       $SystemObject = New-Object -TypeName psobject  
       $SystemObject | Add-Member -MemberType NoteProperty -Name ComputerName -Value $ComputerName  
       $SystemObject | Add-Member -MemberType NoteProperty -Name DaysSinceLastBoot -Value 'OFFLINE'  
       $Report += $SystemObject  
   }  
   $ComputerName = $null  
 }  
 #Print report to screen  
 $Report  
 $OutFile = "C:\Users\Desktop\LastRebootReport.csv"  
 #Delete CSV file if it already exists  
 If ((Test-Path -Path $OutFile) -eq $true) {  
   Remove-Item -Path $OutFile -Force  
 }  
 #Export report to CSV file  
 $Report | Export-Csv -Path $OutFile -NoClobber -Encoding UTF8 -NoTypeInformation -Force  
   

07 March 2022

Configure SQL Server Firewall Ports with PowerShell

 I recently had to rebuild the Configuration Manager server. As I was running the prerequisite tool, it showed it could not communicate with the SQL server that is separate from the Configuration Manager Server. The issue ended up being ports needed to be opened up. 

This PowerShell script will configure the correct ports. It also adds to the description as to what services the port is opened up for in Configuration Manager. If the rule is already present, it skips over. If you open up a rule after the script is executed, you will see it says This is a predefined rule and some of its properties cannot be modified. This was caused by me adding the rule to the group Configuration Manager. If -Group is removed from the cmdlet, this message disappears. 

You can download the script from here.


 If ((Get-NetFirewallRule -Name "ConfigMgr Port 135 UDP" -ErrorAction SilentlyContinue) -eq $null) {  
 New-NetFirewallRule -Name "ConfigMgr Port 135 UDP" -DisplayName "ConfigMgr Port 135 UDP" -Description "Site Server" -Group "Configuration Manager" -Profile "Domain" -Protocol UDP -LocalPort 135 -Enabled True  
 }  
 If ((Get-NetFirewallRule -Name "ConfigMgr Port 135 TCP" -ErrorAction SilentlyContinue) -eq $null) {  
   New-NetFirewallRule -Name "ConfigMgr Port 135 TCP" -DisplayName "ConfigMgr Port 135 TCP" -Description "Site Server" -Group "Configuration Manager" -Profile "Domain" -Protocol TCP -LocalPort 135 -Enabled True  
 }  
 If ((Get-NetFirewallRule -Name "ConfigMgr Port 1433 TCP" -ErrorAction SilentlyContinue) -eq $null) {  
   New-NetFirewallRule -Name "ConfigMgr Port 1433 TCP" -DisplayName "ConfigMgr Port 1433 TCP" -Description "Asset Intelligence Synchronization Point, App Catalog Web Service Point, Endpoint Protection, Enrollment Point, MP, Reporting point, Site Server, SMS Provider, SQL Server, SMP" -Group "Configuration Manager" -Profile "Domain" -Protocol TCP -LocalPort 1433 -Enabled True  
 }  
 If ((Get-NetFirewallRule -Name "ConfigMgr Port 4022 TCP" -ErrorAction SilentlyContinue) -eq $null) {  
   New-NetFirewallRule -Name "ConfigMgr Port 4022 TCP" -DisplayName "ConfigMgr Port 4022 TCP" -Description "SQL Server" -Group "Configuration Manager" -Profile "Domain" -Protocol TCP -LocalPort 4022 -Enabled True  
 }  
 If ((Get-NetFirewallRule -Name "ConfigMgr Port 445 TCP" -ErrorAction SilentlyContinue) -eq $null) {  
   New-NetFirewallRule -Name "ConfigMgr Port 445 TCP" -DisplayName "ConfigMgr Port 445 TCP" -Description "Site Server" -Group "Configuration Manager" -Profile "Domain" -Protocol TCP -LocalPort 445 -Enabled True  
 }  
   

24 January 2022

Troubleshooting No Task Sequences are available (Tasksequence.xml does not exist, is empty, or is inaccessible)

 I encountered this error when working with MDT right after deleting two old task sequences. 


To troubleshoot this, I went to the logs directory in the MDT hierarchy. I specified in the customsettings.ini file to write all build logs to this specific directory:

SLShare=\\BUILD\PRODDeploymentShare$\Logs

SLShareDynamicLogging=\\BUILD\PRODDeploymentShare$\Logs\%ComputerName%

When I went into the logs, I opened up the only log there, BDD.log, and found that it could not find the task sequence specified in the TaskSequences.xml file. I then opened up the TaskSequences.xml file, which is located at %DEPLOYROOT%\Control\TaskSequences.xml. Once I opened up the XML, I searched for the task sequence no found and deleted it. That fixed the issue. 





21 January 2022

How to effectively add Office updates to the update folder

If you are still using an on-prem version of office, you know the need to populate the Updates folder so updates get applied when Office is installed instead of having to wait for the updates to download and then be installed. The issue I have run into is some of the updates, once extracted, are named the same. To resolve this, I extract each update from the cab file. Next, I open up the msp file using ORCA. In the MsiPatchMetadata table, you will find the KB number in the Release property. I use that number and rename the msp file to KB4011634.msp for instance. This prevents files from being overwritten. I then copy it back to the Updates folder. Office is able to read the MSP file with no problems. 






20 December 2021

Installing WinGet via PowerShell

 I wanted to install Winget via PowerShell in an automated process. The first thing I did was to go to the Winget GitHub site and select the latest version of the Windows Package Manager. Under the latest version page, I clicked on the file with the extension of msixbundle and downloaded it to my machine.

Now that it is on your machine, you can automate the installation with a PowerShell one-liner script where the PS1 file resides in the same directory as the Winget installer. The script will search the current directory for a msixbundle file with the cmdlet listed below. 

 Add-AppPackage -Path ((Get-ChildItem -Path ((Get-Location).ProviderPath) -Filter *.msixbundle).FullName)  

19 November 2021

Administrator Reporting Tool

This PowerShell tool queries AD for a list of machines in the specified administrative admin groups. The list is specified by modifying the script with the groups to be queried. The following example shows how to add groups to the query. When there are multiple groups, they can be divided off by using the pipe character between each group.

Where-Object {$_.MemberOf -match 'Admins|Domain Admins|System Admins|'}

There is the parameter called $Days. This specifies how many days old the account needs to be so it is not displayed in the report anymore. 

I wrote this script so that it can easily be used with Orchestrator or as a scheduled task. It exits the script with an error code 0 if there is data to emailed along with the Write-Output statement that puts the list of users in the output of the program once exited. If there was no data to return, it exits with an error code 1 so that Orchestrator knows not to proceed with the email task. 

You can download the script from my GiHub Site

 <#  
      .SYNOPSIS  
           Administrator Report  
        
      .DESCRIPTION  
           This tool is intended to keep staff informed of new administrator accounts. This script queries for a list of users in the specified administrator group(s). It then produces a list of the administrator users that got created within the specified number of days.   
        
      .PARAMETER Days  
           Number of days since the administrator account was created  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2021 v5.8.195  
           Created on:       11/9/2021 1:37 PM  
           Created by:       Mick Pletcher  
           Filename:         AdministratorReport.ps1  
           ===========================================================================  
 #>  
 Param  
 (  
      [ValidateNotNullOrEmpty()][int]$Days = 1  
 )  
   
 #Retrieves a list of users from AD and filters them by association with the specied security groups. The match can be associated with multiple groups separated with a pipe  
 #Example: Where-Object {$_.MemberOf -match '|Domain Admins|System Admins|'}  
 $Users = Get-ADUser -Filter * -Properties MemberOf | Where-Object {$_.MemberOf -match 'Super Admins|Domain Admins'}  
 #Filter out all accounts that are older than the specified $Days  
 $Users | ForEach-Object {  
      If ((New-TimeSpan -Start ((Get-ADUser -Identity $_.SamAccountName -Properties whenCreated).whenCreated) -End (Get-Date)).Days -le $Days) {  
           $NewUsers += $_.Name  
      }  
        
 }  
 If (($NewUsers -ne $null) -and ($NewUsers -ne '')) {  
      Write-Output $NewUsers  
 } Else {  
      Exit 1  
 }  
 Exit 0  

13 October 2021

Installing Printers via ConfigMgr for Non-Admin Users

KB5005652 resolved the "PrintNightMare" vulnerability, but it also brought many companies to a halt when it came to end-users installing printers if they did not have administrator privileges. During the time since the update, we had our help desk install printers for users on an as-needed basis. There was a workaround by setting the RestrictDriverInstallationToAdministrators to 0 to override the fix, but this would have thwarted the security vulnerability. 

I came up with the fix to install printers using configuration manager since it can install them with a privileged account. The solution in my environment was to use PowerShell to query the print servers for a list of offices and printers in our scenario. After the office is selected, it displays a list of printers in that office for the user to select from. Once that printer is selected, it will check if the printer is already installed. If so, it will delete it and reinstall. If not, it will install the new printer. Finally, it will verify that the printer was successfully installed. I currently have the script read from a text file the list of print servers with the associated city location in CSV format. I did make a last-minute change where I hardcoded the locations into the object creation instead of using it from the text file. I will probably find a better way in the near future and update this with something like using Get-ADObject to get a list of the print servers, but will still need to find where the link to the associated city name is to use that method. For now, this is working great in our environment.

This has to be set up in configuration manager as a package advertisement with Allow users to interact with this program checked. This allows users to install a printer on-demand through Software Center and rerun the package as many times as needed. 

NOTE: One last important thing. This script was written for our environment. You will need PowerShell knowledge to modify this script to work in your environment. This is more of a primer to show you how I overcame the limitation in this corporate environment. The script will likely need to be greatly modified for some environments. The size of the company will also make a lot of difference in how this script needs to be modified. The firm I am at is roughly 500 people. A GUI interface may be preferential to some, which I may come back later and implement with PowerShell Studio. 

You can download the PowerShell script from my Github repository


 <#  
      .SYNOPSIS  
           PrinterInstaller  
        
      .DESCRIPTION  
           This script is intended to be used in ConfigMgr as an applicatio advertisement in Software Center. This allows non-admin users to install printers allowing companies to keep the Microsoft print server patch in place. It nwill retrieve all printers from all print servers. It then prompts the user for the office. At that point, it will display a list of printers in that office for the user to select from. Finally, it will check if the printer is already installed. If it is, it will uninstall the printer and proceed to install it, otherwise it will install the printer.  
        
      .PARAMETER PrintServersFile  
           Name of the file which contains a list of print servers  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2021 v5.8.194  
           Created on:       10/12/2021 7:40 PM  
           Created by:       Mick Pletcher  
           Filename:         PrinterInstaller.ps1  
           ===========================================================================  
 #>  
   
 [CmdletBinding()]  
 Param  
 (  
      [ValidateNotNullOrEmpty()]$PrintServersFile = 'PrintServers.txt'  
 )  
   
 Clear-Host  
 Write-Host "Retrieving List of Offices..."  
 #Create Printers array  
 $Printers = @()  
 #Get list of print servers that includes the print server and printer location  
 $PrintServers = Get-Content -Path ($PSScriptRoot + '\' + $PrintServersFile)  
 #Get list of all printers from within each print server  
 $PrintServers | ForEach-Object {  
   #Test if the print server is online before querying it  
   If ((Test-Connection -ComputerName $_.Split(",")[0] -Count 1 -Quiet) -eq $true) {  
     #Add all printer from the specified print server  
     $Query += Get-Printer -ComputerName $_.Split(",")[0]  
   }  
 }  
 #Create the object for each printer and add it to the $Printers array  
 $Query | ForEach-Object {  
      $object = New-Object PSObject  
      $object | Add-Member Noteproperty -Name PrinterName -Value $_.Name  
        $object | Add-Member Noteproperty -Name PrinterPort -Value $_.PortName  
      $object | Add-Member Noteproperty -Name PrintServer -Value $_.ComputerName  
      $object | Add-Member Noteproperty -Name DriverName -Value $_.DriverName  
      Switch ($_.ComputerName) {  
     "Printer1" { $object | Add-Member Noteproperty -Name Office -Value "Austin" }  
     "Printer2" { $object | Add-Member Noteproperty -Name Office -Value "Birmingham" }  
     "Printer3" { $object | Add-Member Noteproperty -Name Office -Value "Chattanooga" }  
     "Printer4" { $object | Add-Member Noteproperty -Name Office -Value "Nashville" }  
   }  
   #Add a floor value to the Floor object if the print server is in Nashville  
   If ($_.ComputerName -eq 'Printer4') {  
     $object | Add-Member Noteproperty -Name Floor -Value ($_.Name.Split("-")[1])  
   } else {  
     #Leave the floor object blank if it is any office other than Nashville  
     $object | Add-Member Noteproperty -Name Floor -Value ""  
   }  
   #Add the object to the $Printers array  
   $Printers += $object  
     
 }  
 #Sort the array by Office and then Floor  
 $Printers = $Printers | Sort-Object -Property Office, Floor  
 #Counter for selecting the office  
 $Count = 1  
 #Display each office with a number selection  
 $PrintServers | ForEach-Object {Write-Host ([string]$Count + ' - ' + $_.Split(",")[1]);$Count++}  
 #Prompt for a user selection of the office  
 $Selection = Read-Host -Prompt "Select the office"  
 #Get list of printers for selected office  
 $PrintersSelection = $Printers | Where-Object {$_.PrintServer -eq ($PrintServers[$Selection - 1].Split(",")[0])}  
 #printer counter  
 $Count = 1  
 Clear-Host  
 Write-Host  
 Write-Host "Retrieving list of Printers..."  
 #Display list of printers in the selected office  
 $PrintersSelection | ForEach-Object {Write-Host ([string]$Count + ' - ' + $_.PrinterName);$Count++}  
 #Prompt the user to select the printer  
 $Selection = Read-Host -Prompt "Select the Printer"  
 #Display the selected printer  
 $PrintersSelection[$Selection - 1]  
 #Check if the printer is installed and uninstall it if true  
 If ((Get-Printer -Name $PrintersSelection[$Selection - 1].PrinterName -ErrorAction SilentlyContinue) -ne $null) {  
      Remove-Printer -Name $PrintersSelection[$Selection - 1].PrinterName  
      Remove-PrinterPort -Name $PrintersSelection[$Selection - 1].PrinterPort  
 }  
 Write-Host  
 Write-Host ('Installing Printer' + [char]32 + $PrintersSelection[$Selection - 1].PrinterName + '.....') -NoNewline  
 #Install the selected printer  
 Add-PrinterPort -Name $PrintersSelection[$Selection - 1].PrinterPort -PrinterHostAddress $PrintersSelection[$Selection - 1].PrinterPort  
 Add-Printer -Name $PrintersSelection[$Selection - 1].PrinterName -DriverName $PrintersSelection[$Selection - 1].DriverName -PortName $PrintersSelection[$Selection - 1].PrinterPort  
 #Verify the printer was installed  
 If ((Get-Printer -Name $PrintersSelection[$Selection - 1].PrinterName -ErrorAction SilentlyContinue) -ne $null) {  
      Write-Host 'success' -ForegroundColor Yellow  
 } Else {  
      Write-Host 'failed' -ForegroundColor Red  
 }  
   

29 June 2021

PowerShell: Install Fonts

Font installation using PowerShell has changed since Windows 10 1909. The old way of doing it with PowerShell no longer works. This new script was originally written by Ben Reader who is a fellow Microsoft MVP from Australia. I took the script and modified it by improving on user interface and file tweaks. 

This script gets a list of all font files that exist in the same directory. It then parses through each font file getting the font name attribute at which point it copies the file to c:\windows\Fonts. Next, it registers the font by writing the font file name to HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts so that it will show up in a drop-down menu. The script also writes the status of each step to the screen so the person executing it knows if each font install is successful or not. 

You can download the script from my GitHub site


 <#  
      .SYNOPSIS  
           Install Open Text and True Type Fonts  
        
      .DESCRIPTION  
           This script will install OTF and TTF fonts that exist in the same directory as the script.  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2021 v5.8.187  
           Created on:      6/24/2021 9:36 AM  
           Created by:      Mick Pletcher  
           Filename:        InstallFonts.ps1  
           ===========================================================================  
 #>  
   
 <#  
      .SYNOPSIS  
           Install the font  
        
      .DESCRIPTION  
           This function will attempt to install the font by copying it to the c:\windows\fonts directory and then registering it in the registry. This also outputs the status of each step for easy tracking.   
        
      .PARAMETER FontFile  
           Name of the Font File to install  
        
      .EXAMPLE  
                     PS C:\> Install-Font -FontFile $value1  
        
      .NOTES  
           Additional information about the function.  
 #>  
 function Install-Font {  
      param  
      (  
           [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][System.IO.FileInfo]$FontFile  
      )  
        
      #Get Font Name from the File's Extended Attributes  
      $oShell = new-object -com shell.application  
      $Folder = $oShell.namespace($FontFile.DirectoryName)  
      $Item = $Folder.Items().Item($FontFile.Name)  
      $FontName = $Folder.GetDetailsOf($Item, 21)  
      try {  
           switch ($FontFile.Extension) {  
                ".ttf" {$FontName = $FontName + [char]32 + '(TrueType)'}  
                ".otf" {$FontName = $FontName + [char]32 + '(OpenType)'}  
           }  
           $Copy = $true  
           Write-Host ('Copying' + [char]32 + $FontFile.Name + '.....') -NoNewline  
           Copy-Item -Path $fontFile.FullName -Destination ("C:\Windows\Fonts\" + $FontFile.Name) -Force  
           #Test if font is copied over  
           If ((Test-Path ("C:\Windows\Fonts\" + $FontFile.Name)) -eq $true) {  
                Write-Host ('Success') -Foreground Yellow  
           } else {  
                Write-Host ('Failed') -ForegroundColor Red  
           }  
           $Copy = $false  
           #Test if font registry entry exists  
           If ((Get-ItemProperty -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" -ErrorAction SilentlyContinue) -ne $null) {  
                #Test if the entry matches the font file name  
                If ((Get-ItemPropertyValue -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts") -eq $FontFile.Name) {  
                     Write-Host ('Adding' + [char]32 + $FontName + [char]32 + 'to the registry.....') -NoNewline  
                     Write-Host ('Success') -ForegroundColor Yellow  
                } else {  
                     $AddKey = $true  
                     Remove-ItemProperty -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" -Force  
                     Write-Host ('Adding' + [char]32 + $FontName + [char]32 + 'to the registry.....') -NoNewline  
                     New-ItemProperty -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" -PropertyType string -Value $FontFile.Name -Force -ErrorAction SilentlyContinue | Out-Null  
                     If ((Get-ItemPropertyValue -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts") -eq $FontFile.Name) {  
                          Write-Host ('Success') -ForegroundColor Yellow  
                     } else {  
                          Write-Host ('Failed') -ForegroundColor Red  
                     }  
                     $AddKey = $false  
                }  
           } else {  
                $AddKey = $true  
                Write-Host ('Adding' + [char]32 + $FontName + [char]32 + 'to the registry.....') -NoNewline  
                New-ItemProperty -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts" -PropertyType string -Value $FontFile.Name -Force -ErrorAction SilentlyContinue | Out-Null  
                If ((Get-ItemPropertyValue -Name $FontName -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Fonts") -eq $FontFile.Name) {  
                     Write-Host ('Success') -ForegroundColor Yellow  
                } else {  
                     Write-Host ('Failed') -ForegroundColor Red  
                }  
                $AddKey = $false  
           }  
             
      } catch {  
           If ($Copy -eq $true) {  
                Write-Host ('Failed') -ForegroundColor Red  
                $Copy = $false  
           }  
           If ($AddKey -eq $true) {  
                Write-Host ('Failed') -ForegroundColor Red  
                $AddKey = $false  
           }  
           write-warning $_.exception.message  
      }  
      Write-Host  
 }  
   
 #Get a list of all font files relative to this script and parse through the list  
 foreach ($FontItem in (Get-ChildItem -Path $PSScriptRoot | Where-Object {  
                ($_.Name -like '*.ttf') -or ($_.Name -like '*.OTF')  
           })) {  
      Install-Font -FontFile $FontItem  
 }  
   

22 January 2021

Configuration Manager Message ID 11170 Error

I was in the process of pushing out the Microsoft Windows 10 20H2 upgrade when I had 23 systems that errored out. They reported the error shown below. 


After looking at the logs and at Software Center on the target machine, I found the target machine was not seeing the deployment under Operating Systems. The fix was to reinstall the client on the target machine. Once the reinstall completed and the client synchronized with the ConfigMgr server, the deployment appeared in Software Center and successfully completed. 

11 January 2021

Automating the Deletion of Windows.old

It is the beginning of 2021 and my first project for the new year is upgrading all systems to Windows 10 20H2. At the end of the upgrades comes the cleanup and there is no clean way to do this for system admins. Cleanmgr.exe is now deprecated as of Windows 10 2004. There is not a PowerShell option for controlling storage sense. Cleanmgr.exe /AUTOCLEAN would open up cleanmgr.exe and then freeze. It never deleted the folder and was stuck at zero CPU usage. I also tried using PSEXEC to execute it with the same results. Another suggestion was using task scheduler. I also tried using Dism.exe /online /Cleanup-Image /StartComponentCleanup. The Windows.old folder was still present. The next option is to delete the folder. Here is a pic of the failure to clean up the Windows.old folder using a domain admin account and cmd.exe run as administrator.

NOTE: There is an issue we ran into when someone had a USB drive connected. The system connected to the wrong drive. and installed the SMSTaskSequence on that drive instead of the bootable C: drive. This caused the system to reboot constantly because it then did not see the Windows.old directory to delete. I fixed this by adding logic that looked for the Windows.old directory. The new code is below



NOTE: Once this folder has been deleted, Windows cannot be reverted back to the previous version. 

Once the upgrade has taken place, the Windows.old folder is present. It typically takes up 15+ GB of space. The upgraded OS is still actively using some files in the directory as I learned while exploring how to delete them. I found the files being used were drivers. This prevents you from deleting it while the OS is in memory. The alternative is to load WinPE so the OS is not in memory thereby freeing up the directory for deletion. Once the directory is deleted in WinPE and the system reboots, the OS reassociates the drivers it was using in the Windows.old directory to the Windows directory.

I first tried using PowerShell to delete the directory and there was a constant problem. PowerShell could not delete all files and directories, no matter what I tried. I consistently got the message "No mapping between account names and security IDs was done." Next, I tried RMDIR and it worked perfectly. I was able to use PowerShell to both find the drive in the WinPE environment and then execute the RMDIR command to delete the Windows.old directory on that drive. I tried this as a one-liner, but it was hit and miss with the RMDIR accepting the piped output instead of a variable. I found storing the path to the Windows.old directory in a variable was much more reliable. Here is the two-line code below:

$Drive = (Get-Partition | Where-Object {((Test-Path ($_.DriveLetter + ':\Windows.old')) -eq $True)}).DriveLetter
If ((Test-Path ($Drive + ':\Windows.old')) -eq $true) {
    $Directory = $Drive + ':\Windows.old'
    cmd.exe /c rmdir /S /Q $Directory
}

Here is a screenshot of the task sequence I used to deploy to the systems. The first restart is to load WinPE, the second sequence is the above PowerShell script, and the third sequence reboots the system back into the installed OS.



25 September 2020

Check Boot Environment for BIOS or UEFI

One of my recent projects is to convert our remaining legacy systems from BIOS to UEFI. While setting up the task sequence, I needed to be able to test the system to make sure it was not already UEFI so the task sequence would end if it was. 

The PowerShell script reads the setupact.log file and extracts if it is configured as BIOS or UEFI. I have included an unknown message in the event the log file does not exist or is inaccessible, which is what I encountered on one machine. For setting it up in a Configuration Manager task sequence, I set the sequence to look for a return code of 0, else it will return either a 1 or 2, which will fail the task sequence, and allow the admin to know why it failed from the console with the error code being returned.

You can download the script from my GitHub site.


 <#  
      .SYNOPSIS  
           Check Boot Environment  
        
      .DESCRIPTION  
           This script reads the setupact.log file to determine if the system is configured for BIOS or UEFI.   
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:       9/25/2020 11:59 AM  
           Created by:       Mick Pletcher  
           Filename:         BootEnvironment.ps1  
           ===========================================================================  
 #>  
   
 Try {  
      $Output = (Get-Content -Path (((Get-ChildItem -Path ($env:windir + '\Panther') -Recurse -Filter setupact.log -ErrorAction SilentlyContinue)[0]).FullName) -ErrorAction SilentlyContinue | Where-Object {$_ -like "*Detected boot environment*"}).Replace("Detected boot environment:", "~").Split("~")[1].Trim()  
      If ($Output -eq 'BIOS') {  
           Write-Output 'BIOS'  
           Exit 0  
      } elseif ($Output -eq 'UEFI') {  
           Write-Output 'UEFI'  
           Exit 1  
      }  
 } Catch {  
      Write-Output 'Unknown'  
      Exit 2  
 }  
   

24 September 2020

Bitlocker Non-Compliance Reporting

As part of the suite of security tools I am writing, this will query the configuration manager SQL database for a list of machines that are not Bitlocker encrypted. There are reports in the configuration manager for this, but not everyone in my organization has access to the configuration manager console, and we wanted a detailed report sent out on a regular basis so that it is in their mailbox with high importance, which is also not available through ConfigMgr. 

This tool was written to include the computer name, model, chassis, drive letter, bitlocker status, last hardware inventory scan, and last logon time. The last hardware scan and last logon time give the admins an idea as to the accuracy of the system being reported. The tool was written so that it can be used with Azure Automation or Orchestrator, and even with a scheduled task if needed. The output is formatted for a clean appearance in an outlook email. 

You can download the script from my GitHub site.



 <#  
      .SYNOPSIS  
           Bitlocker Encryption Reporting  
        
      .DESCRIPTION  
           This script queries the Configuration Manager SQL database for a list of machines that are not Bitlocker Encrypted. It is limited to non-desktop chassis, which can be changed by modifying the SQL query. The script is designed to output the data so that it can be used with Orchestrator, Azure Automation, or a scheduled task.  
        
      .PARAMETER SQLServer  
           Name of the SQL server  
        
      .PARAMETER SQLDatabase  
           Name of the SQL database  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      9/21/2020 3:35 PM  
           Created by:      Mick Pletcher  
           Filename:        BitlockerEncryptionReporting.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLServer,  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLDatabase
 )  
   
 $Query = "SELECT ResultTableName FROM dbo.v_Collections"  
 $Collection = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $Query  
 #SQL query to retrieve the list of machines  
 $Query = "SELECT DISTINCT Name as ComputerName, dbo.Computer_System_DATA.Model00 AS Model, dbo.System_Enclosure_DATA.ChassisTypes00 AS Chassis, dbo.ENCRYPTABLE_VOLUME_DATA.DriveLetter00 AS DriveLetter, ProtectionStatus00 AS BitlockerStatus, LastHardwareScan, ADLastLogonTime AS LastLogonTime FROM dbo.Computer_System_DATA INNER JOIN dbo.ENCRYPTABLE_VOLUME_DATA ON dbo.Computer_System_DATA.MachineID = dbo.ENCRYPTABLE_VOLUME_DATA.MachineID INNER JOIN dbo.v_GS_ENCRYPTABLE_VOLUME ON dbo.v_GS_ENCRYPTABLE_VOLUME.ResourceID = dbo.ENCRYPTABLE_VOLUME_DATA.MachineID INNER JOIN dbo._RES_COLL_SMS00001 ON dbo.ENCRYPTABLE_VOLUME_DATA.MachineID = dbo._RES_COLL_SMS00001.MachineID INNER JOIN dbo.System_Enclosure_DATA ON dbo._RES_COLL_SMS00001.MachineID = dbo.System_Enclosure_DATA.MachineID INNER JOIN dbo.v_GS_VOLUME ON dbo.System_Enclosure_DATA.MachineID = dbo.v_GS_VOLUME.ResourceID WHERE (((dbo.System_Enclosure_DATA.ChassisTypes00 = 8) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 9) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 10) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 12) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 14) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 31)) AND (dbo.ENCRYPTABLE_VOLUME_DATA.DriveLetter00 = 'C:') AND (dbo.ENCRYPTABLE_VOLUME_DATA.ProtectionStatus00 = 0)) ORDER BY Name"  
 $Report = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $Query  
 #If the report has no machines, then exit this script with an error code 1 so that the automation tool the link will not continue to the email task  
 If ($Report -ne $null) {  
      $Array = @()  
      foreach ($Item in $Report) {  
           $SysObj = New-Object -TypeName System.Management.Automation.PSObject  
           $SysObj | Add-Member -MemberType NoteProperty -Name ComputerName -Value $Item.ComputerName  
           $SysObj | Add-Member -MemberType NoteProperty -Name Model -Value $Item.Model  
           $SysObj | Add-Member -MemberType NoteProperty -Name Chassis -Value $Item.Chassis  
           $SysObj | Add-Member -MemberType NoteProperty -Name DriveLetter -Value $Item.DriveLetter  
           $SysObj | Add-Member -MemberType NoteProperty -Name BitlockerStatus -Value $Item.BitlockerStatus  
           $SysObj | Add-Member -MemberType NoteProperty -Name LastHardwareScan -Value $Item.LastHardwareScan  
           $SysObj | Add-Member -MemberType NoteProperty -Name LastLogonTime -Value $Item.LastLogonTime  
           $Array += $SysObj  
      }  
      #Bitlocker reporting fields from the query  
      $Fields = @("Computer Name", "Model", "Chassis", "Drive Letter", "Bitlocker Status", "Last Hardware Scan", "Last Logon Time")  
      #Title row  
      $Output = ($Fields[0] + [char]9 + [char]9 + $Fields[1] + [char]9 + [char]9 + [char]9 + [char]9 + $Fields[2] + [char]9 + [char]9 + $Fields[3] + [char]9 + $Fields[4] + [char]9 + [char]9 + $Fields[5] + [char]9 + $Fields[6] + [char]13)  
      #Add each entry while formatting the computername column as to the width of the computername  
      foreach ($Item in $Array) {  
           If ($Item.ComputerName.Length -le 3) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 7) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 11) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 15) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9  
           } else {  
                $ComputerName = $Item.ComputerName + [char]9  
           }  
           $Output += $ComputerName + $Item.Model + [char]9 + [char]9 + [char]9 + $Item.Chassis + [char]9 + [char]9 + $Item.DriveLetter + [char]9 + [char]9 + $Item.BitlockerStatus + [char]9 + [char]9 + [char]9 + $Item.LastHardwareScan + [char]9 + $Item.LastLogonTime + [char]13  
      }  
      #Write the output so it can be collected from the automation tool  
      Write-Output -InputObject $Output  
 } else {  
      Exit 1  
 }