11/23/20

How to set up group policy scripts programmatically

Introduction

If you are reading this article, you may already know that Windows does not provide a convenient API for configuring Group Policy scripts (startup / shutdown / logon / logoff). It is pretty easy to configure Group Policy with the GUI , but sometimes this process requires automation. There is not a single complete guide to solve this problem on the Internet, so we had to collect information bit by bit.

In this article we'll cover:

  • Manual configuration of Group Policy script
  • Algorithm for Programmatic Configuration of Group Policy Scripts
  • Powershell Code example

Manual configuring

Here is a simple task: we need to write event names for each of the four events (startup/shutdown/logon/logoff) to the C:\Events.log file using Powershell. How to do it manually?

1. Open gpedit

2. Find two sections in the user interface: Computer Configuration and User Configuration. Open Windows Settings for each of the sections and find Startup / Shutdown and Logon / Logoff - those are scripts for each section respectively.

3. Open the Script startup configuration. Find a table with two columns:

Script - a path to the program
Parameter - arguments with which the program will be called

Now we need to call Powershell and pass the command to be executed. There is an example of configuration for a startup script below.

Script %windir%\System32\WindowsPowerShell\v1.0\powershell.exe
Parameter -Command "'Startup' | Out-File C:\Events.log -Append"

Before saving the script, it is highly recommended to open cmd, then manually execute the command “<script> <parameter>” and ensure that everything works as expected.

Let's set up scripts for the other three events in the same way. Restart your PC, then look at the contents of the C:\Events.log file. Everything works perfectly!

Logoff
Shutdown
Startup
Logon


Programmatic configuring

Since Windows does not provide an API for editing Group Policy scripts, automating the process of its configuring is quite difficult.

There is a ready-made example of programmatic configuration using Powershell at the end of the article, but it may happen that you need to implement the same logic in another programming language.

Brief algorithm of actions

  1. Add a Script/Parameter value pair to the corresponding scripts .ini of the hidden GroupPolicy folder.
  2. Add the necessary extensions IDs to gpt.ini and increase the version of group policies.
  3. Run gpupdate.

Detailed algorithm

Adding scripts to scripts.ini

Follow the path C:\Windows\System32\GroupPolicy. This folder is hidden, so we enable the display of hidden items in the explorer.

Startup/Shutdown scripts are stored in .\GroupPolicy\Machine\Scripts\scripts.ini

Logon/Logoff scripts are located in .\GroupPolicy\User\Scripts\scripts.ini

Now scripts.ini for machine scripts looks like this:

[Startup]
0CmdLine=%windir%\System32\WindowsPowerShell\v1.0\powershell.exe
0Parameters=-Command "'Startup' | Out-File C:\Events.log -Append"
[Shutdown]
0CmdLine=%windir%\System32\WindowsPowerShell\v1.0\powershell.exe
0Parameters=-Command "'Shutdown' | Out-File C:\Events.log -Append"

Next, we need to add a new pair of CmdLine/Parameters with an index more by one than the previous script’s in the category. After adding a new Startup script we get a file with the following content:

[Startup]
0CmdLine=%windir%\System32\WindowsPowerShell\v1.0\powershell.exe
0Parameters=-Command "'Startup' | Out-File C:\Events.log -Append"
1CmdLine=path_to_another_script.exe
1Parameters=another_script_parameters
[Shutdown]
0CmdLine=%windir%\System32\WindowsPowerShell\v1.0\powershell.exe
0Parameters=-Command "'Shutdown' | Out-File C:\Events.log -Append"

In the GUI we see the new settings, but when the PC restarts, the scripts will not be called, since the Windows registry does not yet know about them.

Editing gpt.ini

Let's go back to the GroupPolicy folder and create a gpt.ini file. It's fine if it is not there - it will not appear until the first run of gpedit.

Required minimum of gpt.ini content:

[General]
gPCMachineExtensionNames=[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B6664F-4972-11D1-A7CA-0000F87571E3}]
Version=65537
gPCUserExtensionNames=[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}]

Don't be intimidated by long lines with extensions. In this case, these are just constants that correspond to the guids of the extensions needed to serve our script. So if you just want to customize the scripts, then feel free to copy these values.

The gPCMachineExtensionNames parameter is not needed if you are not configuring Startup/Shutdown scripts. The same is for gPCUserExtensionNames in case you don't need Logon/Logoff scripts.

How do I get the Version parameter?

Converting the Version number into hexadecimal notation, we get the number 0x00010001. The first unit is the custom scripts version, the last is the machine scripts version. If we change both machine and user scripts - we just have to add the value 0x00010001 to the current version - we get 0x00020002. Converting it back to decimal we get the number 131074, which is our Version number. But there is also an easier way - you can simply add 65537 to the previous version.

Next, we call the gpupdate command, which will fill in the necessary keys in the registry in accordance with our settings. Done!


Powershell Code

The listing below performs all of the above steps. It is worth highlighting the idempotency of logic as the main feature of the script: it can be called several times without creating duplicates of the same scripts, which was important in our project. Freely edit it to suit your needs.

