Automating Chkdsk Scans Using Powershell With Email Alerts for Rmm

Table of Contents

What this does

Automatically schedules windows filesystem checks if the drive is detected to have corruption. If corruption occurs more than once per week, an email is sent out to your desired address. SFC & DISM scans are scheduled for 10PM. CHKDSKs on the corrupt drive are scheduled for 12AM

This can be run standalone, although ideally you set it up as an automatic response in your RMM software. There are failsafes in place to prevent the script from running multiple times due to alert spam.

Important: A python EXE file is used to send the emails out. The source code to script is on my github LINK. The build in this project is compiled from that.

How to implement with RMM

  • Edit the script and add your SMTP credentials on the last line.
  • Add this script as a component in your RMM
  • Update your RMM and have the script be a response to the following event errors
Get-EventLog -LogName System -EntryType Error -Source Ntfs -Message "A corruption was discovered in the file system structure on volume*" -ErrorAction 'SilentlyContinue'
Get-EventLog -LogName System -EntryType Error -Source Disk -Message "The device, *has a bad block*" -ErrorAction 'SilentlyContinue'
Get-EventLog -LogName System -EntryType Error -Source Disk -Message "The driver detected a controller error on*" -ErrorAction 'SilentlyContinue'

Running standalone

You can schedule this to run automatically on your PC through the task scheduler. My suggestion is have it run daily at some arbitrary hour.

Source Code

My Github: https://github.com/peterunix/PS-ScheduleCHKDSKandScans

<#
.SYNOPSIS
Scans the event log for drive errors. Schedules SFC & DISM Scan to run
at 10PM and CHKDSKs at 12AM.

.DESCRIPTION
Event logs are created with timestamps of when the script ran
successfully and scheduled a CHKDSK.  Additionally, the file
C:\Windows\LastChkdsk.txt is created with the same event log
data. This file's timestamp is checked to make sure the chkdsk doesn't
run more than once a week. If the last CHKDSK was within the week, an
email will be sent out VIA SMTP to your address. In theory, a drive
with frequently corrupt blocks is failing hence the email alert.

The way bad drives are identified is through searching the event log
and then locating the drive using the event logs message data. Hard
coded is the pattern matching and proper commands to find the drive
associated with a bad block error.

If you wish to extend this script to include more event log
detections, the _LocateEvents and _Schedulechkdsk functions are what
you're interested in

.NOTES
Best practice is to configure this as an automatic response to your
device monitoring alerts in RMM. There's are failsafe methods to
ensure that this script doesn't run multiple times for the same alert.

.LINK
Github: https://github.com/peterunix/PS-ScheduleCHKDSKandScans
#>

function _CreateEventLogEntry{
<#
Create a custom event log. Used later on when reporting the time and
reason a CHKDSK scan was done
#>
    param (
	[Parameter(Mandatory=$true)][string]$Message,
	[Parameter(Mandatory=$true)][string]$LogName,
	[Parameter(Mandatory=$true)][string]$LogType,
	[Parameter(Mandatory=$true)][string]$LogSource
    )
    # Define the log name and source
    # Check if the source exists; if not, create it
    if (-not [System.Diagnostics.EventLog]::SourceExists($LogSource)){
	New-EventLog -LogName $LogName -Source $LogSource
    }
    # Define the event message and event ID
    $eventID = 1000
    # Convert the string LogType to EventLogEntryType
    $entryType = [System.Diagnostics.EventLogEntryType]::Information
    switch ($LogType) {
        "Error" { $entryType = [System.Diagnostics.EventLogEntryType]::Error }
        "Warning" { $entryType = [System.Diagnostics.EventLogEntryType]::Warning }
        "Information" { $entryType = [System.Diagnostics.EventLogEntryType]::Information }
        "SuccessAudit" { $entryType = [System.Diagnostics.EventLogEntryType]::SuccessAudit }
        "FailureAudit" { $entryType = [System.Diagnostics.EventLogEntryType]::FailureAudit }
        default { throw "Invalid LogType: $LogType" }
    }
    # Write the informational event to the log
    Write-EventLog -LogName $LogName -Source $LogSource -EventId $eventID -EntryType $entryType -Message $message
}

