PowerShell

Facilitating KMS Activations

Occasionally we run into situations where Windows or the Office Suite fails to activate.

The solution: Use the built-in product-specific scripts to activate.

The problem: Many people in IT are not aware of these scripts, and thus how to use them, which results in a guaranteed call or email to the appropriate team.

Since I’m all about empowering people, I figured I’d put together a little script to help facilitate all this.

Enter: KMSActivate-MicrosoftProducts

I probably could have just left it as ‘Activate-MicrosoftProducts’ but I wanted to make sure potential users knew this was specifically for KMS scenarios, not MAK which has it’s own procedure.


[cmdletbinding()]
Param
    (
        [Parameter(Mandatory=$false)]
            [Switch]$ActivateOS = $true,

        [Parameter(Mandatory=$false)]
            [Switch]$ActivateOffice = $true
    )

Function Check-IfRunningWithAdministratorRights { If (!([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]“Administrator”)) { Write-Warning “ERROR: You DO NOT have Administrator rights to run this script!`nPlease re-run this script as an Administrator!”; Break } }

Function Get-KMSHost { try { return (nslookup -type=srv _vlmcs._tcp | ? { $_ -like '*svr hostname*' }).Replace('svr hostname','').Replace('=','').Trim() } catch { throw $_ } }

Function Get-KMSClientSetupKey
    {
        # https://technet.microsoft.com/en-us/library/jj612867(v=ws.11).aspx
        [cmdletbinding()]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string]$OperatingSystemCaption = (Get-CimInstance -ClassName win32_operatingsystem).caption
            )

        Switch -Wildcard ($OperatingSystemCaption)
            {
                '*Windows Server 2016 Datacenter*' { return 'CB7KF-BWN84-R7R2Y-793K2-8XDDG' }

                '*Windows Server 2016 Standard*' { return 'WC2BQ-8NRM3-FDDYY-2BFGV-KHKQY' }

                '*Windows Server 2016 Essentials*' { return 'JCKRF-N37P4-C2D82-9YXRT-4M63B' }

                '*Windows 10 Enterprise*' { return 'NPPR9-FWDCX-D2C8J-H872K-2YT43' }

                '*Windows 7 Enterprise*' { return '33PXH-7Y6KF-2VJC9-XBBR8-HVTHH' }

                default { write-host "ERROR INVALID OPERATING SYSTEM CAPTION: $_"; throw }
            }
    }

Function KMSActivate-OperatingSystem
    {
        [cmdletbinding()]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string]$KMSHost = $(Get-KMSHost),

                [Parameter(Mandatory=$false)]
                    [string]$KMSClientSetupKey = $(Get-KMSClientSetupKey)
            )

        Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$env:windir\System32\slmgr.vbs`" -dlv" -Wait

        Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$env:windir\System32\slmgr.vbs`" -skms $KMSHost" -Wait

        Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$env:windir\System32\slmgr.vbs`" -ipk $KMSClientSetupKey" -Wait

        Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$env:windir\System32\slmgr.vbs`" -ato" -Wait

        Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$env:windir\System32\slmgr.vbs`" -dlv" -Wait
    }

Function KMSActivate-OfficeSuite
    {
        [cmdletbinding()]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string]$KMSHost = $(Get-KMSHost)
            )

        #write-host "KMSHost [$KMSHost]"

        [System.Collections.ArrayList]$OfficeInstallationDirs = @()
        foreach($ProgFilePath in $env:ProgramFiles,${env:ProgramFiles(x86)})
            {
                if(!(Test-Path -Path $ProgFilePath -PathType Container)) { continue }
                foreach($OfficeVersion in (gci "$ProgFilePath\Microsoft Office" -Filter Office* -ErrorAction SilentlyContinue)) { $OfficeInstallationDirs += $OfficeVersion.Fullname }
            }

        foreach($OfficeInstallationDir in $OfficeInstallationDirs)
            {
                if(!(Test-Path -Path "$OfficeInstallationDir\ospp.vbs" -PathType Leaf)) { continue }

                Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$OfficeInstallationDir\ospp.vbs`" /dstatusall" -Wait

                Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$OfficeInstallationDir\ospp.vbs`" /sethst:$KMSHost" -Wait

                Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$OfficeInstallationDir\ospp.vbs`" /act" -Wait

                Start-Process -FilePath 'cscript' -ArgumentList "/nologo `"$OfficeInstallationDir\ospp.vbs`" /dstatusall" -Wait
            }
    }

Check-IfRunningWithAdministratorRights

if($ActivateOS -eq $true) { KMSActivate-OperatingSystem }

if($ActivateOffice -eq $true) { KMSActivate-OfficeSuite }

 

The script will attempt to:

  • Confirm it’s running elevated otherwise it’ll quit
  • Determine the KMS server (or use the one supplied)
  • Determine the OS to set the correct client setup key
  • Determine the version(s) of Office installed (if any)
  • Activate Windows 7, 10 and a few flavors of Server 2016
  • Activate any various versions of Office if present

Not saying this is the best way – just a way.  I merely wanted a turn-key solution for our IT staff and this was what made sense as it solves some of the more infrequent issues that occasionally arose.

Good Providence to you!

Advertisements

Lenovo BIOS Manipulation: Set-LenovoBIOSSetting

Changing a BIOS setting on a Lenovo, is a two step process:

  • Change the setting(s) in question
  • Commit (or Save) the all of your changes

Explanation

The script supports execution against remote systems to silently and easily toggle various BIOS settings.

By default the script relies on Get-LenovoBIOSSetting for validation purposes:

  1. It confirms the Setting you want to change is a valid setting.
  2. It confirms the count of the results returned from the previous command is equal to 1.
    This is done to ensure you specified an explicit setting like ‘Boot Up Num-Lock Status’ and not something generic like ‘Boot%’ which could return ‘Boot Agent’, ‘Boot Up Num-Lock Status’, ‘BootMode’, ‘BootOrder’ and so on.
  3. Finally, it will attempt to validate the Value you specified by evaluating the ‘OptionalValue’s returned by query.

Once it passes validation – or you bypass it – it then proceeds.

However there is a safety net and that is the requirement of the -Commit parameter to actually flip the bits.  If the -Commit parameter isn’t included, which is the default, it doesn’t make any changes.

Function Set-LenovoBIOSSetting


#region Function Set-LenovoBIOSSetting
Function Set-LenovoBIOSSetting
    {
        [cmdletbinding(SupportsShouldProcess=$True)]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string[]]$ComputerName = $env:computername,

                [Parameter(Mandatory=$true)]
                    [string]$Setting,

                [Parameter(Mandatory=$true)]
                [Alias('NewValue')]
                    [string]$Value,

                [Parameter(Mandatory=$false)]
                    [switch]$SkipCheck = $false,

                [Parameter(Mandatory=$false)]
                    [switch]$Commit
            )

        Begin { $NewValue = $Value }

        Process
            {
                foreach($Computer in $ComputerName)
                    {
                        if($SkipCheck -eq $false)
                            {
                                # Validate Setting - Part 1: Ensure this is a legitimate settng in the BIOS
                                $CurrentSettings = Get-LenovoBIOSSetting -ComputerName $Computer -Setting $Setting
                                if([string]::IsNullOrEmpty($CurrentSettings)) { Write-Output "ERROR INVALID SETTING SPECIFIED: [$Setting]"; throw }

                                # Validate Setting - Part 2: Ensure an 'exact' BIOS setting was specified like 'USB Port 1' not 'USB%' which could match several.
                                if(@($CurrentSettings).Count -gt 1) { Write-Output "ERROR SETTING [$Setting] RETURNED TOO MANY MATCHES: [$($CurrentSettings.Setting -join ', ')]"; throw }

                                # Validate New Value - If the OptionalValue isn't 'N/A' then we can verify the user supplied
                                if($CurrentSettings.OptionalValue -ne 'N/A')
                                    {
                                        if($($CurrentSettings.OptionalValue -split ',') -cnotcontains $Value) { Write-Output "ERROR: INVALID VALUE SPECIFIED [$Value] - MUST BE ONE OF: [$($CurrentSettings.OptionalValue)]"; throw }
                                    }

                                if($CurrentSettings.CurrentValue -eq $NewValue) { Write-Output "No change made for [$Setting] on [$Computer] because new and current values are identical:`r`nCurrent Value: [$($CurrentSettings.CurrentValue)]`r`nUpdated Value: [$NewValue]"; return }

                                $Action = "from [$($CurrentSettings.CurrentValue)] to [$NewValue]"
                            }
                        else { $Action = "to [$NewValue]" }

                        if($PSCmdlet.ShouldProcess("Setting [$Setting] on [$Computer] $Action"))
                            {
                                If($Commit -eq $true)
                                    {
                                        $OriginalEAP = $ErrorActionPreference
                                        $ErrorActionPreference = 'Stop'

                                        Try { $SetResult = (gwmi -class Lenovo_SetBiosSetting -namespace root\wmi -ComputerName $Computer -ErrorAction Stop).SetBiosSetting("$Setting,$NewValue") }
                                        Catch { Write-Output "ERROR: FAILED TO SET BIOS SETTING [$Setting] TO [$NewValue] ON [$Computer]: $_"; throw }

                                        $ErrorActionPreference = $OriginalEAP

                                        Switch($SetResult.return)
                                            {
                                                'Success' { Write-Output "$($SetResult.Return)fully set [$Setting] on [$Computer] $Action" }
                                                default
                                                    {
                                                        Write-Output "ERROR: FAILED TO SET [$Setting] TO [$NewValue] ON [$Computer]: [$($SetResult.Return)]"
                                                        if($Firmware -eq 'UEFI') { Write-Output "HINT: Firmware is UEFI [$Firmware] and you //may// first need to disable either 'OS Optimized Defaults' or 'SecureBoot' as these lock certain settings in the BIOS." }
                                                        throw
                                                    }
                                            }
                                    }
                                Else
                                    {
                                        Write-Output "WARNING: Commit is false [$Commit]; NOT Changing BIOS Setting [$Setting] on [$Computer] $Action!"
                                    }
                            }
                    }
            }

        End {  }
    }
#endregion Function Set-LenovoBIOSSetting

One Final Tidbit: Save-LenovoBIOSSettings

This one is important and I intentionally put this down here at the bottom.  Like I mentioned in the beginning, making BIOS change is a two step process, and so far we’ve only done one of the two.

The final step is to save the changes so that they’re committed.  Yes, I realize I’m using the parameter -Commit above which may be a little confusing but that’s just to make sure you don’t accidentally shoot yourself in the foot: write a change you didn’t mean to write and then forget what you had previously, so you struggle to get back to where you were before.

I intentionally didn’t include the ‘save’ code in the function above because I prefer to set all the changes first:

  • Switch to UEFI via ‘Boot Mode’ and ‘Boot Priority’ or ‘SecureBoot’
  • Set boot device order
  • Enable/Disable USB ports
  • Enable/Disable Bluetooth
  • Enable/Disable IPv4NetworkStack and/or IPv6NetworkStack
  • And so on…

Once all the changes are staged and no errors were encountered, I save the settings once via a call to a function which also requires the -Commit parameter to help prevent accidental foot shooting:


