Public/Register-ADDCDiagHealthMonitor.ps1

function Register-ADDCDiagHealthMonitor {
<#
.SYNOPSIS
    Registers a Windows Scheduled Task that runs Test-ADDCDiagHealth on a
    short repeating interval, turning it into a near-real-time domain
    controller monitoring agent.
 
.DESCRIPTION
    Creates a task under \ADOpsKit\ in Task Scheduler with a repeating
    trigger (default every 5 minutes, indefinitely) that calls
    Test-ADDCDiagHealth with the parameters supplied here.
 
    This function MAKES CHANGES: it writes a wrapper script to
    -ScriptsFolder, registers/replaces the scheduled task, and restricts
    the scripts folder ACL. Supports -WhatIf / -Confirm. WinRM is not
    required.
 
    Run-as account:
        - Defaults to the built-in SYSTEM account (no password to store,
          no rotation to manage) - sufficient for the default dcdiag test
          set against DCs in the same domain as this computer.
        - Pass -RunAsCredential for a delegated account instead, e.g. for
          cross-domain/cross-forest monitoring where SYSTEM's computer
          account does not have visibility.
 
    Security notes:
        - If -SmtpCredential is supplied, its password is stored in the
          generated wrapper script as a machine-scoped DPAPI-encrypted
          blob (same pattern used by Register-ADOpsKitScheduledTasks).
          The plaintext is never written to disk, and the blob only
          decrypts on this computer - if the script is copied elsewhere,
          re-run this function there. The Scripts folder ACL is
          additionally restricted to SYSTEM, Administrators, and the
          run-as account.
        - Most internal SMTP relays for alerting do not require
          authentication (IP allow-listed); prefer that where possible.
 
    DO NOT REGISTER THIS ON A DOMAIN CONTROLLER. dcdiag itself is cheap
    per run, but a recurring task means that computer carries the
    polling workload (process spawns, event-log reads, RPC/LDAP calls to
    every other DC) continuously, on top of its normal DC duties. Run it
    from a non-DC host instead - a member server, admin jump box, or a
    monitoring VM - which only ever queries the DCs remotely. This
    function checks the local computer's domain role at run time and
    writes a warning (but does not block) if it detects it is running on
    a DC.
 
.PARAMETER TaskName
    Name of the scheduled task. Default: ADDCDiagHealthMonitor
 
.PARAMETER IntervalMinutes
    How often to run the check, in minutes. Default: 5
 
.PARAMETER ScriptsFolder
    Folder the wrapper task script is written to. Default:
    C:\ADOpsKit\Scripts
 
.PARAMETER RunAsCredential
    Optional credential to run the task as instead of SYSTEM.
 
.PARAMETER DomainController
    Passed through to Test-ADDCDiagHealth. Defaults to every DC in the
    current domain.
 
.PARAMETER Tests
    Passed through to Test-ADDCDiagHealth. Optional lighter test list
    (e.g. Connectivity,Advertising,NetLogons,Replications,KccEvent,Services)
    instead of dcdiag's full default set, to reduce per-run load. Leave
    unset to run the full default set.
 
.PARAMETER PerDCTimeoutSeconds
    Passed through to Test-ADDCDiagHealth. Default 60.
 
.PARAMETER RepeatAlertAfterHours
    Passed through to Test-ADDCDiagHealth. Default 4.
 
.PARAMETER StateFilePath
    Passed through to Test-ADDCDiagHealth. Default:
    C:\ADOpsKit\State\Test-ADDCDiagHealth.state.json
 
.PARAMETER AlertLogPath
    Passed through to Test-ADDCDiagHealth. Default:
    C:\ADOpsKit\Reports\Test-ADDCDiagHealth\AlertLog.csv
 
.PARAMETER SmtpServer
    SMTP relay for alert email. Required.
 
.PARAMETER SmtpPort
    SMTP port. Default 25.
 
.PARAMETER UseSsl
    Use SSL/TLS to the SMTP relay.
 
.PARAMETER SmtpCredential
    Optional SMTP authentication credential. See Security notes above.
 
.PARAMETER From
    Alert email From address. Required.
 
.PARAMETER To
    Alert email recipient address(es). Required.
 
.EXAMPLE
    Register-ADDCDiagHealthMonitor -SmtpServer smtp.contoso.com -From adalerts@contoso.com -To 'you@contoso.com'
    Registers the monitor to run every 5 minutes as SYSTEM.
 
.EXAMPLE
    Register-ADDCDiagHealthMonitor -IntervalMinutes 2 -SmtpServer smtp.contoso.com -From adalerts@contoso.com -To 'you@contoso.com','oncall@contoso.com' -WhatIf
    Shows what would be registered for a 2-minute polling interval
    without making any changes.
 
.EXAMPLE
    Register-ADDCDiagHealthMonitor -Tests Connectivity,Advertising,NetLogons,Replications,KccEvent,Services -SmtpServer smtp.contoso.com -From adalerts@contoso.com -To 'you@contoso.com'
    Registers the monitor with a lighter test list to further reduce
    per-run load on the DCs being checked.
 
.NOTES
    Author: K Shankar R Karanth
    Website: https://karanth.ovh
    Version: 1.0
    Requires: Run as Administrator, ADOpsKit module installed.
    Makes changes to the local Task Scheduler and filesystem. Does not
    touch Active Directory.
#>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidateNotNullOrEmpty()]
        [string]$TaskName = 'ADDCDiagHealthMonitor',

        [ValidateRange(1, 60)]
        [int]$IntervalMinutes = 5,

        [ValidateNotNullOrEmpty()]
        [string]$ScriptsFolder = 'C:\ADOpsKit\Scripts',

        [pscredential]$RunAsCredential,

        [string[]]$DomainController,

        [string[]]$Tests,

        [ValidateRange(5, 600)]
        [int]$PerDCTimeoutSeconds = 60,

        [ValidateRange(1, 24)]
        [int]$RepeatAlertAfterHours = 4,

        [string]$StateFilePath = 'C:\ADOpsKit\State\Test-ADDCDiagHealth.state.json',

        [string]$AlertLogPath = 'C:\ADOpsKit\Reports\Test-ADDCDiagHealth\AlertLog.csv',

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SmtpServer,

        [ValidateRange(1, 65535)]
        [int]$SmtpPort = 25,

        [switch]$UseSsl,

        [pscredential]$SmtpCredential,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$From,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$To
    )

    #Requires -RunAsAdministrator

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    function ConvertTo-ADOKPSLiteral {
        param([string]$Value)
        "'" + ($Value -replace "'", "''") + "'"
    }

    # ============ WARN IF RUNNING ON A DOMAIN CONTROLLER ============
    # DomainRole: 4 = Backup DC, 5 = Primary DC. WMI, not WinRM.

    try {
        $domainRole = (Get-WmiObject -Class Win32_ComputerSystem -ErrorAction Stop).DomainRole
        if ($domainRole -in 4, 5) {
            Write-Warning "This computer appears to be a domain controller (DomainRole=$domainRole). Registering this recurring task here means this DC will carry the polling workload continuously, on top of its DC duties. Prefer running Register-ADDCDiagHealthMonitor from a non-DC host (member server, jump box, or monitoring VM) that queries the DCs remotely instead."
        }
    }
    catch {
        Write-Verbose "Could not determine local domain role via WMI: $($_.Exception.Message)"
    }

    # ============ RESOLVE MODULE MANIFEST PATH ============
    # $PSScriptRoot here is ADOpsKit\Public (this file's own folder),
    # regardless of where the caller invoked the function from.

    $moduleRoot   = Split-Path -Path $PSScriptRoot -Parent
    $manifestPath = Join-Path $moduleRoot 'ADOpsKit.psd1'

    if (-not (Test-Path -LiteralPath $manifestPath)) {
        throw "Could not resolve ADOpsKit.psd1 next to this function (looked in '$moduleRoot'). Reinstall ADOpsKit and retry."
    }

    # ============ BUILD WRAPPER TASK SCRIPT ============

    if (-not (Test-Path -LiteralPath $ScriptsFolder)) {
        if ($PSCmdlet.ShouldProcess($ScriptsFolder, 'Create scripts folder')) {
            New-Item -ItemType Directory -Path $ScriptsFolder -Force | Out-Null
        }
    }

    $scriptPath = Join-Path $ScriptsFolder "$TaskName.ps1"

    $paramLines = [System.Collections.Generic.List[string]]::new()
    if ($DomainController) {
        $dcList = ($DomainController | ForEach-Object { ConvertTo-ADOKPSLiteral $_ }) -join ','
        $paramLines.Add(" -DomainController @($dcList)")
    }
    if ($Tests) {
        $testList = ($Tests | ForEach-Object { ConvertTo-ADOKPSLiteral $_ }) -join ','
        $paramLines.Add(" -Tests @($testList)")
    }
    $paramLines.Add(" -PerDCTimeoutSeconds $PerDCTimeoutSeconds")
    $paramLines.Add(" -RepeatAlertAfterHours $RepeatAlertAfterHours")
    $paramLines.Add(" -StateFilePath $(ConvertTo-ADOKPSLiteral $StateFilePath)")
    $paramLines.Add(" -AlertLogPath $(ConvertTo-ADOKPSLiteral $AlertLogPath)")
    $paramLines.Add(" -SmtpServer $(ConvertTo-ADOKPSLiteral $SmtpServer)")
    $paramLines.Add(" -SmtpPort $SmtpPort")
    if ($UseSsl) { $paramLines.Add(" -UseSsl") }
    $paramLines.Add(" -From $(ConvertTo-ADOKPSLiteral $From)")
    $toList = ($To | ForEach-Object { ConvertTo-ADOKPSLiteral $_ }) -join ','
    $paramLines.Add(" -To @($toList)")

    $credentialBlock = ''
    if ($SmtpCredential) {
        $smtpUserLiteral = ConvertTo-ADOKPSLiteral $SmtpCredential.UserName
        # Machine-scoped DPAPI: only an encrypted blob is written into the
        # generated script. It decrypts only on this computer, so the
        # plaintext password never reaches disk.
        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SmtpCredential.Password)
        try {
            $plainSmtpPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
        }
        finally {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
        $encSmtpPwd = Protect-ADOKMachineSecret -PlainText $plainSmtpPwd
        $credentialBlock = @"
Add-Type -AssemblyName System.Security
`$smtpPwdBytes = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('$encSmtpPwd'), `$null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
`$smtpSecurePwd = ConvertTo-SecureString ([System.Text.Encoding]::UTF8.GetString(`$smtpPwdBytes)) -AsPlainText -Force
`$smtpPwdBytes = `$null
`$smtpCred = [pscredential]::new($smtpUserLiteral, `$smtpSecurePwd)
"@

        $paramLines.Add(" -SmtpCredential `$smtpCred")
    }

    $scriptContent = @"
