Iron Scripter 2019 Prelude #4 Solution

Last week the Chairman offered up another prelude challenge to prepare you for the Iron Scripter Battle at the next month’s PowerShell + DevOps Summit. As promised, the Chairman has graciously provided a sample solution. For this challenge there were many possible routes you could have chosen so it is impossible to provide a comprehensive solution. Instead, this solution is for those of you who weren’t even sure where to begin. For others, the solution might offer some conceptual tips.

Create the Virtual Machine

The challenge didn’t really care what technology you use to spin up your machine. The sample solution actually goes a bit outside the box to use a Docker container image built on the microsoft/windowsservercore image. The Docker environment also has a separate network for the container.

docker network create --driver nat --internal --subnet 172.16.100.0/24 challenge

The container will also include a volume.

docker volume create winsrv

With these items in place, creating the container is relatively straightforward. The command is saving the command’s output because it will be the container ID which will be needed later.

$id = docker container create --name cow1 --volume winsrv:c:\storage  --interactive --dns 1.1.1.1 --network challenge --memory (2gb) --ip 172.16.100.10 --hostname COW1 microsoft/windowsservercore

Finally, start the container.

docker start cow1 | Out-Null

Configure the Virtual Machine

To configure the server, you could have used Desired State Configuration or related tool. Or use an imperative script like the one in ConfigureServer.ps1

#requires -version 5.1
#requires -runasAdministrator

#ConfigureServer.ps1

#this script assumes it will be executed ON the server.
[cmdletbinding(SupportsShouldProcess)]
Param(
    [Parameter(Mandatory, HelpMessage = "Specify a hashtable with log names as keys and maximum size for values.")]
    [hashtable]$LogSettings,
    [ValidateSet("AllSigned", "RemoteSigned", "Restricted", "Bypass")]
    [String]$ExecutionPolicy = "Remotesigned",
    [string[]]$AddFeatures,
    [string[]]$RemoveFeatures,
    [Parameter(Mandatory)]
    [PSCredential]$NewAdmin
)

# System event log size set to 2GB
$LogSettings.GetEnumerator() | ForEach-Object { Limit-Eventlog  -LogName $_.key -MaximumSize $_.value }

# Create a folder called C:\Data with sub-folders of abbreviated month names
If (-Not (Test-Path -path C:\Data)) {
    New-Item -ItemType Directory -Path C:\Data | Out-Null
}
#get month abbreviations
#This property might include a blank entry
$months = (Get-Culture).DateTimeFormat.AbbreviatedMonthNames | Where-Object {$_}
foreach ($month in $months) {
    $monthPath = Join-Path -Path C:\Data -ChildPath $month
    if (-Not (Test-Path -path $monthPath)) {
        New-Item -ItemType Directory -Path $monthPath | Out-Null
    }
}

#create a hashtable that shows the current state of features
Get-WindowsFeature | ForEach-Object -Begin {
    $f = @{}
} -process {
    $f.Add($_.name, $_.installed)
}
# Install the following Windows Features
# Windows Server Backup
# Telnet Client
# FTP Server
# Enhanced Storage
if ($AddFeatures) {
    foreach ($item in $AddFeatures) {
        if (-Not ($f[$item])) {
            Add-WindowsFeature -Name $item -IncludeAllSubFeature
        }
    }
}
# Make sure the following Windows Features are NOT installed
# SNMP
# PowerShell v2
if ($RemoveFeatures) {
    foreach ($item in $RemoveFeatures) {
        if ($f[$item]) {
            Uninstall-WindowsFeature -Name $item -IncludeManagementTools
        }
    }
}
# Create a local administrator account for RoyGBiv with a decent password.
New-Localuser -Name $NewAdmin.username -Password $NewAdmin.Password -Description "local admin" |
    Add-LocalGroupMember -Group Administrators

# Add 172.16.100.* to TrustedHosts
Set-Item -Path WSMan:\localhost\Client\TrustedHosts -Value '172.16.100.*' -Force -Concatenate

# Install the PSScriptTools module from the PowerShell Gallery
$module = 'PSScriptTools'
if (-not (Get-Module -Name $module -ListAvailable)) {
    Try {
        Install-PackageProvider -name nuget -Force -minimumversion '2.8.5.201' -erroraction stop | Out-Null
        Install-Module $module -Force -ErrorAction stop
    } 
    Catch {
        Write-Warning "Failed to update modules. $($_.exception.message)"
    }
}

# Set the PowerShell Execution policy to RemoteSigned
Set-ExecutionPolicy -ExecutionPolicy $ExecutionPolicy -Force

#restart the computer here or manually outside of the script
# Write-Host "Restarting the computer..." -foreground Yellow
# Restart-Computer -Force