#region Function Set-LenovoBIOSSetting
Function Set-LenovoBIOSSetting
    {
        [cmdletbinding(SupportsShouldProcess=$True)]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string[]]$ComputerName = $env:computername,

                [Parameter(Mandatory=$true)]
                    [string]$Setting,

                [Parameter(Mandatory=$true)]
                [Alias('NewValue')]
                    [string]$Value,

                [Parameter(Mandatory=$false)]
                    [switch]$SkipCheck = $false,

                [Parameter(Mandatory=$false)]
                    [switch]$Commit
            )

        Begin { $NewValue = $Value }

        Process
            {
                foreach($Computer in $ComputerName)
                    {
                        if($SkipCheck -eq $false)
                            {
                                # Validate Setting - Part 1: Ensure this is a legitimate settng in the BIOS
                                $CurrentSettings = Get-LenovoBIOSSetting -ComputerName $Computer -Setting $Setting
                                if([string]::IsNullOrEmpty($CurrentSettings)) { Write-Output "ERROR INVALID SETTING SPECIFIED: [$Setting]"; throw }

                                # Validate Setting - Part 2: Ensure an 'exact' BIOS setting was specified like 'USB Port 1' not 'USB%' which could match several.
                                if(@($CurrentSettings).Count -gt 1) { Write-Output "ERROR SETTING [$Setting] RETURNED TOO MANY MATCHES: [$($CurrentSettings.Setting -join ', ')]"; throw }

                                # Validate New Value - If the OptionalValue isn't 'N/A' then we can verify the user supplied
                                if($CurrentSettings.OptionalValue -ne 'N/A')
                                    {
                                        if($($CurrentSettings.OptionalValue -split ',') -cnotcontains $Value) { Write-Output "ERROR: INVALID VALUE SPECIFIED [$Value] - MUST BE ONE OF: [$($CurrentSettings.OptionalValue)]"; throw }
                                    }

                                if($CurrentSettings.CurrentValue -eq $NewValue) { Write-Output "No change made for [$Setting] on [$Computer] because new and current values are identical:`r`nCurrent Value: [$($CurrentSettings.CurrentValue)]`r`nUpdated Value: [$NewValue]"; return }

                                $Action = "from [$($CurrentSettings.CurrentValue)] to [$NewValue]"
                            }
                        else { $Action = "to [$NewValue]" }

                        if($PSCmdlet.ShouldProcess("Setting [$Setting] on [$Computer] $Action"))
                            {
                                If($Commit -eq $true)
                                    {
                                        $OriginalEAP = $ErrorActionPreference
                                        $ErrorActionPreference = 'Stop'

                                        Try { $SetResult = (gwmi -class Lenovo_SetBiosSetting -namespace root\wmi -ComputerName $Computer -ErrorAction Stop).SetBiosSetting("$Setting,$NewValue") }
                                        Catch { Write-Output "ERROR: FAILED TO SET BIOS SETTING [$Setting] TO [$NewValue] ON [$Computer]: $_"; throw }

                                        $ErrorActionPreference = $OriginalEAP

                                        Switch($SetResult.return)
                                            {
                                                'Success' { Write-Output "$($SetResult.Return)fully set [$Setting] on [$Computer] $Action" }
                                                default
                                                    {
                                                        Write-Output "ERROR: FAILED TO SET [$Setting] TO [$NewValue] ON [$Computer]: [$($SetResult.Return)]"
                                                        if($Firmware -eq 'UEFI') { Write-Output "HINT: Firmware is UEFI [$Firmware] and you //may// first need to disable either 'OS Optimized Defaults' or 'SecureBoot' as these lock certain settings in the BIOS." }
                                                        throw
                                                    }
                                            }
                                    }
                                Else
                                    {
                                        Write-Output "WARNING: Commit is false [$Commit]; NOT Changing BIOS Setting [$Setting] on [$Computer] $Action!"
                                    }
                            }
                    }
            }

        End {  }
    }
#endregion Function Set-LenovoBIOSSetting

Obviously you’re welcome to do as you wish in your environment, even roll the execution of the WMI method to the Set-LenovoBIOSSettings function.

Just be safe and sure of what you’re doing 🙂

Good Providence to you as you reconfigure your BIOS’!

Preparing for Windows 10: Move Computers into to Win 10 OUs

One thing that has annoyed me about MDT and SCCM was that there wasn’t a built-in method to move machines into the proper OU.  As such it required creating something from scratch and I opted for something that didn’t require dependencies, like the ActiveDirectory module.

This isn’t the best way and this isn’t the only way – it’s just a way; one of many in fact.

Please note that this is NOT the ideal way to handle any operations that require credentials!  Keeping credentials in a script is bad practice as anyone snooping around could happen upon them and create some problems.  Instead, you should rely on web services to do this and Maik Koster has put together an excellent little care package to help get you started.

Move-ComputerToOU Prerequisites

My script has a few prerequisites:

  • The current AD site
  • A [local] Domain Controller to use (recommended)
  • The current OU of the machine to be moved
  • The new OU to move the machine into

It’s important to know that this script does not rely on the ActiveDirectory module.
One of my [many] quirks is to try to keep everything self-contained where it makes sense to do so, and I liked the idea of not having to rely on some installed component, an EXE and so on.  But to be honest, web services is the way to go for this.

Getting the Current AD Site

Better see this post for that.

Getting a Local Domain Controller

Better see this post for that.

Getting the Current OU

Better see this post for that.

Getting / Setting the New OU

If this is being executed as part of an OSD, yank those details from the MachineObjectOU Task Sequence variable via something like:

Function Get-TaskSequenceEnvironmentVariable
    {
        Param([Parameter(Mandatory=$true,Position=0)]$VarName)
        Try { return (New-Object -COMObject Microsoft.SMS.TSEnvironment).Value($VarName) }
        Catch { throw $_ }
    }
$MachineObjectOU = Get-TaskSequenceEnvironmentVariable MachineObjectOU

Otherwise just feed it the new OU via the parameter

Process Explanation

We first have to find existing object in AD


# This is the machine we want to move
$ADComputer = $env:COMPUTERNAME

# Create Directory Entry with authentication
$de = New-Object System.DirectoryServices.DirectoryEntry($LDAPPath,"$domain\$user",$pass)

# Create Directory Searcher
$ds = New-Object System.DirectoryServices.DirectorySearcher($de)

# Fiter for the machine in question
$ds.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"

# Optionally, set other search parameters
#$ds.SearchRoot = $de
#$ds.PageSize = 1000
#$ds.Filter = $strFilter
#$ds.SearchScope = "Subtree"
#$colPropList = "distinguishedName", "Name", "samaccountname"
#foreach ($Property in $colPropList) { $ds.PropertiesToLoad.Add($Property) | Out-Null }

# Execute the find operation
$res = $ds.FindAll()

 

Then we bind to the existing computer object


# If there's an existing asset in AD with that sam account name, it should be the first - and only - item in the array.
# So we bind to the existing computer object in AD
$CurrentComputerObject = New-Object System.DirectoryServices.DirectoryEntry($res[0].path,"$domain\$user",$pass)

# Extract the current OU
$CurrentOU = $CurrentComputerObject.Path.Substring(8+$SiteDC.Length+3+$ADComputerName.Length+1)

 

From there setup the destination OU


# This Here we set the new OU location
$DestinationOU = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$SiteDC/$NewOU","$domain\$user",$pass)

 

And finally move the machine from the old/current OU to the new/destination OU


# And now we move the asset to that new OU
$Result = $CurrentComputerObject.PSBase.MoveTo($DestinationOU)

Move-ComputerToProperOU.ps1

So this is a shortened version of the script I’m using in production.  All you need to do is fill in the blanks and test it in your environment.


# There's a separate function to get the local domain controller
$SiteDC = Get-SiteDomainController
# Or you can hard code the local domain controller
$SiteDC = 'localdc.domain.fqdn'

# Build LDAP String
# I //think// there were maybe two cases whre this din't work
$LocalRootDomainNamingContext = ([ADSI]"LDAP://$SiteDC/RootDSE").rootDomainNamingContext
# So I added logic to trap that and pull what I needed from SiteDC
#$LocalRootDomainNamingContext = $SiteDC.Substring($SiteDC.IndexOf('.'))
#$LocalRootDomainNamingContext = ($LocalRootDomainNamingContext.Replace('.',',DC=')).Substring(1)

# Building the LDAP string
$LDAPPath = 'LDAP://' + $SiteDC + "/" + $LocalRootDomainNamingContext

# Set my domian for authentication
$domain = 'mydomain'

# Set the user for authentication
$user = 'myjoindomainaccount'

# Set the password for authentication
$pass = 'my sekret leet-o-1337 creds'

# This is the machine I want to find & move into the proper OU.
$ADComputerName = $env:COMPUTERNAME

# This is the new OU to move the above machine into.
$NewOU = 'OU=Laptops,OU=Win10,OU=Office,OU=CorpWorkstations,DC=domain,DC=fqdn'

# Create Directory Entry with authentication
$de = New-Object System.DirectoryServices.DirectoryEntry($LDAPPath,"$domain\$user",$pass)

# Create Directory Searcher
$ds = New-Object System.DirectoryServices.DirectorySearcher($de)

# Fiter for the machine in question
$ds.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"

# Optionally, set other search parameters
#$ds.SearchRoot = $de
#$ds.PageSize = 1000
#$ds.Filter = $strFilter
#$ds.SearchScope = "Subtree"
#$colPropList = "distinguishedName", "Name", "samaccountname"
#foreach ($Property in $colPropList) { $ds.PropertiesToLoad.Add($Property) | Out-Null }

# Execute the find operation
$res = $ds.FindAll()

# If there's an existing asset in AD with that sam account name, it should be the first - and only - item in the array.
# So we bind to the existing computer object in AD
$CurrentComputerObject = New-Object System.DirectoryServices.DirectoryEntry($res[0].path,"$domain\$user",$pass)

# Extract the current OU
$CurrentOU = $CurrentComputerObject.Path.Substring(8+$SiteDC.Length+3+$ADComputerName.Length+1)

# This Here we set the new OU location
$DestinationOU = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$SiteDC/$NewOU","$domain\$user",$pass)

# And now we move the asset to that new OU
$Result = $CurrentComputerObject.PSBase.MoveTo($DestinationOU)

 

Happy Upgrading & Good Providence!

Generate WindowsUpdate.Log Without Get-WindowsUpdateLog

Just like knowing that a shrimps heart is located in it’s head area (thorax) you can file this tidbit under useless facts.

If you find yourself in a situation where you need to convert some Windows Update .ETL files into human readable format and the Get-WindowsUpdateLog PowerShell cmdlet isn’t available for whatever reason, you can use TraceFmt.exe to do this for you.

The TraceFmt utility, available through both the Windows Software Development Kit (SDK) and Windows Driver Kit (WDK), takes the details in the trace logs and outputs a human-readable text file containing the formatted trace messages.

Usage:


tracefmt.exe -o "%UserProfile%\Desktop\TraceFmt-WindowsUpdate.log" %SystemRoot%\Logs\WindowsUpdate\WindowsUpdate.20171002.085155.537.1.etl -r srv*%SystemDrive%\Symbols*https://msdl.microsoft.com/download/symbols

Output:


Setting log file to: C:\windows\logs\WindowsUpdate\WindowsUpdate.20171002.085155.537.1.etl
Examining C:\Program Files (x86)\Windows Kits\10\bin\10.0.15063.0\x64\default.tmf for message formats,  3 found.
Searching for TMF files on path: C:\Program Files (x86)\Windows Kits\10\bin\10.0.15063.0\x64
Logfile C:\windows\logs\WindowsUpdate\WindowsUpdate.20171002.085155.537.1.etl:
        OS version              10.0.14393  (Currently running on 10.0.14393)
        Start Time              2017-10-02-08:51:55.537
        End Time                2017-10-02-09:01:57.790
        Timezone is             @tzres.dll,-112 (Bias is 300mins)
        BufferSize              4096 B
        Maximum File Size       128 MB
        Buffers  Written        3
        Logger Mode Settings    (11002009) ( sequential newfile paged)
        ProcessorCount          1

Processing completed   Buffers: 3, Events: 70, EventsLost: 0 :: Format Errors: 0, Unknowns: 7

Event traces dumped to C:\Users\perkinsjg\Desktop\TraceFmt-WindowsUpdate.log
Event Summary dumped to C:\Users\perkinsjg\Desktop\TraceFmt-WindowsUpdate.log.sum

 

Comparison

TraceFMT:

TraceFMTWindowsUpdateLog.png

Get-WindowsUpdateLog:

Get-WindowsUpdateLog

In Closing

The TraceFmt generated log file will not be identical to the one generated by the Get-WindowsUpdateLog PowerShell cmdlet; but it’ll help in a pinch!

For now, I bid you Good Providence!

Lenovo BIOS Manipulation: Get-LenovoBIOSSetting

First An Apology

I owe you an apology because I forgot to follow up: A year ago I posted about getting ‘pretty’ BIOS data via PowerShell+WMI but never followed up with a function/script; which I’ve been using for sometime now.  I lost sight of this so sorry about that.

Explanation