# Auto-generated by Register-ADDCDiagHealthMonitor on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss').
# Do not edit by hand - re-run Register-ADDCDiagHealthMonitor to change settings.
`$ErrorActionPreference = 'Stop'
Import-Module -Name $(ConvertTo-ADOKPSLiteral $manifestPath) -Force
$credentialBlock
Test-ADDCDiagHealth ``
$($paramLines -join " ``
")
"@


    if ($PSCmdlet.ShouldProcess($scriptPath, 'Write monitor task script')) {
        Set-Content -LiteralPath $scriptPath -Value $scriptContent -Encoding UTF8
    }

    # Restrict ACL: SYSTEM, Administrators, and the run-as account only -
    # defense in depth; the script contains at most a machine-scoped
    # DPAPI-encrypted SMTP password (no plaintext).
    if ($PSCmdlet.ShouldProcess($ScriptsFolder, 'Restrict folder ACL')) {
        try {
            $acl = New-Object System.Security.AccessControl.DirectorySecurity
            $acl.SetAccessRuleProtection($true, $false)
            $identities = @('NT AUTHORITY\SYSTEM', 'BUILTIN\Administrators')
            if ($RunAsCredential) { $identities += $RunAsCredential.UserName }
            foreach ($identity in $identities) {
                $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
                    $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'
                )
                $acl.AddAccessRule($rule)
            }
            Set-Acl -LiteralPath $ScriptsFolder -AclObject $acl
        }
        catch {
            if ($SmtpCredential) {
                # The NTFS ACL is the only thing standing between a local, unprivileged
                # user and the DPAPI-encrypted SMTP password (LocalMachine-scoped DPAPI
                # blobs can be decrypted by any local principal, not just admins) - so a
                # failure to lock down this folder must not be treated as a soft warning
                # when a secret was actually written into it.
                throw "Could not restrict ACL on '$ScriptsFolder': $($_.Exception.Message). " +
                      "This folder now contains a DPAPI-encrypted SMTP password, and the ACL is its only " +
                      "access control - refusing to continue. Restrict '$ScriptsFolder' manually (SYSTEM, " +
                      "Administrators, and the run-as account only) and re-run, or re-run without -SmtpCredential."
            }
            else {
                Write-Warning "Could not restrict ACL on '$ScriptsFolder': $($_.Exception.Message). Restrict it manually."
            }
        }
    }

    # ============ REGISTER SCHEDULED TASK ============

    $action  = New-ScheduledTaskAction -Execute 'powershell.exe' `
        -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$scriptPath`""

    $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
        -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) `
        -RepetitionDuration (New-TimeSpan -Days 3650)

    $settings = New-ScheduledTaskSettingsSet `
        -ExecutionTimeLimit (New-TimeSpan -Minutes ([Math]::Max(5, $IntervalMinutes * 2))) `
        -MultipleInstances IgnoreNew -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)

    if ($PSCmdlet.ShouldProcess("\ADOpsKit\$TaskName", "Register scheduled task (every $IntervalMinutes minute(s))")) {
        if ($RunAsCredential) {
            $runAsBstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($RunAsCredential.Password)
            try {
                $plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($runAsBstr)
            }
            finally {
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($runAsBstr)
            }
            try {
                Register-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -Action $action -Trigger $trigger `
                    -Settings $settings -User $RunAsCredential.UserName -Password $plainPwd -RunLevel Highest -Force | Out-Null
            }
            finally {
                $plainPwd = $null
            }
        }
        else {
            Register-ScheduledTask -TaskPath '\ADOpsKit\' -TaskName $TaskName -Action $action -Trigger $trigger `
                -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
        }
        Write-Host "[OK] Registered scheduled task \ADOpsKit\$TaskName - runs every $IntervalMinutes minute(s)." -ForegroundColor Green
    }

    [PSCustomObject]@{
        TaskName        = $TaskName
        TaskPath        = "\ADOpsKit\$TaskName"
        IntervalMinutes = $IntervalMinutes
        RunAs           = if ($RunAsCredential) { $RunAsCredential.UserName } else { 'NT AUTHORITY\SYSTEM' }
        ScriptPath      = $scriptPath
        StateFilePath   = $StateFilePath
        AlertLogPath    = $AlertLogPath
    }
}