GPO / Scheduled Task / Extension Configurator

Introduction

When a client checks for Group Policy updates, it follows a structured process to determine what needs to be applied.

Version : Before doing anything, the client compares its locally cached GPO version against the remote version, stored both as a versionNumber attribute on the GPO object and in the GPT.ini file on SYSVOL. If the remote version isn’t newer, processing stops there (unless gpupdate /force is used).

CSE GUIDs extensions : If an update is detected, the client inspects the gPCMachineExtensionNames and gPCUserExtensionNames attributes to know which Client-Side Extensions (CSEs) to load. For Immediate Scheduled Tasks specifically, two GUID blocks must be present (one referencing the Core GPO Engine and Scheduled Tasks CSEs and another pairing the Preference CSE with the Scheduled Tasks tool CSE. Without these GUIDs, the client won’t even look at scheduled task configuration.

ScheduledTasks.xml : Once the correct CSEs are identified, the client fetches ScheduledTasks.xml from a predictable SYSVOL path, parses it, and registers the tasks locally.

In short, three things must align for a GPO-based scheduled task to apply correctly: the version number must be incremented, the right CSE GUIDs must be declared on the GPO object, and the ScheduledTasks.xml file must exist in the expected SYSVOL location.

Script

# ============================================================
# GPO Scheduled Task Extension Configurator
# - Backs up GPO before any modification
# - Adds required CSE GUIDs for Immediate Scheduled Tasks
# - Restores Extensions from a previous backup
# ============================================================

Import-Module GroupPolicy
Import-Module ActiveDirectory

# ---------- CONFIGURATION ----------
$GPOName    = "Ma GPO"
$AD = "<domain>"
$DomainDN   = (Get-ADDomain -Server $AD).DistinguishedName
$DomainFQDN = (Get-ADDomain -Server $AD).DNSRoot
$BackupPath = Join-Path -Path (Get-Location) -ChildPath "GPO_Backups"

$PSDefaultParameterValues["*-AD*:Server"] = $AD
# -----------------------------------

# Required CSE GUIDs for Immediate Scheduled Tasks (Machine scope)
$RequiredExtensionString = "[{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}][{AADCED64-746C-4633-A97C-D61349046527}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]"

# GUID friendly names for display
$GUIDNames = @{
    "{00000000-0000-0000-0000-000000000000}" = "Core GPO Engine"
    "{79F92669-4224-476C-9C5C-6EFB4D87DF4A}" = "Preference Tool CSE - Local Users and Groups"
    "{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}" = "Preference Tool CSE - Scheduled Tasks"
    "{AADCED64-746C-4633-A97C-D61349046527}" = "Preference CSE - Scheduled Tasks"
}

# ============================================================
# HELPER - Pretty print extension blocks
# ============================================================
function Format-ExtensionBlocks {
    param ([string]$ExtensionString)

    if ([string]::IsNullOrWhiteSpace($ExtensionString)) {
        Write-Host "    (empty)" -ForegroundColor DarkGray
        return
    }

    $blocks = [regex]::Matches($ExtensionString, '\[\{[A-F0-9\-\}\{]+\}\]')
    $blockIndex = 1
    foreach ($block in $blocks) {
        Write-Host "    Block $blockIndex : $($block.Value)" -ForegroundColor White
        $guids = [regex]::Matches($block.Value, '\{[A-F0-9\-]+\}')
        foreach ($guid in $guids) {
            $name = $GUIDNames[$guid.Value.ToUpper()]
            if ($name) {
                Write-Host "             $($guid.Value)  ->  $name" -ForegroundColor DarkCyan
            } else {
                Write-Host "             $($guid.Value)" -ForegroundColor DarkGray
            }
        }
        $blockIndex++
    }
}

# ============================================================
# HELPER - Merge extension blocks
# ============================================================
function Merge-ExtensionBlocks {
    param (
        [string]$Current,
        [string]$Required
    )

    $CurrentBlocks  = [regex]::Matches($Current,  '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value.ToUpper() }
    $RequiredBlocks = [regex]::Matches($Required, '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value.ToUpper() }

    $MergedBlocks = [System.Collections.Generic.List[string]]($CurrentBlocks)

    foreach ($block in $RequiredBlocks) {
        if ($MergedBlocks -notcontains $block) {
            $MergedBlocks.Add($block)
        }
    }

    $Sorted = $MergedBlocks | Sort-Object
    return $Sorted -join ""
}

# ============================================================
# HELPER - Read gPCMachineExtensionNames from a backup XML
# ============================================================
function Get-ExtensionsFromBackup {
    param ([string]$BackupFolder)

    $BkupInfo = Join-Path $BackupFolder "bkupInfo.xml"
    if (-not (Test-Path $BkupInfo)) {
        Write-Host "[-] bkupInfo.xml not found in: $BackupFolder" -ForegroundColor Red
        return $null
    }

    [xml]$xml = Get-Content $BkupInfo -Encoding UTF8

    $node = $xml.SelectSingleNode("//MachineExtensionGuids")
    if ($null -eq $node) {
        Write-Host "[!] No MachineExtensionGuids node found in backup XML." -ForegroundColor Yellow
        return ""
    }

    return $node.InnerText.Trim()
}

# ============================================================
# MODE SELECTION
# ============================================================
Write-Host "`n============================================================" -ForegroundColor DarkYellow
Write-Host "       GPO Scheduled Task Extension Configurator" -ForegroundColor DarkYellow
Write-Host "============================================================" -ForegroundColor DarkYellow
Write-Host "`n  [1] Apply required CSE extensions to GPO"
Write-Host "  [2] Restore Extensions from backup`n"

$ModeInput = Read-Host "Select mode (1 or 2)"

switch ($ModeInput) {
    "1" { $Mode = "Apply"   }
    "2" { $Mode = "Restore" }
    default {
        Write-Host "`n[-] Invalid selection. Exiting." -ForegroundColor Red
        exit 1
    }
}

# ============================================================
# STEP 1 - Retrieve GPO
# ============================================================
Write-Host "`n[*] Retrieving GPO: '$GPOName'..." -ForegroundColor Cyan

try {
    $GPO = Get-GPO -Name $GPOName -ErrorAction Stop
    Write-Host "[+] GPO found: $($GPO.DisplayName) | ID: $($GPO.Id)" -ForegroundColor Green
} catch {
    Write-Host "[-] GPO '$GPOName' not found. Exiting." -ForegroundColor Red
    exit 1
}

$GPOPath = "CN={$($GPO.Id)},CN=Policies,CN=System,$DomainDN"

# ============================================================
# STEP 2 - Read current gPCMachineExtensionNames from AD
# ============================================================
Write-Host "`n[*] Reading current gPCMachineExtensionNames from AD..." -ForegroundColor Cyan

try {
    $ADObject = Get-ADObject -Identity $GPOPath -Server $AD -Properties gPCMachineExtensionNames -ErrorAction Stop
    $CurrentExtensions = $ADObject.gPCMachineExtensionNames
} catch {
    Write-Host "[-] Failed to read AD object: $_" -ForegroundColor Red
    exit 1
}

# ============================================================
# RESTORE MODE
# ============================================================
if ($Mode -eq "Restore") {

    Write-Host "`n[*] Scanning backup folder: $BackupPath" -ForegroundColor Cyan

    if (-not (Test-Path $BackupPath)) {
        Write-Host "[-] Backup folder not found: $BackupPath" -ForegroundColor Red
        exit 1
    }

    # List all backup sub-folders that belong to this GPO (matched by GUID inside bkupInfo.xml)
    $BackupFolders = Get-ChildItem -Path $BackupPath -Directory |
        Where-Object {
            $infoFile = Join-Path $_.FullName "bkupInfo.xml"
            if (Test-Path $infoFile) {
                [xml]$x = Get-Content $infoFile -Encoding UTF8
                $idNode = $x.SelectSingleNode("//GPOId")
                $idNode -and ($idNode.InnerText.Trim(" {}") -eq $GPO.Id.ToString())
            }
        } | Sort-Object LastWriteTime -Descending

    if ($BackupFolders.Count -eq 0) {
        Write-Host "[-] No backups found for GPO '$GPOName' (ID: $($GPO.Id)) in $BackupPath" -ForegroundColor Red
        exit 1
    }

    # Display available backups
    Write-Host "`n  Available backups for '$GPOName':`n" -ForegroundColor Yellow
    $i = 1
    $BackupList = @()

    foreach ($folder in $BackupFolders) {
        $ext = Get-ExtensionsFromBackup -BackupFolder $folder.FullName
        Write-Host "  [$i] $($folder.Name)" -ForegroundColor White
        Write-Host "      Date   : $($folder.LastWriteTime)" -ForegroundColor DarkGray
        Write-Host "      Path   : $($folder.FullName)" -ForegroundColor DarkGray
        if ([string]::IsNullOrWhiteSpace($ext)) {
            Write-Host "      Extensions: (none recorded)" -ForegroundColor DarkGray
        } else {
            Write-Host "      Extensions:" -ForegroundColor DarkGray
            Format-ExtensionBlocks -ExtensionString $ext
        }
        Write-Host ""
        $BackupList += [PSCustomObject]@{ Index = $i; Folder = $folder.FullName; Extensions = $ext }
        $i++
    }

    $SelInput = Read-Host "Enter the number of the backup to restore from (or 'q' to quit)"

    if ($SelInput -eq 'q') {
        Write-Host "`n[!] Restore cancelled." -ForegroundColor Yellow
        exit 0
    }

    $SelIndex = [int]$SelInput
    $Selected = $BackupList | Where-Object { $_.Index -eq $SelIndex }

    if ($null -eq $Selected) {
        Write-Host "[-] Invalid selection." -ForegroundColor Red
        exit 1
    }

    $RestoredExtensions = $Selected.Extensions

    # Show change summary
    Write-Host "`n============================================================" -ForegroundColor DarkYellow
    Write-Host "                RESTORE SUMMARY" -ForegroundColor DarkYellow
    Write-Host "============================================================" -ForegroundColor DarkYellow
    Write-Host "`n  GPO Name : $($GPO.DisplayName)"
    Write-Host "  GPO ID   : $($GPO.Id)"
    Write-Host "  Domain   : $DomainFQDN"
    Write-Host "  Backup   : $($Selected.Folder)"

    Write-Host "`n--- CURRENT state (gPCMachineExtensionNames) ---" -ForegroundColor Yellow
    Format-ExtensionBlocks -ExtensionString $CurrentExtensions

    Write-Host "`n--- WILL BE RESTORED TO ---" -ForegroundColor Green
    if ([string]::IsNullOrWhiteSpace($RestoredExtensions)) {
        Write-Host "    (empty - attribute will be cleared)" -ForegroundColor DarkGray
    } else {
        Format-ExtensionBlocks -ExtensionString $RestoredExtensions
    }

    Write-Host "`n============================================================" -ForegroundColor DarkYellow

    # Safety backup before restore
    Write-Host "`n[*] Creating a safety backup before restore..." -ForegroundColor Cyan
    try {
        $SafetyBackup = Backup-GPO -Name $GPOName -Path $BackupPath -ErrorAction Stop
        Write-Host "[+] Safety backup created. ID: $($SafetyBackup.Id)" -ForegroundColor Green
    } catch {
        Write-Host "[-] Safety backup failed: $_" -ForegroundColor Red
        $proceed = Read-Host "Continue without safety backup? (yes/no)"
        if ($proceed -notin @("yes", "y")) {
            exit 1
        }
    }

    # Confirmation
    $confirmation = Read-Host "`nApply restore to AD? (yes/no)"
    if ($confirmation -notin @("yes", "y")) {
        Write-Host "`n[!] Restore cancelled by user." -ForegroundColor Yellow
        exit 0
    }

    # Apply restored value
    Write-Host "`n[*] Writing restored extensions to AD..." -ForegroundColor Cyan
    try {
        if ([string]::IsNullOrWhiteSpace($RestoredExtensions)) {
            Set-ADObject -Identity $GPOPath -Server $AD -Clear gPCMachineExtensionNames -ErrorAction Stop
        } else {
            Set-ADObject -Identity $GPOPath -Server $AD -Replace @{ gPCMachineExtensionNames = $RestoredExtensions } -ErrorAction Stop
        }
        Write-Host "[+] gPCMachineExtensionNames restored successfully." -ForegroundColor Green
    } catch {
        Write-Host "[-] Failed to restore: $_" -ForegroundColor Red
        exit 1
    }

    # Verify
    Write-Host "`n[*] Verifying restored value..." -ForegroundColor Cyan
    $Verify = Get-ADObject -Identity $GPOPath -Server $AD -Properties gPCMachineExtensionNames
    Write-Host "`n--- APPLIED state (gPCMachineExtensionNames) ---" -ForegroundColor Green
    Format-ExtensionBlocks -ExtensionString $Verify.gPCMachineExtensionNames

    Write-Host "`n[+] Restore complete for '$GPOName'." -ForegroundColor Green
    Write-Host "[+] Safety backup available at: $BackupPath`n" -ForegroundColor Green
    exit 0
}

# ============================================================
# APPLY MODE (original logic)
# ============================================================

# STEP 3 - Backup GPO
Write-Host "`n[*] Creating backup in: $BackupPath" -ForegroundColor Cyan

if (-not (Test-Path $BackupPath)) {
    New-Item -ItemType Directory -Path $BackupPath | Out-Null
}

try {
    $Backup = Backup-GPO -Name $GPOName -Path $BackupPath -ErrorAction Stop
    Write-Host "[+] Backup successful." -ForegroundColor Green
    Write-Host "    Backup ID  : $($Backup.Id)"
    Write-Host "    Backup Path: $BackupPath"
    Write-Host "    Timestamp  : $($Backup.BackupTime)"
} catch {
    Write-Host "[-] Backup failed: $_" -ForegroundColor Red
    exit 1
}

# STEP 4 - Compute new extension value
$NewExtensions = Merge-ExtensionBlocks -Current $CurrentExtensions -Required $RequiredExtensionString

# STEP 5 - Display change summary and ask for confirmation
Write-Host "`n============================================================" -ForegroundColor DarkYellow
Write-Host "                   CHANGE SUMMARY" -ForegroundColor DarkYellow
Write-Host "============================================================" -ForegroundColor DarkYellow

Write-Host "`n  GPO Name : $($GPO.DisplayName)"
Write-Host "  GPO ID   : $($GPO.Id)"
Write-Host "  Domain   : $DomainFQDN"

Write-Host "`n--- CURRENT state (gPCMachineExtensionNames) ---" -ForegroundColor Yellow
Format-ExtensionBlocks -ExtensionString $CurrentExtensions

Write-Host "`n--- NEW state after change (gPCMachineExtensionNames) ---" -ForegroundColor Green
Format-ExtensionBlocks -ExtensionString $NewExtensions

$CurrentBlocks = [regex]::Matches($CurrentExtensions.ToUpper(), '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value }
$NewBlocks     = [regex]::Matches($NewExtensions.ToUpper(),     '\[\{[A-F0-9\-\}\{]+\}\]') | ForEach-Object { $_.Value }
$AddedBlocks   = $NewBlocks | Where-Object { $CurrentBlocks -notcontains $_ }

if ($AddedBlocks.Count -eq 0) {
    Write-Host "`n[=] No changes needed - all required extensions already present." -ForegroundColor Green
    exit 0
}

Write-Host "`n--- BLOCKS TO BE ADDED ---" -ForegroundColor Magenta
foreach ($block in $AddedBlocks) {
    Write-Host "  + $block" -ForegroundColor Magenta
}

Write-Host "`n============================================================" -ForegroundColor DarkYellow
Write-Host "[!] A backup has already been saved to: $BackupPath" -ForegroundColor Cyan
Write-Host "============================================================`n" -ForegroundColor DarkYellow

$confirmation = Read-Host "Apply these changes to AD? (yes/no)"

if ($confirmation -notin @("yes", "y")) {
    Write-Host "`n[!] Operation cancelled by user. No changes were made." -ForegroundColor Yellow
    Write-Host "[!] Backup is still available at: $BackupPath`n" -ForegroundColor Yellow
    exit 0
}

# STEP 6 - Apply changes to AD
Write-Host "`n[*] Applying changes to AD object..." -ForegroundColor Cyan

try {
    Set-ADObject -Identity $GPOPath -Server $AD -Replace @{ gPCMachineExtensionNames = $NewExtensions } -ErrorAction Stop
    Write-Host "[+] gPCMachineExtensionNames updated successfully." -ForegroundColor Green
} catch {
    Write-Host "[-] Failed to update AD object: $_" -ForegroundColor Red
    Write-Host "[!] Restore the GPO using backup ID: $($Backup.Id) in $BackupPath" -ForegroundColor Yellow
    exit 1
}

# STEP 7 - Verify
Write-Host "`n[*] Verifying applied value..." -ForegroundColor Cyan

$Verify = Get-ADObject -Identity $GPOPath -Server $AD -Properties gPCMachineExtensionNames
Write-Host "`n--- APPLIED state (gPCMachineExtensionNames) ---" -ForegroundColor Green
Format-ExtensionBlocks -ExtensionString $Verify.gPCMachineExtensionNames

Write-Host "`n[+] Done. Scheduled Task CSE extensions applied to '$GPOName'." -ForegroundColor Green
Write-Host "[+] Recovery backup available at: $BackupPath`n" -ForegroundColor Green