There really isn’t much to this: Execute Get-BIOSSetting without any parameters to pull ALL the BIOS settings from the current machine.

If you’re on a ThinkPad Laptop (T440 through T470, X240 through X270) it’ll output data like this


ComputerName    Setting                             CurrentValue                          OptionalValue
------------    -------                             ------------                          -------------
Leno-T470s AdaptiveThermalManagementAC         MaximizePerformance                   N/A
Leno-T470s AdaptiveThermalManagementBattery    Balanced                              N/A
Leno-T470s AlwaysOnUSB                         Enable                                N/A
Leno-T470s AMTControl                          Enable                                N/A
Leno-T470s BIOSPasswordAtBootDeviceList        Disable                               N/A
Leno-T470s BIOSPasswordAtReboot                Disable                               N/A
Leno-T470s BIOSPasswordAtUnattendedBoot        Enable                                N/A
Leno-T470s BIOSUpdateByEndUsers                Enable                                N/A
Leno-T470s BluetoothAccess                     Enable                                N/A
Leno-T470s BootDeviceListF12Option             Enable                                N/A
Leno-T470s BootDisplayDevice                   LCD                                   N/A
Leno-T470s BootMode                            Quick                                 N/A
Leno-T470s BootOrder                           USBCD:USBFDD:NVMe0:HDD0:USBHDD:PCILAN N/A
...
Leno-T470s USBPortAccess                       Enable                                N/A
Leno-T470s VirtualizationTechnology            Disable                               N/A
Leno-T470s VTdFeature                          Disable                               N/A
Leno-T470s WakeByThunderbolt                   Enable                                N/A
Leno-T470s WakeOnLAN                           ACOnly                                N/A
Leno-T470s WakeOnLANDock                       Enable                                N/A
Leno-T470s WiGig                               Enable                                N/A
Leno-T470s WiGigWake                           Disable                               N/A
Leno-T470s WindowsUEFIFirmwareUpdate           Enable                                N/A
Leno-T470s WirelessAutoDisconnection           Disable                               N/A
Leno-T470s WirelessLANAccess                   Enable                                N/A
Leno-T470s WirelessWANAccess                   Enable                                N/A

However if you’re on a ThinkCentre, like a M93, M900, M910 it’ll output something like this


ComputerName    Setting                                   CurrentValue                                                                                                              OptionalValue
------------    -------                                   ------------                                                                                                              -------------
Leno-M910 After Power Loss                          Last State                                                                                                                      Power On,Power Off,Last State
Leno-M910 Alarm Date(MM/DD/YYYY)                    [01/01/2016][Status:ShowOnly]                                                                                                   N/A
Leno-M910 Alarm Day of Week                         Sunday                                                                                                                          Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,SaturdayStatus:ShowOnly
Leno-M910 Alarm Time(HH:MM:SS)                      [00:00:00][Status:ShowOnly]                                                                                                     N/A
Leno-M910 Allow Flashing BIOS to a Previous Version Yes                                                                                                                             No,Yes
Leno-M910 Automatic Boot Sequence                   Network 1:M.2 Drive 1:PCIE4X_1 Drive:PCIE16X_1 Drive:SATA 1:SATA 2:SATA 3:SATA 4:Other Device                                   Network 2:Network 3:Network 4:USB FDD:USB HDD:USB CDROM:USB KEY
Leno-M910 Boot Agent                                PXE                                                                                                                             Disabled,PXE
Leno-M910 Boot Up Num-Lock Status                   On                                                                                                                              Off,On
...
Leno-M910 User Defined Alarm Time                   [00:00:00][Status:ShowOnly]                                                                                                     N/A
Leno-M910 VT-d                                      Enabled                                                                                                                         Disabled,Enabled
Leno-M910 Wake from Serial Port Ring                Primary                                                                                                                         Primary,Automatic,Disabled
Leno-M910 Wake on LAN                               Automatic                                                                                                                       Primary,Automatic,Disabled
Leno-M910 Wake Up on Alarm                          Disabled                                                                                                                        Single Event,Daily Event,Weekly Event,Disabled,User Defined
Leno-M910 Wednesday                                 Disabled                                                                                                                        Disabled,EnabledStatus:ShowOnly
Leno-M910 Windows UEFI Firmware Update              Enabled                                                                                                                         Disabled,Enabled

I like that the Desktop’s provide valid configuration settings and I hope this is something that will one day make it over to the laptops.

My two favorite parts about this script:

  • Supports querying remote systems
  • Supports querying for all, single or multiple specific settings

The script isn’t perfect but it works for our needs on our systems but I’m interested in hearing from you.

Function: Get-LenovoBIOSSetting

Usage

Get-LenovoBIOSSetting

Get-LenovoBIOSSetting -Setting Wi%

Get-LenovoBIOSSetting -ComputerName Leno-T470 -Setting 'Fingerprint%'

Get-LenovoBIOSSetting -ComputerName Leno-M900,Leno-M910 -Setting 'USB%','C State Support','Intel(R) Virtualization Technology'

Full code below


#region Function Get-LenovoBIOSSetting
Function Get-LenovoBIOSSetting
    {
        [cmdletbinding()]
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string[]]$ComputerName = $env:computername,

                [Parameter(Mandatory=$false)]
                    [string[]]$Setting
            )

        Begin { [System.Collections.ArrayList]$BIOSSetting = @() }

        Process
            {
                foreach($Computer in $ComputerName)
                    {
                        if($Setting)
                            {
                                Foreach($Item in $Setting)
                                    {
                                        Try
                                            {
                                                $arrCurrentBIOSSetting = gwmi -class Lenovo_BiosSetting -namespace root\wmi -Filter "CurrentSetting like '$Item,%'" -ComputerName $Computer -ErrorAction Stop | ? { $_.CurrentSetting -ne "" } | Select -ExpandProperty CurrentSetting
                                                Foreach($CurrentBIOSSetting in $arrCurrentBIOSSetting)
                                                    {
                                                        [string]$CurrentItem = $CurrentBIOSSetting.SubString(0,$($CurrentBIOSSetting.IndexOf(',')))
                                                
                                                        if($CurrentBIOSSetting.IndexOf(';') -gt 0) { [string]$CurrentValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(',')+1),$CurrentBIOSSetting.IndexOf(';')-$($CurrentBIOSSetting.IndexOf(',')+1)) }
                                                        else { [string]$CurrentValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(',')+1)) }
                                                
                                                        if($CurrentBIOSSetting.IndexOf(';') -gt 0)
                                                            {
                                                                [string]$OptionalValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(';')+1))
                                                                [string]$OptionalValue = $OptionalValue.Replace('[','').Replace(']','').Replace('Optional:','').Replace('Excluded from boot order:','')
                                                            }
                                                        Else { [string]$OptionalValue = 'N/A' }
                                                                                
                                                        $BIOSSetting += [pscustomobject]@{ComputerName=$Computer;Setting=$CurrentItem;CurrentValue=$CurrentValue;OptionalValue=$OptionalValue;}
                                                
                                                        Remove-Variable CurrentItem,CurrentValue,OptionalValue -ErrorAction SilentlyContinue -WhatIf:$false
                                                    }
                                                Remove-Variable arrCurrentBIOSSetting -ErrorAction SilentlyContinue -WhatIf:$false
                                            }
                                        Catch { Write-Output "ERROR: UNABLE TO QUERY THE BIOS VIA CLASS [Lenovo_BiosSeting] ON [$Computer] - POSSIBLE INVALID SETTING SPECIFIED [$Item][$CurrentItem]: $_"; throw }
                                    }
                            }
                        Else
                            {
                                Try
                                    {
                                        $arrCurrentBIOSSetting = gwmi -class Lenovo_BiosSetting -namespace root\wmi -ComputerName $Computer -ErrorAction Stop | ? { $_.CurrentSetting -ne "" } | Select -ExpandProperty CurrentSetting
                                        Foreach($CurrentBIOSSetting in $arrCurrentBIOSSetting)
                                            {
                                                [string]$CurrentItem = $CurrentBIOSSetting.SubString(0,$($CurrentBIOSSetting.IndexOf(',')))
                                        
                                                if($CurrentBIOSSetting.IndexOf(';') -gt 0) { [string]$CurrentValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(',')+1),$CurrentBIOSSetting.IndexOf(';')-$($CurrentBIOSSetting.IndexOf(',')+1)) }
                                                else { [string]$CurrentValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(',')+1)) }
                                                                        
                                                if($CurrentBIOSSetting.IndexOf(';') -gt 0)
                                                    {
                                                        [string]$OptionalValue = $CurrentBIOSSetting.SubString($($CurrentBIOSSetting.IndexOf(';')+1))
                                                        [string]$OptionalValue = $OptionalValue.Replace('[','').Replace(']','').Replace('Optional:','').Replace('Excluded from boot order:','')
                                                    }
                                                Else { [string]$OptionalValue = 'N/A' }
                                                                                                                        
                                                $BIOSSetting += [pscustomobject]@{ComputerName=$Computer;Setting=$CurrentItem;CurrentValue=$CurrentValue;OptionalValue=$OptionalValue;}
                                        
                                                Remove-Variable CurrentItem,CurrentValue,OptionalValue -ErrorAction SilentlyContinue -WhatIf:$false
                                            }
                                        Remove-Variable arrCurrentBIOSSetting -ErrorAction SilentlyContinue -WhatIf:$false
                                    }
                                Catch { Write-Output "ERROR: UNABLE TO QUERY THE BIOS VIA CLASS [Lenovo_BiosSeting] ON [$Computer]: $_"; throw }
                            }
                    }
            }
        
        End { $BIOSSetting }
    }
#endregion Function Get-LenovoBIOSSetting

Wrap It Up!

If you decide to try this and run into problems or have questions, please let me know as I’m generally interested in use cases and constructive feedback.

Thanks and Good Providence to you!

Task Sequence Fails to Start 0x80041032

Recommended reading: https://blogs.msdn.microsoft.com/steverac/2014/08/25/policy-flow-the-details/

After preparing a 1730 upgrade Task Sequence for our 1607 machines, I kicked it off on a box via Software Center and walked away once the status changed to ‘Installing thinking I’d come back to an upgraded machine an hour later.  To my surprise, I was still logged on and the TS was still ‘running’.  A few hours later the button was back to ‘Install’ with a status of ‘Available’.  Thinking I goofed I tried again and the same thing happened.

I assumed it was unique to this one machine so I tried it on another and it behaved the same way.  I then ran the upgrade sequence on 5 other machines and they all exhibited the same behavior.  I knew the Task Sequence was sound because others were using it, so it was definitely something unique to my machines but what?

Since I was doing this on 1607 machines I tried upgrading form 1511 to 1703 and 1511 to 1607 but they too failed the same way confirming it was not Task Sequence specific but again unique to my machines.  After spending a quite a few cycles on this, my original machine started failing differently: I was now seeing a ‘Retry’ button with a status of ‘Failed’.  I checked the smsts.log but it didn’t have a recent date/time stamp so it never got that far.  Hmm…

Check the TSAgent.log

Opening the TSAgent.log file I could see some 80070002 errors about not being able to delete HKLM\Software\Microsoft\SMS\Task Sequence\Active Request Handle but the real cause was a bit further up.

TSAgentLog

The lines of interest:


Getting assignments from local WMI. TSAgent 9/1/2017 12:58:27 PM 1748 (0x06D4)
pIWBEMServices->;;ExecQuery (BString(L"WQL"), BString (L"select * from XXX_Policy_Policy4"), WBEM_FLAG_FORWARD_ONLY, NULL, &pWBEMInstanceEnum), HRESULT=80041032 (e:\nts_sccm_release\sms\framework\osdmessaging\libsmsmessaging.cpp,3205) TSAgent 9/1/2017 1:03:55 PM 1748 (0x06D4)
Query for assigned policies failed. 80041032 TSAgent 9/1/2017 1:03:55 PM 1748 (0x06D4)oPolicyAssignments.RequestAssignmentsLocally(), HRESULT=80041032 (e:\cm1702_rtm\sms\framework\tscore\tspolicy.cpp,990) TSAgent 9/1/2017 1:03:55 PM 1748 (0x06D4)
Failed to get assignments from local WMI (Code 0x80041032) TSAgent 9/1/2017 1:03:55 PM 1748 (0x06D4)