# Lists the hard disk name (\\device\harddisvolume4) and the drive letter its mounted to (C:)
function _GetHarddiskVolumes{
<#
Returns a list of hard disk names (\\device\harddiskvolume4) and the
drive letter its mounted to. It's used to find the drive letter by
searching the hard disk name given in the event log
#>
    # Build System Assembly in order to call Kernel32:QueryDosDevice.
    $DynAssembly = New-Object System.Reflection.AssemblyName('SysUtils')
    $AssemblyBuilder = [AppDomain]::CurrentDomain.DefineDynamicAssembly($DynAssembly, [Reflection.Emit.AssemblyBuilderAccess]::Run)
    $ModuleBuilder = $AssemblyBuilder.DefineDynamicModule('SysUtils', $False)
    # Define [Kernel32]::QueryDosDevice method
    $TypeBuilder = $ModuleBuilder.DefineType('Kernel32', 'Public, Class')
    $PInvokeMethod = $TypeBuilder.DefinePInvokeMethod('QueryDosDevice', 'kernel32.dll', ([Reflection.MethodAttributes]::Public -bor [Reflection.MethodAttributes]::Static), [Reflection.CallingConventions]::Standard, [UInt32], [Type[]]@([String], [Text.StringBuilder], [UInt32]), [Runtime.InteropServices.CallingConvention]::Winapi, [Runtime.InteropServices.CharSet]::Auto)
    $DllImportConstructor = [Runtime.InteropServices.DllImportAttribute].GetConstructor(@([String]))
    $SetLastError = [Runtime.InteropServices.DllImportAttribute].GetField('SetLastError')
    $SetLastErrorCustomAttribute = New-Object Reflection.Emit.CustomAttributeBuilder($DllImportConstructor, @('kernel32.dll'), [Reflection.FieldInfo[]]@($SetLastError), @($true))
    $PInvokeMethod.SetCustomAttribute($SetLastErrorCustomAttribute)
    $Kernel32 = $TypeBuilder.CreateType()
    $Max = 65536
    $StringBuilder = New-Object System.Text.StringBuilder($Max)
    Get-WmiObject Win32_Volume | ? { $_.DriveLetter } | % {
	$ReturnLength = $Kernel32::QueryDosDevice($_.DriveLetter, $StringBuilder, $Max)
	if ($ReturnLength){
	    $DriveMapping = @{
		DriveLetter = $_.DriveLetter
		DevicePath = $StringBuilder.ToString()
	    }
	    New-Object PSObject -Property $DriveMapping
	}
    }
}

function _LocateEvents{
<#
Finds event related to disk errors. Returns a string with the type of
disk error it is. This is used in the CHKDSK function and different
code is ran to find the drive based off the event log.

It only returns the most recent event. If there are two different
drives alerting at the same time, only one of the drives will be
scanned.
#>
    if ($EVENT = Get-EventLog -LogName System -EntryType Error -Source Ntfs -Message "A corruption was discovered in the file system structure on volume*" -ErrorAction 'SilentlyContinue' | Select -First 1){
	if ($EVENT.TimeGenerated -gt (Get-Date).AddDays(-1)){
	    $EVENTTYPE = "NTFS"
	    return @($EVENT, $EVENTTYPE)
	}}

    if ($EVENT = Get-EventLog -LogName System -EntryType Error -Source Disk -Message "The device, *has a bad block*" -ErrorAction 'SilentlyContinue' | Select -First 1){
	if ($EVENT.TimeGenerated -gt (Get-Date).AddDays(-1)){
	    $EVENTTYPE = "Bad Block"
	    return @($EVENT, $EVENTTYPE)
	}
    }

    if ($EVENT = Get-EventLog -LogName System -EntryType Error -Source Disk -Message "The driver detected a controller error on*" -ErrorAction 'SilentlyContinue' | Select -First 1){
	if ($EVENT.TimeGenerated -gt (Get-Date).AddDays(-1)){
	    $EVENTTYPE = "Driver Detection"
	    return @($EVENT, $EVENTTYPE)
	}
    }

    if ($EVENT -eq $null){
	"No disk error events found. Exiting gracefully"
	Exit 1
    }
}