# config
$powershell = "%windir%\System32\WindowsPowerShell\v1.0\powershell.exe"
$scriptsMap = @{
    Startup = @{
        CmdLine = $powershell
        Parameters = "-Command ""'Startup' | Out-File C:\Events.log -Append"""
    }
    Shutdown = @{
        CmdLine = $powershell
        Parameters = "-Command ""'Shutdown' | Out-File C:\Events.log -Append"""
    }
    Logon = @{
        CmdLine = $powershell
        Parameters = "-Command ""'Logon' | Out-File C:\Events.log -Append"""
    }
    Logoff = @{
        CmdLine = $powershell
        Parameters = "-Command ""'Logoff' | Out-File C:\Events.log -Append"""
    }
}
 
class Script {
    [string]$CmdLine
    [string]$Parameters
}
 
function Merge-Scripts {
    param(
        [string[]]$Contents,
        [ValidateSet('Logon', 'Logoff', 'Startup', 'Shutdown')]
        [string]$Type,
        [string]$CmdLine,
        [string]$Parameters
    )
    
    if (!$contents) {
        $contents = @();
    }
 
    $scripts = [Script[]]@()
    $collect = $false
 
    for ($i = 0; $i -lt $contents.Length; $i++) {
        if ($contents[$i] -eq "[$Type]") {
            $collect = $true
            continue
        }
        if ($collect -eq $true) {
            if ($contents[$i] -match "\[\w+\]") {
                break
            }
            if ($contents[$i].Length -gt 0) {
                $scripts += [Script]@{
                    CmdLine = ($contents[$i] -split "CmdLine=")[1]
                    Parameters = ($contents[$i + 1] -split "Parameters=")[1]
                }
                $i++
            }
        }
    }
 
    $cmdLine = $scriptsMap[$Type]["CmdLine"]
    $parameters = $scriptsMap[$Type]["Parameters"]
    
    $scripts = [Script[]]($scripts | Where-Object {$_.CmdLine -ne $cmdLine -or $_.Parameters -ne $parameters})
 
    $scripts += [Script]@{
        CmdLine = $cmdLine
        Parameters = $parameters
    }
 
    $contents = @("[$Type]")
 
    for ($i = 0; $i -lt $scripts.Count; $i++) {
        $contents += "$($i)CmdLine=$($scripts[$i].CmdLine)"
        $contents += "$($i)Parameters=$($scripts[$i].Parameters)"
    }
 
    return $contents
}
 
function Read-ScriptsIni() {
    param(
        [string]$ScriptsPath
    )
 
    $parent = Split-Path -Path $scriptsPath
    if (!(Test-Path $parent)) {
        New-Item $parent -ItemType Directory | Out-Null
    }
    if (!(Test-Path $scriptsPath)) {
        New-Item $scriptsPath -ItemType File | Out-Null
    }
 
    return Get-Content $ScriptsPath -ErrorAction SilentlyContinue
}
 
# paths
$GpRoot = "${env:SystemRoot}\System32\GroupPolicy"
 
# startup/shutdown scripts
$machineScriptsPath = Join-Path $GpRoot "Machine\Scripts\scripts.ini"
$contents = Read-ScriptsIni -ScriptsPath $machineScriptsPath
$startupScripts = Merge-Scripts -Contents $contents -Type Startup
$shutdownScripts = Merge-Scripts -Contents $contents -Type Shutdown
Set-Content $machineScriptsPath -Value ($startupScripts + $shutdownScripts) -Encoding Unicode -Force
 
# logon/logoff scripts
$userScriptsPath = Join-Path $GpRoot "User\Scripts\scripts.ini"
$contents = Read-ScriptsIni -ScriptsPath $userScriptsPath
$logonScripts = Merge-Scripts -Contents $contents -Type Logon
$logoffScripts = Merge-Scripts -Contents $contents -Type Logoff
Set-Content $userScriptsPath -Value ($logonScripts + $logoffScripts) -Encoding Unicode -Force
 
# bumping machine/user script versions in gpt.ini
$GpIni = Join-Path $GpRoot "gpt.ini"
$MachineGpExtensions = '{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B6664F-4972-11D1-A7CA-0000F87571E3}'
$UserGpExtensions = '{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}'
 
$contents = Get-Content $GpIni -ErrorAction SilentlyContinue
$newVersion = 65537 # 0x00010001
 
$versionMatchInfo = $contents | Select-String -Pattern 'Version=(.+)'
if ($versionMatchInfo.Matches.Groups -and $versionMatchInfo.Matches.Groups[1].Success) {
    $newVersion += [int]::Parse($versionMatchInfo.Matches.Groups[1].Value)
}
 
(
    "[General]",
    "gPCMachineExtensionNames=[$MachineGpExtensions]",
    "Version=$newVersion",
    "gPCUserExtensionNames=[$UserGpExtensions]"
) | Out-File -FilePath $GpIni -Encoding ascii
 
# generating registry keys
gpupdate

Note that:

  • Startup/Shutdown scripts are called on behalf of the local system account;
  • Logon/Logoff scripts are called on behalf of the user, so make sure that the user has the necessary rights;
  • When passing powershell.exe the -File parameter>, you will probably need to pass -ExecutionPolicy> if it is not configured globally;
  • Some sources suggest editing scripts directly in the registry. We don't recommend doing this, since these settings are not displayed in the interface and will be overwritten whenever group policies are changed using it.