The source of error code 80041032 is Windows Management (WMI) and translates to ‘Call cancelled’ which presumably happened while running the query select * from XXX_Policy_Policy4, where XXX is the site code.

I ran a similar query on my machine to get a feel for the number of items in there:


(gwmi -Class xxx_policy_policy4 -Namespace root\xxx\Policy\machine\RequestedConfig).Count

Which ended up failing with a Quota violation error suggesting I’ve reached the WMI memory quota.

Increase WMI MemoryPerHost & MemoryAllHosts

Fortunately, there’s a super helpful TechNet Blog post about this.  Since all of my test machines were running into this, I decided to make life easier for myself and use PowerShell to accomplish the task on a few of them thinking I’d have to raise the limit once.


$PHQC = gwmi -Class __providerhostquotaconfiguration -Namespace root
$PHQC.MemoryPerHost = 805306368
# Below is optional but mentioned in the article
#$PHQC.MemoryAllHosts = 2147483648
$PHQC.Put()
Restart-Computer

After the machine came up I ran the same query again, and after 2 minutes and 38 seconds it returned over 1800 items.  Great!  I ran it again and after 5 minutes it failed with the same quota violation error.  Boo urns.  I kept raising MemoryPerHost and MemoryAllHosts to insane levels to get the query to run back to back successfully.

The good news is that I made progress suggesting I’m definitely hitting some sort of memory ceiling that has now been raised.

The bad news is why me and not others?  Hmm…

Root Cause Analysis

I checked the deployments available to that machine and wowzers it was super long.  I won’t embarrass myself by posting an image of that but it was very long.  This helped to identify areas of improvement in the way we’re managing collections & deploying things, be it Applications, Packages, Software Updates and so on.

On my patient zero machine I ran the following to clear out the policies stored in WMI:


gwmi -namespace root\xxx\softmgmtagent -query "select * from ccm_tsexecutionrequest" | remove-wmiobject

gwmi -namespace root\xxx -query "select * from sms_maintenancetaskrequests" | remove-wmiobject

restart-service -name ccmexec

I then downloaded the policies and tried to image – it worked!  I decided to let that machine go and focus on some administrative cleanup in SCCM.  After tidying things up a bit, the rest of my 1607 machines started the 1703 upgrade Task Sequence without issue and the 1511 machines ran the 1607 upgrade Task Sequence as well.

As we continue to phase out Windows 7, we’ll hopefully update our methods to help avoid problems like this and perform that routine maintenance a little more frequently.

 

Backing up Recovery Keys to MBAM and AD During OSD

Scenario

As we prepared for our Windows 10 roll out, we had MBAM all setup and ready to go when a wise man suggested we backup the keys to AD too.  I was a little perplexed: In my mind this is redundant since that’s what MBAM is supposed to do.  Can’t we just trust MBAM to do its thing?  But then the same wise man dropped a statement that I totally agreed with:

“I don’t want to be the one to have to explain to our CIO that we have no way of unlocking some VIP’s machine.”

Neither did I.

Here’s a high level overview of how we setup MBAM during OSD.
It’s not the best way and it’s not the only way.  It’s just a way.

Prerequisites:

  1. Export your BitLocker registry settings from a properly configured machine
  2. Edit the export, set the ‘ClientWakeupFrequency‘ to something low like 5 minutes
  3. Edit the export, set the ‘StatusReportingFrequency‘ to something low like 10 minutes
  4. Package up the .REG file as part of your MBAM client installation
    • This could either be a true Package, but I would recommend an Application that runs a wrapper to import the registry configuration; or create an MST; or add it to the original MSI.

Task Sequence Setup

  1. Wait until the machine is in real Windows, not WinPE
  2. Install the MBAM client (obviously!)
  3. Reboot
  4. Stop the MBAM service – We need to do this so that the settings we make below take effect
  5. Set the MBAM service to start automatically without delay – Want to make sure it fires as soon as possible.
  6. Import your BitLocker registry settings you exported & edited
    • This is the real meat: Since GPO’s are not applied during OSD, your GPO policies won’t reach the machine during the imaging process.  This will ensure your policies are in play as soon as possible.
    • Most places don’t set the  ‘ClientWakeupFrequency‘ and/or ‘StatusReportingFrequency‘ values to something insanely low via GPO which is why we manually edited the .REG file.  If you left them at the default values, the keys wouldn’t get escrowed for a few hours due to the way the MBAM client works.
  7. Optional but Recommended: Switch to AES-XTS-256 by setting ‘EncryptionMethodWithXtsOs‘ in ‘HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\FVE‘ to ‘7
  8. Start the MBAM service
  9. Enable BitLocker using the MBAM Deployment Scripts
  10. Reboot the machine
  11. Continue with your normal imaging process

The Good

  • We’ve not run into machines with improper configurations.
  • Every machine is encrypted using
    • full disk encryption versus used space
    • leverages AES-XTS-256
  • Keys are quickly escrowed to both AD and MBAM.
  • It just works: Deployed with 1511, we’re moving to 1607 and IT is testing 1703.

The Bad

  • I couldn’t figure out how to perform full disk AES-XTS-256 encryption in WinPE so this has to happen when we’re in a real OS.
    • I tried setting the keys via the registry but didn’t bother editing WSF files or trying to reverse engineer what goes on in that step to see if I could make it work.
  • Encryption does NOT begin until after someone logs on.
  • Encryption takes a while (but not too long) on SSDs.

In Closing

I would really like to hear from others on this one.  Because it was – and has been working for a over a year now – we really couldn’t justify dedicating bandwidth to exploring this further.  So we left it as-is.  My brain would like to see it work ‘properly’ one day, but that’ll have to wait.

Good Providence to you!

PSA: Disabling Bluetooth via PowerShell in Response to BlueBorne

Please note that nearly all of the machines in our environment are on Windows 10 so this is written with that in mind.

With the recent news about BlueBorne:

It’s been an interesting day!

Since Bluetooth is generally enabled in our environment, one school of thought is to reduce the attack surface by disabling Bluetooth across the board, then re-enable where necessary as some users have Bluetooth keyboards, mice and other peripherals.

There were two approaches:

  • Disable it in the BIOS (more on that later)
  • Disable it in Device Manager

Since the latter was more universal, and I’ll explain my perspective on that, I slapped together something basic to disable Bluetooth devices on the system.  I haven’t tested it extensively but on all of my Lenovo ThinkPad laptops, it’s worked without issue.

Disable-Bluetoothv1


Function Disable-Bluetoothv1
    {
        foreach($BTDevice in $(Get-PnpDevice -FriendlyName '*bluetooth*' -Status OK -ErrorAction SilentlyContinue))
            {
                if(Get-PnpDevice -FriendlyName $BTDevice.FriendlyName -Status OK -ErrorAction SilentlyContinue)
                    {
                        try { Disable-PnpDevice -InstanceId $BTDevice.InstanceId -Verbose -Confirm:$false -WhatIf }
                        catch { Write-Output "ERROR DISABLING [$($BTDevice.FriendlyName)] @ [$($BTDevice.InstanceId)]:`r`n$_" }
                    }
            }
    }

Disable-Bluetoothv2


Function Disable-Bluetoothv2
    {
        foreach($BTDevice in $(gwmi -Class Win32_PnPEntity | ? { (($_.Caption -like '*bluetooth*') -or ($_.Description -like '*bluetooth*')) -and $_.Status -eq 'OK' }))
            {
                if(gwmi -Class Win32_Pnpentity -Filter "Caption='$($BTDevice.Caption)' AND Status='OK'")
                    {
                        try { Invoke-WmiMethod -InputObject $BTDevice -Name 'Disable' -Verbose -WhatIf }
                        catch { Write-Output "ERROR DISABLING [$($BTDevice.Caption)][$($BTDevice.Description)] @ [$($BTDevice.DeviceID)]:`r`n$_" }
                    }
            }
    }

Both work and I personally don’t have a preference.  It’s really just a tomato tomahtoe / potaytoh potato / six of one half dozen of another type situation.

Also, be sure to remove the -WhatIf parameter if you decide to use it!

So here’s what my Lenovo laptop looked like before:

BTBefore

And here’s what it looks like after:

BTAfter

Good Providence and be safe out there!

Synology Cloud Station Client Can’t Handle Read-Only Files

Script Updated 2017-03-01

I picked up a Synology NAS (DS411+ii) years ago and it’s still alive and kicking today, mostly due to the rock-solid hardware and the amazing improvements in DSM.

I’ve been slowly working on weaning myself off services like DropBox, OneDrive, Google Drive to rely fully on the NAS but it certainly hasn’t been without it’s struggles.

I setup Cloud Station on the NAS and installed the Cloud Station Client (CSC) on my home machines to sync key directories on the machine to the NAS.  Initially things worked well, but as I added more data – like 300GB of data – I noticed things were not synchronizing correctly.  Upon further investigation, the Cloud Station Client (CSC) was having trouble processing read-only files.

So here’s my operation:

  1. I work on system A, generating files in a synchronized directory
  2. The CSC syncs the data up to the NAS and all is well.
  3. I jump to system B and setup CSC which syncs the data down from the NAS

Both systems now have identical sets of data.

  1. I modify a handful of read-only files on system B which gets synced up to the NAS
  2. I switch to system A and the read-only files are now updated with copies from the NAS.
  3. I make changes to the same read-only files on system A which get synched up to the NAS
  4. I switch back to system B and CSC is up in arms because “FILENAME cannot be synced due to access permission denied, or it is in use.”

In this state, a file sync queue builds because CSC is hung up on the handful of files that it cannot overwrite locally because they’re read-only.  I thought it was going to fix itself so I left it in this state for a while until I realized changes I made were not reaching the other system.  That’s when I noticed the file sync queue was something like 90k and CSC needed some help.

The other cloud sync services don’t fall prey to this issue, so it’s a little strange that the CSC isn’t able to handle it.  I took to the Synology forums and not only found I wasn’t the only one experiencing this problem, but that someone wrote a small script to address this issue.  Score!

I ran the script which mostly worked but I ran into some odd problems.  Since I had nothing better to do, I expanded on it, adding some checks & balances, visual feedback and I tried to add some error handling.

It may not be perfect, but it resolved the issues I was running into and provides meaningful output.


[CmdletBinding()]
Param
    (
        # How long we want to give the client to catch up
        # Note, this is a calculated timeout based on this + the number of files it found that needed fixing
        # In my testing, this worked better than a fixed number that was either
        #     insanely high for a low number of files
        #     too low for a high number of files
        [Parameter(Mandatory=$false)]
            [int]$CloudStationClientCatchUpTimeOut = 60,

        # How long we wait before restarting the loop
        [Parameter(Mandatory=$false)]
            [int]$RestartFixTimeOut = 30,

        # Creation of this file will allow you to:
        #    safely stop the script, and
        #    un-fix (read: re-apply the read-only attribute) on files it already processed
        [Parameter(Mandatory=$false)]
            [string]$StopMonitoringFlagFile = "$env:LOCALAPPDATA\CloudStation\log\StopMonitoring.txt",

        # This is the path to the CloudStation log file; you should't have to change this.
        [Parameter(Mandatory=$false)]
            [string]$CSDaemonLog = $env:LOCALAPPDATA + '\CloudStation\log\daemon.log',

        # Use this for debugging purposes only.
        [Parameter(Mandatory=$false)]
            [bool]$DebugEnabled = $false
    )

###############################################################
###          DON'T CHANGE ANYTHING BELOW THIS LINE          ###
###############################################################

##*=============================================
##* VARIABLE DECLARATION
##*=============================================
#region VariableDeclaration

# Master Counter - Number of times the script ran
[int]$Global:Loop = 0

# Counter for number of files fixed
[int]$TotalNumberOfFilesFixed = 0

# Global Variable for Debug Mode
[bool]$Global:DebugEnabled = $DebugEnabled
if($Global:DebugEnabled -eq $true) { write-host "$(Get-Date -Format s) - [$Global:Loop] :: DEBUG :: DEBUG MODE HAS BEEN ENABLED [$Global:DebugEnabled]" }

