Terraform + Azure + WinRM
By Annie Hedgpeth · April 17, 2019
Terraform + Azure + WinRM

Walk with me for a moment if you will. Let’s say you need to spin up a Windows

2016 node in Terraform that has to join the Active Directory domain. And then you need to be able to WinRM into that node during your Terraform run, because let’s say you need to add a remote_exec provisioner that does something that you can only do as a domain account user on the domain, and it has to happen within Terraform for whatever reason. Let’s also say that your Group Policy is super strict, and there’s no changing it.

Acceptance Criteria

Be able to WinRM into a Windows Server 2016 with Terraform from a Shared Image Gallery image

Challenges

  1. The node being provisioned needs to be on the domain.
  2. There is an Active Directory Group Policy requiring that WinRM be authorized via Kerberos or NTLM
  3. Only a domain account user can make the request to the CA
  4. You have to WinRM over HTTPS as a domain account user.

TL;DR Steps

  1. Create your virtual machine
  2. Join the domain
  3. Run a custom script extension that does all the work
  4. Now you can WinRM
resource "azurerm_virtual_machine" "self" {}
resource "azurerm_virtual_machine_extension" "join-domain" {}
resource "azurerm_virtual_machine_extension" "custom-script" {}
resource "null_resource" "remote_exec" {}

The wordy instructions

So let’s talk about this…I’ll assume you’ve already created the first two steps (see TL;DR above) in Terraform. Step three is where we’ll hang out for a bit.

The way you configure WinRM to run over HTTPS is by importing a certificate and then creating a WinRM listener that is authenticated by that certificate. Assuming you’ve gotten your certificate, all you do for that is add this line to your winrm config, and you can add it simply by running this in Powershell:

# Get the thumbprint of the certificate first. You may have to add more criteria to narrow it down if there are others w/hostname in the name.
$thumbprint = (Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object {$_.Subject -match "$hostname").Thumbprint

# Create a listener that uses that thumbprint.
winrm create winrm/config/Listener?Address=IP:$ip+Transport=HTTPS "@{Hostname=`"$hostname`"; CertificateThumbprint=`"$thumbprint`"}"

Great, right? Let’s get that certificate and get moving.

Oh, wait…you can’t just use a random self-signed certificate spun up in Key Vault. No, your Group Policy mandates that the certificate be signed by the Certificate Authority (CA) and that the CA be your company, let’s call it Fireside, Inc. Okay, so you’ll need to request a certificate from Fireside, Inc. with a Powershell script like this or this. Oh, but only a domain account user can make the request to the CA (per the Group Policy). So how do I make the request to the CA as a domain user if Terraform only runs as the local user I just created? Well, this is tricky. You can run as another user, but we have to do some work to get there first given the constraints of your AD Group Policy. You will have to run in an elevated shell, which Terraform doesn’t do on its own, so let’s see how we can make this happen for you.

How to run in an elevated shell

You want to run as the local admin (non-domain account) that has permission to run as a domain user with its credentials, but in order to do that you need to be in an elevated shell. For that we go to none other than the go-to-Windows-WinRM-guru, Matt Wrock. In an azurerm_virtual_machine_extension which runs as the non-domain local admin user you’ll call Matt Wrock’s Powershell script called elevated_shell.ps1. (He created this script as part of a gem called winrm-elevated, which you can also use, but we didn’t.)

There is a parameter in that script called $script which is the script that you want run in the elevated shell. You may need to add your domain account user at this point, so in the beginning of Matt’s script go ahead and add a one-liner to add your domain user to the administrators group on the machine. Then the script creates a task to allow you to run $script as the elevated shell which allows you to run as the domain user. As long as that domain user is in the Administrator’s group on the machine you are provisioning, it should have the required access rights. Your $script parameter will be another script that you create called setupWinRm.ps1 that requests a certificate from the Certificate Authority (CA) as the domain user. Then it will configure WinRM for HTTPS on 5986 with that certificate and opened the firewall for HTTPS. That process enables WinRM for

HTTPS through Kerberos or NTLM authentication.

Your Terraform block will look something like this:

resource "azurerm_virtual_machine_extension" "custom-script" {
 # < all the arguments here >

  settings = <<SETTINGS
    {
        "commandToExecute": "powershell .\\elevated_shell.ps1 -Script (Resolve-Path .\\setupWinRm.ps1) -Username ${var.active_directory_domain}\\${var.vm_domain_user} -Password ${var.vm_domain_password}",
        "fileUris" : ["https://yourbloborwhereveryoukeepyourscripts/elevated_shell.ps1", "https://yourbloborwhereveryoukeepyourscripts/setupWinRm.ps1"]
     }
  SETTINGS

  depends_on = ["azurerm_virtual_machine_extension.join-domain"]
}