function _ScheduleDismSFC{
<#
Schedules an SFC and DISM to run at 10PM.
#>
    $taskName = "Schedule SFC & DISM at 10PM"
    $taskExists = Get-ScheduledTask | Where-Object {$_.TaskName -match $taskname }

    if ($taskExists){
	"The task for $taskName already exists"
    } else{
	$action = New-ScheduledTaskAction -Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -command "sfc /scannow ; dism /online /cleanup-image /restorehealth"'
	$trigger = New-ScheduledTaskTrigger -Once -At 10pm
	$task = Register-ScheduledTask -TaskName $taskName -Trigger $trigger -Action $action -User System
	$task | Set-ScheduledTask
    }
}

function _ScheduleCHKDSK{
<#
Schedules an SFC scan and then uses the returned values of the
_LocateEvents function to find the drive that needs to be repaired.

Different code is required to find the drive from the event, hence the
switch statement.
#>
    # Depending on the type of error, different code will run to find the drive
    $EVENT,$EVENTTYPE = _LocateEvents
    if ($EVENT -eq $null){
	"Didn't locate an event for a drive error thats occurred within the last 24 hours. Exiting gracefully"
	"No changes were made."
	Exit 1
    }

    _ScheduleDismSFC
    $taskName = "Schedule CHKDSK at 12AM"


    switch($EVENTTYPE){
	"NTFS"{
	    # Check if CHKDSK task already exists
	    $taskExists = Get-ScheduledTask | Where-Object {$_.TaskName -match $taskName }
	    # Create task if it doesn't exist
	    if ($taskExists -eq $null){
		# Find the volume letter from the event
		$eventString=($EVENT).message | Select-String -Pattern "[A-Z]:" -AllMatches
		$driveLetter=($eventString.Matches | Select -First 1).value
		if ($driveLetter -match "C:"){
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter & shutdown /r /t 0 /f'"
		} else{
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter'"
		}
		# Schedule the task
		$trigger = New-ScheduledTaskTrigger -Once -At 12AM
		$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable:$true
		$task = Register-ScheduledTask -TaskName $taskName -Trigger $trigger -Action $action -User System -Settings $settings
		$task | Set-ScheduledTask
		# Recreating the logfile show a scan was done
		Write-Host "Corrupt Volume Chkdsk Scheduled: $driveLetter $(Get-Date)"
		Add-Content "Corrupt Volume Chkdsk Scheduled: $driveLetter $(Get-Date)" -Path $TXTLOG | Out-Null
		_CreateEventLogEntry -LogName "Application" -LogSource "ChkdskScript" -Message "Corrupt Volume Chkdsk Scheduled: $driveLetter $(Get-Date)" -LogType "Information"
	    } else{"CHKDSK Scheduled task already exists. Exiting" ; Exit 1}
	}
	"Bad Block"{
	    # Check if CHKDSK task already exists
	    $taskExists = Get-ScheduledTask | Where-Object {$_.TaskName -match $taskName }
	    # Create task if it doesn't exist
	    if ($taskExists -eq $null){
		$eventString = $EVENT.Message | Select-String -Pattern "\\device\\Harddisk[1-100]\\..." -AllMatches
		$driveName = ($eventString.Matches | Select -First 1).value
		$driveNumber = $driveName.Substring($driveName.length-1)
		$driveLetter = (_GetHarddiskVolumes | Where {$_.DevicePath -like "*$driveNumber"}).DriveLetter
		# Reboot if the OS drive is scanned. Otherwise don't.
		if ($driveLetter -match "C:"){
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter & shutdown /r /t 0 /f'"
		} else{
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter'"
		}
		$trigger = New-ScheduledTaskTrigger -Once -At 11pm
		$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable:$true
		$task = Register-ScheduledTask -TaskName $taskName -Trigger $trigger -Action $action -User System -Settings $settings
		$task | Set-ScheduledTask
		# Recreating the logfile show a scan was done
		Write-Host "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)"
		Add-Content "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)" -Path $TXTLOG | Out-Null
		_CreateEventLogEntry -LogName "Application" -LogSource "ChkdskScript" -Message "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)" -LogType "Information"
	    } else{"CHKDSK Scheduled task already exists. Exiting" ; Exit 1}
	}
	"Driver Detection"{
	    # Check if CHKDSK task already exists
	    $taskExists = Get-ScheduledTask | Where-Object {$_.TaskName -match $taskName }
	    # Create task if it doesn't exist
	    if ($taskExists -eq $null){
		$eventString = $EVENT.Message | Select-String -Pattern "\\device\\Harddisk[1-100]\\..." -AllMatches
		$driveName = ($eventString.Matches | Select -First 1).value
		$driveNumber = $driveName.Substring($driveName.length-1)
		$driveLetter = (Get-CimInstance -ClassName Win32_DiskDrive |
		  Where-Object {$_.DeviceID -like "*$driveNumber"} |
		  Get-CimAssociatedInstance -Association Win32_DiskDriveToDiskPartition |
		  Get-CimAssociatedInstance -Association Win32_LogicalDiskToPartition |
		  Select-Object DeviceID).DeviceID
		if ($driveLetter -match "C:"){
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter & shutdown /r /t 0 /f'"
		} else{
		    $action = New-ScheduledTaskAction -Execute "C:\Windows\System32\cmd.exe /c 'echo y | chkdsk.exe /x /f $driveLetter'"
		}
		$trigger = New-ScheduledTaskTrigger -Once -At 11pm
		$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable:$true
		$task = Register-ScheduledTask -TaskName $taskName -Trigger $trigger -Action $action -User System -Settings $settings
		$task | Set-ScheduledTask
		# Recreating the logfile show a scan was done
		Write-Host "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)"
		Add-Content "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)" -Path $TXTLOG | Out-Null
		_CreateEventLogEntry -LogName "Application" -LogSource "ChkdskScript" -Message "Corrupt Disk Chkdsk Scheduled: $driveName $driveNumber $driveLetter $(Get-Date)" -LogType "Information"
	    } else{"CHKDSK Scheduled task already exists. Exiting" ; Exit 1}
	}
    }
}