#endregion VariableDeclaration
##*=============================================
##* END VARIABLE DECLARATION
##*=============================================

##*=============================================
##* FUNCTION LISTINGS
##*=============================================
#region FunctionListings

#region Function Pause-Script
Function Pause-Script
    {
        Param
            (
                [Parameter(Mandatory=$false)]
                    [string]$MSG,

                [Parameter(Mandatory=$false)]
                    [string]$Title
            )

        if([string]::IsNullOrEmpty($MSG)) { $MSG = 'Script Paused.  Press any key to continue.' }
        if([string]::IsNullOrEmpty($Title)) { $Title = 'Script Paused.  Press any key to continue.' }

        if($host.Name -notmatch 'ISE')
            {
                write-host `r`n`r`n$MSG
                $HOST.UI.RawUI.ReadKey(“NoEcho,IncludeKeyDown”) | Out-Null
                $HOST.UI.RawUI.Flushinputbuffer()
            }
        Else
            {
                [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null
                [System.Windows.Forms.MessageBox]::Show($MSG,$Title,[System.Windows.Forms.MessageBoxButtons]::OK) | Out-Null
            }
    }
#endregion #region Function Pause-Script

#region Function Toggle-ReadOnly
Function Toggle-ReadOnly
    {
        [CmdletBinding()]
        Param
            (
	            [Parameter(Mandatory=$true)]
                [ValidateScript({If(Test-Path $_){$true}else{Throw "Invalid path given: [$_]"}})]
                    [string[]]$File,

                [Parameter(Mandatory=$false)]
                    [switch]$MakeReadOnly
            )

        Begin {  }

        Process
            {
                Foreach($Item in $File)
                    {
                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Item [$Item]" }
                        [string]$ItemFixed = $Item.ToString().Replace('"','')
                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: ItemFixed [$ItemFixed]" }

                        If(Test-Path -Path $ItemFixed)
                            {
                                Try { (Get-ChildItem -Path "$ItemFixed" -Force -ErrorAction Stop).IsReadOnly = $MakeReadOnly; $Return = $true } Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR TOGGLING READONLY ATTRIBUTE ON [$ItemFixed]: [$_]"; $Return = $_ }
                            }
                        Else { write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR DID NOT FIND [$ItemFixed]" }
                    }
            }

        End { return $Return }
    }
#endregion Function Toggle-ReadOnly

#endregion FunctionListings
##*=============================================
##* END FUNCTION LISTINGS
##*=============================================

##*=============================================
##* SCRIPT BODY
##*=============================================
#region ScriptBody

Do
    {
        # Increment Counter
        [int]$Global:Loop++ | Out-Null

        # Reset these at the start of each loop
        # This is the abort flag
        [bool]$Abort = $false

        # This is the error flag
        [bool]$CriticalErrorEncountered = $false

        # This is the number of files we need to fix this time raound
        [int]$CountOfFilesToFix = 0

        # This is the array that holds the files we've successfully processed
        [string]$arrProcessed = $null;[System.Collections.ArrayList]$arrProcessed = @()

        if(Test-Path -Path $StopMonitoringFlagFile -PathType Leaf) { [bool]$Abort = $true }

        if(!(Test-Path $CSDaemonLog -PathType Leaf)) { write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR MISSING CLOUD STATION DAEMON LOG [$CSDaemonLog]"; $CriticalErrorEncountered = $true; continue }

        Try
            {
                write-host "$(get-date -Format s) - [$Global:Loop] :: Ingesting log [$CSDaemonLog]"
                $CSDaemonLogFullContent = $null;$CSDaemonLogFullContent = Get-Content $CSDaemonLog -ErrorAction Stop

                ##############################################################################
                #                             SUPER DEBUG                                    #
                # Only enable this if you're seeing //really// strange or unexpected results #
                <#                 if($Global:DebugEnabled -eq $true)                     {                         write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogFullContent BEGIN>>>>"
                        write-host $CSDaemonLogFullContent
                        write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogFullContent END<<<<"                     }                 #>
                ##############################################################################

                Try
                    {
                        write-host "$(get-date -Format s) - [$Global:Loop] :: Checking ingested log content for errors"
                        # Only grab lines that contain '[Error]' since those are the lines we want to focus on.
                        $CSDaemonLogContent = $null; [System.Collections.ArrayList]$CSDaemonLogContent = {$CSDaemonLogFullContent | ? { $_ -match [regex]::Escape('[Error]') }}.Invoke()

                        ##############################################################################
                        #                             SUPER DEBUG                                    #
                        # Only enable this if you're seeing //really// strange or unexpected results #
                        <#                         if($Global:DebugEnabled -eq $true)                             {                                 write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Number of items in `$CSDaemonLogContent BEFORE refinement [$($CSDaemonLogContent.Count)]"                                 write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent BEGIN>>>>"
                                for($i=0; $i -lt $CSDaemonLogContent.Count;$i++) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent[$i] $($CSDaemonLogContent[$i])" }
                                write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent END<<<<"                             }                         #>
                        ##############################################################################

                        # However, there are certain types of errors we can likely ignore since they don't specifically speak to the read-only issue this script aims to resolves.
                        $arrErrorsToIgnore = @('*channel error while connecting to server*',
                                               '*Failed to remove local signature*',
                                               '*Failed to write magic*',
                                               '*Failed to send protocol header.*',
                                               '*Failed to send protocol.*'
                                               )
                                               <#                                                ,                                                'Failed to prepare file block for',                                                'Failed to read file for'                                                #>

                        # Loop through the errors to ignore and remove them from the array if they match
                        # At the same time we'll eliminate any lines that don't look like they contain a real drive letter (*:\*)
                        foreach($Line in @($CSDaemonLogContent))
                            {
                                [bool]$IgnoreLine = $false
                                foreach($ErrorToIgnore in $arrErrorsToIgnore)
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: [$i][$ErrorToIgnore][$Line]" }
                                        ##############################################################################

                                        if(($Line -like $ErrorToIgnore) -eq $true)
                                            {
                                                $IgnoreLine = $true
                                                ##############################################################################
                                                #                             SUPER DEBUG                                    #
                                                # Only enable this if you're seeing //really// strange or unexpected results #
                                                #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Ignoring [$IgnoreLine] because [$ErrorToIgnore] was found in: [$Line] " }
                                                ##############################################################################
                                            }
                                        else
                                            {
                                                ##############################################################################
                                                #                             SUPER DEBUG                                    #
                                                # Only enable this if you're seeing //really// strange or unexpected results #
                                                #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Proceeding normally because [$ErrorToIgnore] was not found in: [$Line]" }
                                                ##############################################################################
                                            }
                                    }

                                # No need to check if we're already ignoring the line
                                if($IgnoreLine -ne $true)
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Performing drive check (*:\*) because IgnoreLine is not true [$IgnoreLine]" }
                                        ##############################################################################

                                        # Check to ensure the line contains :\ which is more than likely a real path, otherwise we can skip it
                                        if($Line -notlike '*:\*')
                                            {
                                                $IgnoreLine = $true
                                                ##############################################################################
                                                #                             SUPER DEBUG                                    #
                                                # Only enable this if you're seeing //really// strange or unexpected results #
                                                #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Ignoring [$IgnoreLine] because it doesn't appear to correspond to a local path: [$Line] " }
                                                ##############################################################################
                                            }
                                        else
                                            {
                                                ##############################################################################
                                                #                             SUPER DEBUG                                    #
                                                # Only enable this if you're seeing //really// strange or unexpected results #
                                                #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Proceeding normally because it appears to correspond to a local path: [$Line]" }
                                                ##############################################################################

                                                # If it does not have slashes, it's not a file path so we can ignore it
                                                if(-not $Matches[1].Contains('/'))
                                                    {
                                                        $IgnoreLine = $true
                                                        ##############################################################################
                                                        #                             SUPER DEBUG                                    #
                                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Ignoring [$IgnoreLine] because it doesn't contain a '/': [$Line]" }
                                                        ##############################################################################
                                                    }
                                                else
                                                    {
                                                        ##############################################################################
                                                        #                             SUPER DEBUG                                    #
                                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Proceeding normally because it does contain a '/': [$Line]" }
                                                        ##############################################################################
                                                    }
                                            }
                                    }
                                else
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Skipping Drive (*:\*) check because IgnoreLine is already true [$IgnoreLine]" }
                                        ##############################################################################
                                    }

                                if($IgnoreLine -eq $true)
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: IgnoreLine is true [$IgnoreLine]: Removing from `$CSDaemonLogContent [$Line]" }
                                        ##############################################################################

                                        # Remove that line/log entry from the array since we deemed it wasn't valid.
                                        $CSDaemonLogContent.Remove($Line) | Out-Null
                                    }
                                else
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: IgnoreLine is false [$IgnoreLine]: Leaving `$CSDaemonLogContent [$Line]" }
                                        ##############################################################################
                                    }
                            }

                        ##############################################################################
                        #                             SUPER DEBUG                                    #
                        # Only enable this if you're seeing //really// strange or unexpected results #
                        <#                         if($Global:DebugEnabled -eq $true)                             {                                 write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Number of items in `$CSDaemonLogContent AFTER refinement [$($CSDaemonLogContent.Count)]"                                 write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent BEGIN>>>>"
                                for($i=0; $i -lt $CSDaemonLogContent.Count;$i++) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent[$i] $($CSDaemonLogContent[$i])" }
                                write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CSDaemonLogContent END<<<<"                             }                         #>
                        ##############################################################################

                        # Here we check if the array contains content (read: errors) and if not, we pause then start the loop again.
                        if(([string]::Isnullorempty($CSDaemonLogContent)) -or ($CSDaemonLogContent.Count -eq 0))
                            {
                                write-host "$(get-date -Format s) - [$Global:Loop] :: Congratulations - No file errors found!"
                                if(($CriticalErrorEncountered -ne $true) -and ($Abort -ne $true) -and  (!(Test-Path -Path $StopMonitoringFlagFile -PathType Leaf)))
                                    {
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: Restarting in [$RestartFixTimeOut] seconds.`r`n"
                                        Start-Sleep -Seconds $RestartFixTimeOut
                                    }
                                continue
                            }

                        # Otherwise there //are// errors so we need to dive into that further.
                        write-host "$(get-date -Format s) - [$Global:Loop] :: `tWARNING: Found [$($CSDaemonLogContent.Count)] error(s) in the log [$CSDaemonLog]`r`n"

                        ##############################################################################
                        #                             SUPER DEBUG                                    #
                        # Only enable this if you're seeing //really// strange or unexpected results #
                        #if($Global:DebugEnabled -eq $true) { for($i=0; $i -ne $CSDaemonLogContent.Count;$i++) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Error $($i+1) of $($CSDaemonLogContent.Count): $($CSDaemonLogContent[$i])" } }
                        ##############################################################################

                        for($i=0; $i -lt $CSDaemonLogContent.Count;$i++)
                            {
                                [bool]$IgnoreError = $false

                                ##############################################################################
                                #                             SUPER DEBUG                                    #
                                # Only enable this if you're seeing //really// strange or unexpected results #
                                #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: `$CSDaemonLogContent[$i] [$($CSDaemonLogContent[$i])]" }
                                ##############################################################################

                                # This is a regex that aims to extract just the file name bits from the 'error' line entry.
                                # The regex looks for what ever is in between the single quotes '' and extracts just that
                                # Example
                                #     Mar 01 11:15:48 [ERROR] upload-local-handler.cpp(624): Failed to read file for 'path:\some/nested/directory/fi.le', try it again 0 潎攠牲牯
                                #     Mar 01 11:15:48 [ERROR] upload-local-handler.cpp(435): Failed to prepare file block for 'path:\some/nested/directory/fi.le'. System error.
                                # Leaving us with just: 'path:\some/nested/directory/fi.le'
                                $CSDaemonLogContent[$i] -match ".+'(.*?.+?)'" | Out-Null

                                # Matches[0] is the entire line
                                # Matches[1] is regex result

                                # Checks to see if $Matches[1] is null or empty; hopefully shouldn't happen at this point
                                if([string]::IsNullOrEmpty($Matches[1]) -eq $true)
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: WARNING Matches[1] is null or empty [$($Matches[1])]" }
                                        ##############################################################################
                                        continue
                                    }
                                else
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Matches[1] is not null or empty [$($Matches[1])]" }
                                        ##############################################################################
                                    }

                                # If it does not have slashes, it's not a file path so we can ignore it
                                if(-not $Matches[1].Contains('/'))
                                    {
                                        ##############################################################################
                                        #                             SUPER DEBUG                                    #
                                        # Only enable this if you're seeing //really// strange or unexpected results #
                                        #if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Matches[1] [$($Matches[1])] DOES NOT contain a '/' [$($Matches[1].Contains('/'))]" }
                                        ##############################################################################
                                        continue
                                    }

                                # Unix to Windows path fix
                                $FilePathFixed = $null; [string]$FilePathFixed = $Matches[1].Replace('/','\')
                                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: FilePathFixed: [$FilePathFixed]" }

                                # If $Matches[1] contains something and it's not already in the array $arrProcessed, we need to check it out
                                if(([string]::IsNullOrEmpty($Matches[1]) -eq $false) -and ($arrProcessed.Contains($FilePathFixed) -eq $false))
                                    {
                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Matches[1] is not null or empty [$($Matches[1])] AND `$arrProcessed does not contain [$FilePathFixed] [$($arrProcessed.Contains($FilePathFixed))]" }

                                        write-host "$(get-date -Format s) - [$Global:Loop] :: The Synology Cloud Station Client had trouble processing file [$FilePathFixed]"

                                        if(Test-Path -Path "$FilePathFixed")
                                            {
                                                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Found FilePathFixed [$FilePathFixed]" }

                                                Try
                                                    {
                                                        # Determine if the file is read-only or not
                                                        [bool]$IsReadOnly = (Get-ChildItem -Path "$FilePathFixed" -Force -ErrorAction Stop ).IsReadOnly
                                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: IsReadOnly is [$IsReadOnly]" }

                                                        If ($IsReadOnly -eq $true)
                                                            {
                                                                write-host "$(get-date -Format s) - [$Global:Loop] :: REASON: Possibly because the file is read-only; Removing read-only attribute..."
                                                                $DisableReadOnly = Toggle-ReadOnly -File "$FilePathFixed"
                                                                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: DisableReadOnly is [$DisableReadOnly]" }

                                                                if($DisableReadOnly -eq $true)
                                                                    {
                                                                        write-host "$(get-date -Format s) - [$Global:Loop] :: Successfully Toggled Read-Only Attribute on [$FilePathFixed]"
                                                                        Try { $arrProcessed.Add($FilePathFixed) | Out-Null; [int]$CountOfFilesToFix++ | Out-Null; [int]$TotalNumberOfFilesFixed++ | Out-Null } Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR ADDING TO COLLECTION [$FilePathFixed]: $_" }
                                                                    }
                                                                else { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR ENABLING READ-ONLY ATTRIBUTE ON FILE [$FilePathFixed]: [$DisableReadOnly]" }
                                                            }
                                                        else { write-host "$(get-date -Format s) - [$Global:Loop] :: `tWARNING: Unsure why the Synology Cloud Station Client had trouble with file [$FilePathFixed] since it's not read-only [$IsReadOnly]." }
                                                    }
                                                Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR CHECKING READ-ONLY ATTRIBUTES ON FILE [$FilePathFixed]: $_" }
                                            }
                                        else { write-host "$(get-date -Format s) - [$Global:Loop] :: REASON: Possibly because the file is missing [$FilePathFixed]" }
                                    }
                                Elseif(([string]::IsNullOrEmpty($Matches[1]) -eq $false) -and ($arrProcessed.Contains($FilePathFixed) -eq $true)) { write-host "$(get-date -Format s) - [$Global:Loop] :: Skipping Processed File [$FilePathFixed]"  }
                                else
                                    {
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: SOMETHING UNEXPECTED HAPPENED:"
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: `$Matches[1] [$($Matches[1])]"
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: `$arrProcessed.Contains($($Matches[1])) [$($arrProcessed.Contains($Matches[1]))]"
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: `$FilePathFixed [$FilePathFixed]"
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: `$arrProcessed.Contains($FilePathFixed) [$($arrProcessed.Contains($FilePathFixed))]"
                                        write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR: DUE TO THE UNEXPECTED NATURE OF THE ERROR ABOVE WE ARE BREAKING OUT OF THE FIXING LOOP"
                                        $CriticalErrorEncountered = $true
                                    }

                                # Check for the flag file that will stop the script
                                if(Test-Path -Path $StopMonitoringFlagFile -PathType Leaf)
                                    {
                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: WARNING Stop monitoring flag file found [$StopMonitoringFlagFile]; BREAKING OUT OF THE FIXING LOOP" }
                                        [bool]$Abort = $true
                                    }
                                else { if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Stop monitoring flag file not found [$StopMonitoringFlagFile]; Continuing execution" } }

                                if(($CriticalErrorEncountered -eq $true) -or ($Abort -eq $true))
                                    {
                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: ERROR EITHER CRITICALERRORENCOUNTERED IS TRUE [$CriticalErrorEncountered] OR ABORT IS TRUE [$Abort]; BREAKING OUT OF THE FIXING LOOP" }
                                        break
                                    }
                                else
                                    {
                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Both CriticalErrorEncountered [$CriticalErrorEncountered] and Abort [$Abort] are false; Continuing execution" }
                                        Write-Host
                                    }
                            }

                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: CountOfFilesToFix is [$CountOfFilesToFix]" }

                        if ($CountOfFilesToFix -gt 0)
                            {
                                write-host "$(get-date -Format s) - [$Global:Loop] :: Finished processing [$CountOfFilesToFix] file(s)."
                                if($DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: `$CountOfFilesToFix.ToString().Length is [$([string]$CountOfFilesToFix.ToString().Length)]" }

                                # I find that the more files we fix, the longer we have to wait for the Cloud Station Client to catch up.
                                # So this is some simple logic based on my [limited] testing with a little over 125000 files to figure out how long we should wait.
                                switch([string]$CountOfFilesToFix.ToString().Length)
                                    {
                                        { ($_ -eq 1) } { $CalculatedTimeOut = $CloudStationClientCatchUpTimeOut }
                                        { ($_ -eq 2) } { $CalculatedTimeOut = $CloudStationClientCatchUpTimeOut + ([math]::Round($CountOfFilesToFix/10)*10) }
                                        { ($_ -eq 3) } { $CalculatedTimeOut = $CloudStationClientCatchUpTimeOut + ([math]::Round($CountOfFilesToFix/100)*10) }
                                        { ($_ -ge 4) } { $CalculatedTimeOut = $CloudStationClientCatchUpTimeOut + ([math]::Round($CountOfFilesToFix/1000)*10) }
                                    }
                                write-host "$(get-date -Format s) - [$Global:Loop] :: Waiting [$CalculatedTimeOut] seconds for the Cloud Station Client to resync the [$CountOfFilesToFix] file(s) we fixed."
                                Start-Sleep -Seconds $CalculatedTimeOut

                                write-host "$(get-date -Format s) - [$Global:Loop] :: Unfixing read-only attributes on [$CountOfFilesToFix] changed file(s)."
                                foreach($ProcessedFile in @($arrProcessed))
                                    {
                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: ProcessedFile is [$ProcessedFile]" }
                                        if(Test-Path -Path "$ProcessedFile")
                                            {
                                                Try
                                                    {
                                                        [bool]$IsReadOnly = (Get-ChildItem -Path "$ProcessedFile" -Force -ErrorAction Stop ).isreadonly
                                                        if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: IsReadOnly is [$IsReadOnly]" }

                                                        If ($IsReadOnly -eq $false)
                                                            {
                                                                write-host "$(get-date -Format s) - [$Global:Loop] :: Re-adding read-only attribute on file [$ProcessedFile]..."
                                                                $EnableReadOnly = Toggle-ReadOnly -File $ProcessedFile -MakeReadOnly
                                                                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: EnableReadOnly is [$EnableReadOnly]" }

                                                                if($EnableReadOnly -eq $true)
                                                                    {
                                                                        write-host "$(get-date -Format s) - [$Global:Loop] :: Successfully re-set read-only attribute on file [$ProcessedFile]"
                                                                        Try { $arrProcessed.Remove($ProcessedFile) | Out-Null } Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: ERROR REMOVING FILE [$ProcessedFile] FROM COLLECTION: $_" }
                                                                    }
                                                                else { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR TOGGLING READ-ONLY ATTRIBUTE ON FILE [$ProcessedFile]: [$EnableReadOnly]" }
                                                            }
                                                        else { write-host "$(get-date -Format s) - [$Global:Loop] :: `tWARNING File [$ProcessedFile] is already set to read-only [$IsReadOnly]; Skipping..." }
                                                    }
                                                Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR CHECKING READ-ONLY ATTRIBUTES ON FILE [$ProcessedFile]: $_" }
                                            }
                                        else { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR FILE MISSING: [$ProcessedFile]" }

                                        write-host
                                    }
                            }
                        else { write-host "$(get-date -Format s) - [$Global:Loop] :: Congratulations - No [$CountOfFilesToFix] files needed processing!"}
                    }
                Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR AN UNKNOWN INNER ERROR OCCURRED: $_"; $CriticalErrorEncountered = $true }
            }
        Catch { write-host "$(get-date -Format s) - [$Global:Loop] :: `tERROR GETTING CONTENT OF [$CSDaemonLog]: $_"; $CriticalErrorEncountered = $true }

        if(Test-Path -Path $StopMonitoringFlagFile -PathType Leaf)
            {
                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: WARNING Stop monitoring flag file found [$StopMonitoringFlagFile]" }
                [bool]$Abort = $true
            }
        Else { if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: Stop monitoring flag file not found [$StopMonitoringFlagFile]; Continuing execution" } }

        if(($CriticalErrorEncountered -ne $true) -and ($Abort -ne $true))
            {
                write-host "$(get-date -Format s) - [$Global:Loop] :: Loop Completed - waiting [$RestartFixTimeOut] seconds before restarting.`r`n"
                Start-Sleep -Seconds $RestartFixTimeOut
            }
        else
            {
                if($Global:DebugEnabled -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: DEBUG :: ERROR EITHER CRITICALERRORENCOUNTERED IS TRUE [$CriticalErrorEncountered] OR ABORT IS TRUE [$Abort]; BREAKING OUT OF THE MAIN LOOP" }
                if($Abort -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: WARNING Process aborted due to presence of flag file [$StopMonitoringFlagFile]" }
                if($CriticalErrorEncountered -eq $true) { write-host "$(get-date -Format s) - [$Global:Loop] :: CRITICAL ERROR ENCOUNTERED; SCRIPT QUITTING!" }
            }
    }
Until(($Abort -eq $true ) -or ($CriticalErrorEncountered -eq $true))

write-host "$(get-date -Format s) - [$Global:Loop] :: Total number of files fixed this session: [$TotalNumberOfFilesFixed]`r`n"

Pause-Script

#endregion ScriptBody
##*=============================================
##* END SCRIPT BODY
##*=============================================

Practical Use: Manipulating XML Files With PowerShell

In my previous post I talked about getting values from XML files.  Today we’re going to update a value in an XML file.

As before, I’m not a PowerShell or XML guru, but what I’m going to cover below works for me and I can’t think of a reason it wouldn’t work for you.

We’re going to continue using the sample XML file provided by Microsoft.  Copy the XML content & paste into a new file, books.xml, & save it some place convenient.

Getting the Current Value

We want to change an author’s name from Stefan to Stephane.  First, let’s find the entries that we need to change:

[xml]$XML = Get-Content &quot;C:\Users\Julius\Downloads\books.xml&quot;
$XML.catalog.book | ? { $_.author -like '*stefan' }

XML-SetValue-001

Great only one result so we don’t need to loop!

Setting the New Value

We know the property we want is author so we can simply  do something like this to set the new value:


($XML.catalog.book | ? { $_.author -like &quot;*stefan&quot; }).author = &quot;Knorr, Stephane&quot;

But I find it helpful to do something like this instead

$Node = $XML.catalog.book | ? { $_.author -like &quot;*stefan&quot; }

$Node = $XML.catalog.book | ? { $_.author -like &quot;*stefan&quot; }

&quot;Old Author: {0}&quot; -f $Node.author

$Node.author = $Node.author.Replace('Stefan','Stephane')

&quot;New Author: {0}&quot; -f $Node.author

XML-SetValue-002.PNG

If you had multiple entries, as is the case with author Eva Corets, you could do this:

foreach($Result in ($XML.catalog.book | ? { $_.author -like &quot;Corets*&quot; }))
    {
        write-host &quot;Book #$($Result.id) Written by $($Result.author)&quot;
        $Result.author = 'Mendez, Eva'
        write-host &quot;Book #$(Result.id) Updated to $($Result.author)`r`n
    }

XML-SetValue-003.PNG

Saving the Updated XML

The save operation is super easy:

$XML.Save(&quot;C:\Users\Julius\Downloads\book.xml&quot;)

Done!
But I’m a big fan of having backup copies, so I opt for something like:


[string]$XMLFile = &quot;C:\Users\Julius\Downloads\books.xml&quot;
[xml]$XML = Get-Content $XMLFile
foreach($Result in ($XML.catalog.book | ? { $_.author -like &quot;Corets*&quot; }))
    {
        &quot;Book #{0} Written by {1}&quot; -f $Result.id,$Result.author
        $Result.author = 'Mendez, Eva'
        &quot;Book #{0} Updated to {1}`r`n&quot; -f $Result.id,$Result.author
    }
Copy-Item -Path $XMLFile -Destination &quot;$XMLFile.ORIG.$(Get-date -Format 'yyyymmdd_hhmmss')&quot;
$XML.Save($XMLFile)

 

Function Set-XMLValue

Try to think of this function a more of a framework than a complete solution.  I recently had to update a few different XML files, some of which were on a few hundred machines and used this to do the heavy lifting.

Function Set-XMLValue
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$XMLFile,

                [Parameter(Mandatory=$true)]
                    [string]$XMLTreePath,

                [Parameter(Mandatory=$true)]
                    [string]$Property,

                [Parameter(Mandatory=$false)]
                    $OldValue,

                [Parameter(Mandatory=$true)]
                    $NewValue,

                [Parameter(Mandatory=$true)]
                    [string]$NewFile = $null
            )

        if(!(Test-Path -Path $XMLFile -PathType Leaf)) { return 2 }

        [bool]$DoUpdate = $false

        Try
            {
                [xml]$XML = Get-Content $XMLFile

                $XMLPath = '$XML.' + $XMLTreePath

                Foreach($Node in (Invoke-Expression $XMLPath | ? { $_.$Property -ieq &quot;$OldValue&quot; }))
                    {
                        # Check to confirm that particular property exists
                        if([string]::IsNullOrEmpty($Node)) { Write-host &quot;ERROR: NO PROPERTY [$Property] FOUND CONTAINING ORIGINAL VALUE [$OldValue]&quot;; [int]$Return = 2 }

                        # Get current value from XML
                        $CurrValue = $Node.$Property

                        # Phase 1: Analysis of parameters and values

                        # Check if the old value was specified
                        if($OldValue)
                            {
                                # When the old value is specified, the script check if the current value matches the old value.

                                # If the current value matches old value and will need to be updated
                                if($CurrValue -eq $OldValue) { $DoUpdate = $true }

                                # If the current value doesn't match the old value but matches the new, its already up to date
                                Elseif($CurrValue -eq $NewValue) { [int]$Return = 0 }

                                # If the current value doesn't match the old or new value we won't change anything but return the current value
                                Else { [string]$Return = &quot;WARNING: The current value [$CurrValue] did not match the specified [$OldValue] so NO changes were made.&quot; }

                            }
                        # If an old value was not specified, we'll update regardless of the current value
                        Else
                            {
                                # If the current value doesn't match the new value it will need to be updated
                                if($CurrValue -ne $NewValue) { $DoUpdate = $true }

                                # If the current value matches the new value its already up to date
                                elseif($CurrValue -eq $NewValue) { [int]$Return = 0 }
                            }

                        # Phase 2: Performing the update if deemed necessary
                        If($DoUpdate -eq $true)
                            {
                                # Update value
                                $Node.$Property = [string]$NewValue

                                # If we're using a new file, we don't need to backup the original file.
                                if([string]::IsNullOrEmpty($NewFile)) { $XML.Save($NewFile) }
                                Else
                                    {
                                        # Backup existing XML
                                        Copy-Item -Path $XMLFile -Destination &quot;$XMLFile.ORIG.$(Get-date -Format 'yyyymmdd_hhmmss')&quot; -Force -ErrorAction Stop

                                        # Save new/updated XML (overwrite existing)
                                        $XML.Save($XMLFile)
                                    }

                                # Success!
                                [int]$Return = 0
                            }
                    }
            }
        Catch { Write-Warning &quot;ERROR DOING XML UPDATE OPERATION for [$XMLFile]: $_&quot;; [int]$Return = 1 }
        return $Return
    }