The script is designed with some flexibility in mind. The assumption with this solution is that it will run remotely ON the server either through PowerShell remoting or PowerShell Direct. The configuration script will need some parameter values.

$log = @{System=2GB}
$Policy = "Remotesigned"
$add = @("windows-server-backup","telnet-client","web-ftp-server","enhancedstorage")
$remove = @("powershell-v2","snmp-service")

The Password Challenge

The challenge also required creating a new local user account.  Whenever working with credentials you want to avoid plain text passwords wherever possible. However, the other part of the challenge was to make this a hands-free process. One way to securely store a password is with a protected CMS message. This requires the use of a document encryption certificate. This makes it easy to store the password in an encrypted file.

Protect-CmsMessage -To "[email protected]" -Content "[email protected]" -Outfile .\ironpassword.txt

This is done ahead of time. The automated solution needs to decrypt the password and convert it to a secure string which can be used create a PSCredential object.

$plain = Unprotect-CmsMessage -To "[email protected]" -Path .\ironpassword.txt
$secure = ConvertTo-SecureString -String $plain -AsPlainText -Force
$local = New-Object PSCredential roygbiv,$secure

All of the settings are added to an array.

$config = @($log,$policy,$add,$remove,$local)

You’ll see why in a moment.

Running the Configuration

Even though the solution is using a container,  you can still use PowerShell remoting. Assuming the container was created in the first place and $ID has a value, create a PSSession to the container.

$ps = New-PSSession -containerid $ID -runasadministrator

From here you can use PowerShell remoting commands as you normally would such as using Invoke-Command to run the configuration script in the container.

Invoke-Command -filepath .\ConfigureServer.ps1 -argumentlist $Config -session $ps

When using a technique like this, script parameters are all positional. And because some of the parameter values are themselves arrays, you can avoid problems by creating explicit arrays. This way the value of $Add, which is an array of feature names, will be passed as one value to the script’s AddFeatures parameter.

You can decide how you want to restart the server. In this solution, which is really a proof of concept, during testing the EnhancedStorage feature didn’t persist when restarting the container. Even though it was clearly installed. This is most likely due to the nature of the container that doesn’t really affect the challenge. The sample solution doesn’t restart the container.

Validation

The last step would be some sort of validation. How do you know that everything is configured as expected? That’s where a Pester test can come in handy. The scripted solution takes the liberty of installing the latest version of Pester.

invoke-command {install-module pester -force -skip} -session $ps

Of course, you need a Pester test.

#validate.tests.ps1

Describe ServerValidation {

It "Should have a system event log size of 2GB" {
    (Get-Eventlog -list).where({$_.log -eq 'system'}).maximumkilobytes | Should be 2097152
}

It "Should have a script execution policy of RemoteSigned" {
    Get-ExecutionPolicy | Should Be "RemoteSigned"
}

It "Should have a local user account of RoyGBiv" {
    (Get-Ciminstance -ClassName win32_useraccount -filter "name = 'roygbiv'").domain | Should Be (hostname)
}

It "Should have a folder called C:\Data with monthly-named subfolders" {
    Test-Path C:\Data
   (Get-Culture).DateTimeFormat.AbbreviatedMonthNames | Where-Object {$_} |
   ForEach-Object { Test-Path (Join-Path C:\Data $_)}
}

It "Has a value of 172.16.100.* in Trusted Hosts" {
    (get-item WSMan:\localhost\Client\TrustedHosts).value -match "172\.16\.100\.\*"
}

It "Has the module PSScriptTools installed" {
    (Get-Module PSScriptTools -listAvailable).Name | Should be "PSScriptTools"
}

$add = @("windows-server-backup","telnet-client","web-ftp-server","enhancedstorage")
foreach ($item in $add) {
    It "Should have the feature $item installed" {
        (Get-WindowsFeature $item).Installed | Should Be $True
    }
}

$remove = @("powershell-v2","snmp-service")
foreach ($item in $remove) {
    It "Should NOT have the feature $item installed" {
        (Get-WindowsFeature $item).Installed | Should Be $False
    }
}

}

Because the tests really need to run on the server, the test is copied.

copy .\validate.tests.ps1 -destination c:\ -tosession $ps

Finally it can be executed.

$t = Invoke-Command {Invoke-pester c:\validate.tests.ps1 -passthru} -session $ps

There are several ways you could consume the test output. The sample solution simply checks for any failures.

if ($t.failedcount -ne 0) {
        Write-Warning "Validation failed"
    }
    else {
        Write-Host "Validation successful! You can stop the container and remove it." -foreground green
    }

Clean Up

The challenge wasn’t specific, but you should also have included some code to spin down and remove the test machine. In the Docker situation this is as easy as this:

docker stop cow1
docker rm cow1

How did you do? Are you ready for another challenge? Keep an eye on the site.