See above that your commandToExecute has setupWinRm.ps1 as the $script parameter and that you’re grabbing two files from blob or wherever to put onto the node, your altered elevated_shell.ps1 and your setupWinRm.ps1.

Your setupWinRm.ps1 will look different depending upon your needs, but first you’ll request the cert like this, this or this.

$hostname = "$ComputerName.$domain"
$fileBaseName = $hostname -replace "\.", "_"
$fileBaseName = $fileBaseName -replace "\*", ""

$infFile = $workdir + "\" + $fileBaseName + ".inf"
$requestFile = $workdir + "\" + $fileBaseName + ".req"
$CertFileOut = $workdir + "\" + $fileBaseName + ".cer"
$subject = "CN=$hostname"

Try {
    Write-Verbose "Creating the certificate request information file ..."
    $inf = @"
[Version]
Signature="`$Windows NT`$"

[NewRequest]
Subject = "$subject"
KeySpec = 1
KeyLength = $Keylength
Exportable = TRUE
FriendlyName = "$hostname"
MachineKeySet = TRUE
SMIME = False
PrivateKeyArchive = FALSE
UserProtected = FALSE
UseExistingKeySet = FALSE
ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
ProviderType = 12
RequestType = PKCS10
KeyUsage = 0xa0
"@

    $inf | Set-Content -Path $infFile

    Write-Verbose "Creating the certificate request ..."
    & certreq.exe -new "$infFile" "$requestFile"

    Write-Verbose "Submitting the certificate request to the certificate authority ..."
    & certreq.exe -submit -config "$CertificateAuthority" -attrib "CertificateTemplate:WebServer" "$requestFile" "$CertFileOut"

    if (Test-Path "$CertFileOut") {
        Write-Verbose "Installing the generated certificate ..."
        & certreq.exe -accept "$CertFileOut"
    }
}
Finally {
    Get-ChildItem "$workdir\$fileBaseName.*" | remove-item
}

And then to configure WinRM, you’ll grab your certificate’s thumbprint and go from there. It might look like this.

Write-Host "Obtaining the Thumbprint of the CA Certificate"
$thumbprint = (Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object {$_.Subject -match "$hostname" -and $_.EnhancedKeyUsageList[0].FriendlyName -eq "Server Authentication"} ).Thumbprint | Select -first 1

Write-Host "Enable HTTPS in WinRM.."
$ipAddress = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object {$_.Ipaddress.length -gt 1}
$ip = $ipAddress.ipaddress[0]
winrm create winrm/config/Listener?Address=IP:$ip+Transport=HTTPS "@{Hostname=`"$hostname`"; CertificateThumbprint=`"$thumbprint`"}"
winrm set winrm/config '@{MaxTimeoutms="1800000"}'

Write-Host "Re-starting the WinRM Service"
net stop winrm
net start winrm

Write-Host "Open Firewall Ports"
netsh advfirewall firewall add rule name="Windows Remote Management (HTTPS-In)" dir=in action=allow protocol=TCP localport=5986

After that custom script extension passes, then you can remote into that machine via HTTPS with a remote_exec provisioner or whatever you need.

You Configured WinRM Cookie

Great, so problem solved, right?

Almost. Your DNS entry may not become available on the DNS servers for a while, making authentication with your DNS name not possible until the entry is set. It’s possible that replication from the DNS server to others takes about 15 minutes and from the office to Azure is another 15 minutes. You could try resolving the DNS name of the new VM by running a Powershell command to do a force lookup of the DNS by using your internal DNS servers directly. Those servers should basically give you a result immediately. If that doesn’t work, as a last resort, you can simply add some functionality to our remote_execscript that adds the

DNS entry to the provisioner’s hosts file (and clean it up afterward).

Why shouldn’t I just use Terraform’s suggested method for enabling WinRm over HTTPS? Tombuildsstuff created an excellent example which creates a new certificate in Key Vault, installs it on the node being provisioned, and configures WinRm during

VMprovisioning using that certificate to create the HTTPS WinRM listener during VM provisioning. However, again, check your Group Policy to see if it allows WinRm on a certificate that’s not issued by your domain. If you can’t request a certificate unless you’re on the domain, then you have a little chicken and egg problem.

Why wouldn’t I just use the stock gallery image that has WinRM configured already? You can’t configure WinRM over HTTPS this way, so it’s less secure. It is an option, just not very attractive. It also doesn’t follow most people’s standards of using images, like the Shared Image Gallery in Azure with Packer-built images.

Concluding Thoughts

Terraform doesn’t want to replace a pipeline tool (Jenkins) or a configuration management tool (Chef), and we shouldn’t try to make it. When we try to make tools do things they weren’t made to do, we get frustrated pretty quickly. That said, use with caution and use your best judgment.