$UpdateResult = Update-XMLValue -XMLFile &quot;C:\Users\Julius\Downloads\books.xml&quot; -XMLTreePath catalog.book -Property author -OldValue 'Knorr, Stefan' -NewValue 'Knorr, Stephane' -NewFile &quot;C:\Users\Julius\Downloads\newbooks.xml&quot;

write-host &quot;UpdateResult [$UpdateResult]&quot;

This may not be fancy, and arguably complicated, but if you’re dealing with multiple XML files, PowerShell can be a huge timesaver.

 

Good Providence!

Practical Use: Getting Values from XML Files

A number of applications we use in the organization have XML-based configuration files and occasionally the need arises to validate the settings from the configuration.  For example, we might want to verify that a user is pointed to the right server, or that a particular setting is set to X.  We could treat XML files the same way we would a typical text file, as seen here, but where’s the fun in that?

I’m not a PowerShell or XML guru, but what I’m going to cover below works for me and I can’t think of a reason it wouldn’t work for you.

To keep things simple, we’ll be working with a sample XML file provided by Microsoft.  Copy the XML content & paste into a new file, books.xml, & save it some place convenient.

Ingest An XML File

In order to get started, you need to pull in the XML into a variable for further manipulation as such:

[xml]$XML = Get-Content &amp;amp;quot;C:\Users\Julius\Downloads\books.xml&amp;amp;quot;
# OR
$XML = [xml](Get-Content &amp;amp;quot;C:\Users\Julius\Downloads\books.xml&amp;amp;quot;)