function _Main{
    param (
	[Parameter(Mandatory=$true)][string]$Email,
	[Parameter(Mandatory=$true)][string]$Password,
	[Parameter(Mandatory=$true)][string]$SMTPServer,
	[Parameter(Mandatory=$true)][string]$SMTPPort,
	[Parameter(Mandatory=$true)][string]$Recipient
    )
    # Scheduling the scans to run only if the last CHKDSK scan was done more than a week ago
    $TXTLOG = "C:\Windows\LastChkdsk.txt"
    if (Test-Path $TXTLOG){
	$TXTLOGATTRIBUTES = Get-Item $TXTLOG -ErrorAction SilentlyContinue
	# If the log file is younger than 7 days...
	if ($TXTLOGATTRIBUTES.LastWriteTime -gt (Get-Date).AddDays(-7)){
	    _CreateEventLogEntry -LogName "Application" -LogSource "ChkdskScript" -Message "Corrupt Volume Chkdsk Scheduled: $driveLetter $(Get-Date)" -LogType "Information"
	    "Last scan was run on: " + $TXTLOGATTRIBUTES.LastWriteTime
	    "No action was taken since it was last done less than 7 days ago"
	    "This incident will be recorded since bad blocks are no bueno"

	    # Sending the report email
	    & .\sendmail.exe -I smtp.gmail.com -i $SMTPPort -u $Email -p $Password -r $Recipient -s "Datto Possible Disk Failure" -m `
	      "
		The CHKDSK Monitor already repaired this drive.
		An alert popped up again, which may indicate drive failure.
		Check this computer out!

		Site Name: $env:CS_PROFILE_NAME
		Site UID: $env:CS_PROFILE_UID
		Device Name: $env:COMPUTERNAME
		Device Description: $env:CS_PROFILE_DESC
		Domain: $env:CS_DOMAIN
		"
	} else{
	    # Run the CHKDSK if $TXTLOG is older than 7 days
	    _ScheduleCHKDSK
	}
    } else {
	# Run the CHKDSK if $TXTLOG doesn't exist
	"Could not find $TXTLOG. Running the script for the first time"
	_ScheduleCHKDSK
    }
}



_Main -Email EMAIL@EMAIL.COM -Password PASSWORDHERE -SMTPServer smtp.gmail.com -SMTPPort 587 -Recipient "[email protected]"