Six of one, half a dozen of another; whatever works for you.

If you type $XML you should see something to the effect of:

XML-GetValues-001

Browsing the Structure

Once your XML variable is populated, it’s time to walk the XML tree structure to find the key that contains the data you’re looking for; And in order to do that you have to know the tree structure.

Without going too deep into this

  • The first line is the XML prolog containing the version information
  • The second is the <catalog> and that is our root element or node
  • All of the <book> nodes are children of the root
  • Each <book> node has a number of children: author, title, genre, price, publish_date & description.

Keep in mind this is a pretty simple & basic XML example, so the structure doesn’t go too deep.  You may find that your application XML’s are several layers deep.

If I wanted to see all the books, I would use

$XML.catalog.book

Which would then list the books:

XML-GetValues-002.PNG

 

Locating the Right Data

If I wanted to find all books by written by authors Stefan Knorr and Eva Corets, I would use

$XMl.catalog.book | ? { $_.author -like &quot;*stefan&quot; }

$XMl.catalog.book | ? { $_.author -like &quot;Corets*&quot; }

That would quickly narrow the scope:

XML-GetValues-003.PNG

Getting Specific Values

To grab key bits of details from Stefan Knorr’s book I could do

$XML.catalog.book | ? { $_.author -like &quot;*stefan&quot;} | select id,price,publish_date

# OR

$Node = $XML.catalog.book | ? { $_.author -like &quot;*stefan&quot; }

$Node.id

$Node.price

$Node.publish_date

XML-GetValues-004.PNG

I try to be as specific as possible when searching for data.

 

Function Get-XMLValue

This function is more of a framework as it depends squarely on the XML you’re working with and what you want to retrieve.  I recently had to verify a setting on a few hundred machines, so I used a modified version of this function to do the heavy lifting.

The function below is geared to return the book description when supplied the ID.

Function Get-XMLValue
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$XMLFile,

                [Parameter(Mandatory=$true)]
                    [string]$XMLTreePath,

                [Parameter(Mandatory=$true)]
                    [string]$AnchorProperty,

                [Parameter(Mandatory=$true)]
                    [string]$AnchorValue,

                [Parameter(Mandatory=$true)]
                    [string]$Property
            )

        if(!(Test-Path -Path $XMLFile -PathType Leaf)) { return 2 }

        Try
            {
                [xml]$XML = Get-Content $XMLFile

                $XMLPath = '$XML.' + $XMLTreePath
                $Return = @()
                Foreach($Node in (Invoke-Expression $XMLPath | ? { $_.$AnchorProperty -ieq &quot;$AnchorValue&quot; })) { $Return += $Node.$Property }

                if(-not $Return.count -gt 0) { Write-host &quot;ERROR: NO PROPERTY [$Property] FOUND BASED ON QUERY: [$AnchorProperty] = [$AnchorValue]&quot;; [int]$Return = 2 }
            }
        Catch { Write-Warning &quot;ERROR RETRIEVING VALUE OR PROPERTY [$Property] BASED ON QUERY: [$AnchorProperty] = [$AnchorValue] FROM [$XMLFile]: $_&quot;; [int]$Return = $_ }
        return $Return
    }

I couldn’t think of an elegant way to retrieve the values I wanted without specifying some qualifiers.  For instance, if I wanted the price of all Eva Corets books

  • The AnchorPrperty would be: author
  • The AnchorValue would be: Corets, Eva
  • The Property would be price

This way I’m certain to get the results I’m looking for.

Is there a better way?  Maybe.  This is what I came up with that met my need.

Usage:

# These two work
Get-XMLValue -xmlFile &quot;C:\Users\Julius\Downloads\books.xml&quot; -value bk108
Get-XMLValue -xmlFile &quot;C:\Users\Julius\Downloads\books.xml&quot; -value bk107

# This deoesn't
Get-XMLValue -xmlFile &quot;C:\Users\Julius\Downloads\books.xml&quot; -value bk100

It may not be the most elegant solution but I’m hoping it’ll at least point you in the right direction should the need arise to get data from XML files.

 

Good Providence to you!

Practical Use: Find & Replace in a Text File

A number of applications rely on simple text-based configuration files versus some proprietary format.  This makes editing files – ones that can’t simply be replaced via GPO/GPP or Login Script – really easy to update.

Back in my VBScript days I would likely

  1. Ingest the file via ReadAll()
  2. Check if it contains the value via InStr
  3. Replace(old_value,new_value)
  4. Write out the file with the updated content

I figured Get-Content was going to behave similarly but I discovered each line is its own separate object which meant iterating through the array for the content.  No big deal but something new and good to know.

The method for locating your text is important depending on what you’re searching for.

Locating X File

If it’s simple text, you can get away with either operator: -match or -like.

  • Match will simply return true or false and is geared towards regular expression based searches.
  • Like will return the actual objects that match.

So if you just want to know whether or not the file contains X, you could go either way.

[string]$File = 'C:\windows\Temp\ASPNETSetup_00000.log'
$OriginalContent = Get-Content -Path $File
[string]$Find = 'Vista'

# Search via Match
($OriginalContent | % { $_ -match $Find }) -contains $true

# Search via Like (similar concept)
($OriginalContent | % { $_ -like &quot;*$Find*&quot; }) - contains $true

But if you want to locate X and do something with it, -like is your friend.

#Like
[string]$File = 'C:\windows\Temp\ASPNETSetup_00000.log'
$OriginalContent = Get-Content -Path $File
[string]$Find = 'Vista'
$Results = $OriginalContent | % { $_ -like &quot;*$Find*&quot; }

 

I don’t want to get too deep into this, because it’s well documented elsewhere, but I just want to mention that if you’re searching for something that contains special characters some additional care is necessary.

If you’re using -like, you should be fine:

# Like
[string]$File = &quot;C:\WINDOWS\temp\ASPNETSetup_00000.log&quot;
$OrigContent = Get-Content -Path $File
[string]$Find = '\/\/I/\/D0WZ'

# Return true/false
($OrigContent | % { $_ -like $Find }) -contains $true

# Get the lines that match
$Results = $OriginalContent | % { $_ -like &quot;*$Find*&quot; }

 

But if you’re using -match, you’ll need to either escape those characters manually:

# Match
[string]$File = &quot;C:\WINDOWS\temp\ASPNETSetup_00000.log&quot;
$OriginalContent = Get-Content -Path $File
[string]$Find = '\\\/\\\/I\/\\\/D0WZ'
($OriginalContent | % { $_ -match $Find }) -contains $true

Or rely on the Escape() method of the Regex class:

# Match
[string]$File = &quot;C:\WINDOWS\temp\ASPNETSetup_00000.log&quot;
$OriginalContent = Get-Content -Path $File
[string]$Find = '\/\/I/\/D0WZ'

($OriginalContent | % { $_ -match [regex]::Escape($Find) }) -contains $true

Replacing the Content

Now that you’ve confirmed file X contains Y, its time to replace it.  Since humans are prone to making mistakes, I always like to have a way of backing out of programmatic changes like, so the steps below include a backup process.

# Replace&amp;nbsp;X with Y and store it in a new variable
$NewContent = $OriginalContent | % { $_ -replace $Find,$Replace }

# Create a new file that will ultimately replace the existing file.
#     If you want a UTF-8 file with BOM use this
#$NewContent | Out-File -FilePath &quot;$File.NEW&quot; -Encoding utf8 -Force

#     If you just want a UTF-8 without BOM, this does the trick.
$NewContent | Out-File -FilePath &quot;$File.NEW&quot; -Encoding default -Force

# Backup the existing file
Copy-Item -Path $File -Destination &quot;$File.ORIG.$(Get-date -Format 'yyyymmdd_hhmmss')&quot; -Force

# Move the new file that we staged to overwrite the orignal
Move-Item -Path &quot;$File.NEW&quot; -Destination $File -Force

To the experts, this is really simple and basic stuff.  To those less seasoned, this is practical. 🙂

Good Providence!

Determine Current OU of Machine

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

Please note that this is NOT the ideal way to handle any operations that require credentials.  Keeping credentials in a script is bad practice as anyone snooping around could happen upon them and create some problems.  Instead, you should rely on webservices to do this and Maik Koster has put together an excellent little care package to help you get started.

Get-CurrentOU Prerequisites

My script has a few prerequisites:

  • The name of the computer you’re working with
  • The current AD site
  • A [local] Domain Controller to connect to

This script does not rely on the ActiveDirectory module as I needed this to execute in environments where it wouldn’t be present.
I personally try to keep everything self contained where it makes sense to do so.  It’s one of my [many] quirks.

The Computer Name

Just feed it as a parameter or let it default to the current machine name.

Finding the Current AD Site

Better see this post for that.

Finding a Local Domain Controller

Better see this post for that.

Get-CurrentOU

Function Get-CurrentOU
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$ADComputerName = $env:COMPUTERNAME,

                [Parameter(Mandatory=$false)]
                    [string]$Site = $Global:Site,

                [Parameter(Mandatory=$false)]
                    [string]$SiteDC = $Global:SiteDC
            )

        Try
            {
                $Domain = ([ADSI]"LDAP://RootDSE").rootDomainNamingContext
                $_computerType = 'CN=Computer,CN=Schema,CN=Configuration,' + $Domain
                $path = 'LDAP://' + $SiteDC + "/" + $Domain
                $12 = 'YwBvAG4AdABvAHMAbwAuAGMAbwBtAFwAcwBlAHIAdgBpAGMAZQBfAGEAYwBjAG8AdQBuAHQAXwBqAG8AaQBuAF8AZABvAG0AYQBpAG4A'
                $3 = 'bQB5ACAAdQBiAGUAcgAgAHMAZQBrAHIAZQB0ACAAUAA0ADUANQB3ADAAcgBkACAAZgBvAHIAIABhAG4AIAAzADEAMwAzADcAIABoAGEAeAAwAHIAIQAhACEAMQAhAA=='
                $DirectoryEntry = New-Object System.DirectoryServices.DirectoryEntry($path,[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($12)),[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($3)))
                $DirectorySearcher = New-Object System.DirectoryServices.DirectorySearcher($DirectoryEntry)
                $DirectorySearcher.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"
                $SearchResults = $DirectorySearcher.FindAll()

                if($SearchResults.count -gt 0) { return (New-Object System.DirectoryServices.DirectoryEntry($SearchResults[0].Path,[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($12)),[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($3)))).Path.Substring(8+$siteDC.Length+3+$ADComputerName.length+1) }
                Else { Write-Host "ERROR: Computer object not found in AD: [$ADComputerName]"; return $false }
            }
        Catch { return $_ }
    }

 

This has worked well for me, but you’re welcome to use a different filter instead, such as

$DirectorySearcher.Filter = "(Name=$ADComputerName)"

 

As I mentioned above, you should really explore webservices instead of hardcoding passwords in scripts but this will work in a pinch until you can get that setup.

 

Good Providence!

Finding a Local Domain Controller

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

Since I started down the System Engineer / Desktop Engineer / Application Packager path a handful of years ago, I’ve seen some interesting behaviors.  For example, when joining a domain, a machine might reach out to a domain controller overseas versus one at the local site or even in the same continent.  And because things were working, those types of anomalies were never really looked into.

This only ever really came up when there were OU structure changes.  For instance, when going from XP to Windows 7, or after completing an AD remediation project.  As machines were being moved around, we noticed the Domain Controller used varied and the time it took to replicate those changes varied from:

  • instantaneously – suggesting the change was made on a local DC OR a DC with change notification enabled
  • upwards of an hour – suggesting a remote DC (and usually verified by logs or checking manually) or on a DC where change notification was not enabled.

So I decided to write a small function for my scripts that would ensure they were using a local Domain Controller every time.

There are a handful of ways to get a domain controller

  • LOGONSERVER
  • dsquery
  • Get-ADDomainController
  • Other third-party utilities like adfind, admod

But either they were not always reliable (as was the case with LOGONSERVER which would frequently point to a remote DC) or they required supporting files (as is the case with dsquery and Get-ADDomainController).  Nothing wrong with creating packages, adding these utilities to %ScriptRoot% or even calling files on a network share.
I simply preferred something self-contained.

Find Domain Controllers via NSLOOKUP

I started exploring by using nslookup to locate for service record (SRV) resource records for domain controllers and I landed on this:

nslookup -type=srv _kerberos._tcp.dc._msdcs.$env:USERDNSDOMAIN

Problem was it returned every domain controller in the organization.  Fortunately  you can specify an AD site name to narrow it down to that specific AD Site:

nslookup -type=srv _kerberos._tcp.$ADSiteName._sites.$env:USERDNSDOMAIN

Bingo!  This gave me exactly what I was hoping to find and the DC’s it returns are in a different order every time which helps to round-robin things a bit.

From there it was all downhill:

  1. Put AD Sites in the script
  2. Figure out which Site the machine is in by it’s gateway IP
  3. Use nslookup to query for SRV records in that site
  4. Pull the data accordingly.

Yes I know – hard coding AD Sites and gateway IP’s is probably frowned upon because

What if it changes?

I don’t know about your organisation but in my [limited] experience the rationale was that AD Sites and gateway IP’s typically don’t change often enough to warrant that level of concern, so it didn’t deter me from using this method.  But I do acknowledge that it is something to remember especially during expansion.

Also, I already had all of our gateway IP’s accessible in a bootstrap.ini due to our existing MDT infrastructure making this approach much simpler workwise.

Get-SiteDomainController

And here’s where I ended up.  The most useful part – to me anyway – is that it works in Windows (real Windows) at any stage of the imaging process and doesn’t require anything extra to work.  The only gotcha is that it does not work in WinPE because nslookup isn’t built-in and I don’t know why after all these years it still isn’t built-in.

Function Get-SiteDomainController
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$Site,

                [Parameter(Mandatory=$true)]
                    [string]$Domain
            )
        Try { return (@(nslookup -type=srv _kerberos._tcp.$Site._sites.$Domain | where {$_ -like "*hostname*"}))[0].SubString(20) }
        Catch { return $_ }
    }

Hindsight

I’ve been using the above method for determining the local DC for years and its been super reliable for me.  It wasn’t until recently that I learned that the query I was using would not always return a domain controller since the query simply locates servers that are running the Kerberos KDC service for that domain.  Whoops.

Instead, I should have used this query which will always only return domain controllers:

nslookup -type=srv _kerberos._tcp.$ADSiteName._sites.dc._msdcs.$env:USERDNSDOMAIN

 

Honorable Mention: NLTEST

I would have been able to accomplish the same task with nltest via

nltest /dclist:$env:USERDNSDOMAIN

And then continuing with the gateway IP to AD Site translation to pull out just the entries I need.

nltest /dclist:$env:USERDNSDOMAIN | find /i ": $ADSiteName"

One interesting thing to note about nltest is that it seems to always return the servers in the same order.  This could be good for consistency’s sake, but it could also be a single point of failure if that first server has a problem.

The nslookup query on the other hand returns results in a different order each time it’s executed.  Again, this could also be bad, making it difficult to spot an issue with a Domain Controller, but it could be good in that it doesn’t halt whatever process is using the output of that function.

¯\_(ツ)_/¯

Anyway, since the nslookup script has been in production for some time now and works just fine, I’m not going to change it.  However, any future scripts I create that need similar functionality will likely use the updated nslookup query method.

 

Good Providence!

Determine AD Site

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

For a variety of reasons, I needed a reliable way to determine which AD Site a particular machine was in.  Over the years I’ve seen some really odd behaviors that – technically – shouldn’t happen.  But as with many organizations (one hopes anyway…) things don’t always go as planned so you need that backup plan.

Get-Site Prerequisites

My script has a few prerequisites:

I personally try to keep everything self contained where it makes sense to do so.  It’s one of my [many(?)] quirks.

Finding the Current Default Gateway IP

Better see this post for that.

Get-Site Explained

In our environment, the machine names contain the office location which makes it easy to figure out where the machine is physically.  However, not every machine is named ‘correctly’ (read: follows this naming convention) as they were either renamed by their owners or are running an image prior to this new naming convention.  This is why I’m leveraging a hash table array of IP’s as a backup.

It seemed tedious at first, but since I already had a list of IP’s in the bootstrap.ini so it made this a little easier.  Also, Site IP’s don’t change often which means this should be a reliable method for years to come with only the occasional update.

Function Get-Site
    {
        Param([Parameter(Mandatory=$true)][string]$DefaultGatewayIP)

        Switch($env:COMPUTERNAME.Substring(0,2))
            {
                { 'HQ', 'TX', 'FL' -contains $_ } { $Site = $_ }
                Default
                    {
                        $htOfficeGateways = @{
                            "Headquarters"  = "192.168.0.1","192.168.10.1";
                            "Office1"       = "192.168.1.1","192.168.11.1";
                            "Office2"       = "192.168.2.1","192.168.12.1";
                        }

                        $DefaultGatewayIP = '192.168.0.1'
                        #$DefaultGatewayIP = '192.168.1.1'
                        #$DefaultGatewayIP = '192.168.2.1'

                        Foreach($Office in ($htOfficeGateways.GetEnumerator() | Where-Object { $_.Value -eq $DefaultGatewayIP } ))
                            {
                                Switch($($Office.Name))
                                    {
                                        "Headquarters" { $Site = 'HQ' }
                                        "Office1"      { $Site = 'TX' }
                                        "Office2"      { $Site = 'FL' }
                                    }
                            }
                    }
            }
        return $Site
    }

 

I didn’t add a check prior to returning $Site to confirm it was actually set but that can be handled outside of the function as well.  Otherwise you could do $Site = 'DDOJSIOC' at the top and then check for that later since that should never be a valid site … unless you’re Billy Rosewood.