Complex Azure Odyssey Part Four: WAP Server
Part One of this series covered the project itself and the overall template structure. Part Two went through how I deploy the Domain Controller in depth. Part Three talks about deploying my ADFS server and in this final part I will show you how to configure the WAP server that faces the outside world.
The Template
The WAP server is the only one in my environment that faces the internet. Because of this the deployment is more complex. I’ve also added further complexity because I want to be able to have more than one WAP server in future, so there’s a load balancer deployed too. You can see the resource outline in the screenshot below:
The internet-facing stuff means we need more things in our template. First up is our PublicIPAddress:{ "name": "\[variables('vmWAPpublicipName')\]", "type": "Microsoft.Network/publicIPAddresses", "location": "\[parameters('resourceLocation')\]", "apiVersion": "2015-05-01-preview", "dependsOn": \[ \], "tags": { "displayName": "vmWAPpublicip" }, "properties": { "publicIPAllocationMethod": "Dynamic", "dnsSettings": { "domainNameLabel": "\[variables('vmWAPpublicipDnsName')\]" } } },
This is pretty straightforward stuff. The nature of my environment means that I am perfectly happy with a dynamic IP that changes if I stop and then start the environment. Access will be via the hostname assigned to that IP and I use that hostname in my ADFS service configuration and certificates. Azure builds the hostname based on a pattern and I can use that pattern in my templates, which is how I’ve created the certs when I deploy the DC and configure the ADFS service all before I’ve deployed the WAP server. That public IP address is then bound to our load balancer which provides the internet-endpoint for our services:{ "apiVersion": "2015-05-01-preview", "name": "\[variables('vmWAPlbName')\]", "type": "Microsoft.Network/loadBalancers", "location": "\[parameters('resourceLocation')\]", "dependsOn": \[ "\[resourceId('Microsoft.Network/publicIPAddresses',variables('vmWAPpublicipName'))\]" \], "properties": { "frontendIPConfigurations": \[ { "name": "\[variables('LBFE')\]", "properties": { "publicIPAddress": { "id": "\[resourceId('Microsoft.Network/publicIPAddresses',variables('vmWAPpublicipName'))\]" } } } \], "backendAddressPools": \[ { "name": "\[variables('LBBE')\]" } \], "inboundNatRules": \[ { "name": "\[variables('RDPNAT')\]", "properties": { "frontendIPConfiguration": { "id": "\[variables('vmWAPLbfeConfigID')\]" }, "protocol": "tcp", "frontendPort": "\[variables('rdpPort')\]", "backendPort": 3389, "enableFloatingIP": false } }, { "name": "\[variables('httpsNAT')\]", "properties": { "frontendIPConfiguration": { "id": "\[variables('vmWAPLbfeConfigID')\]" }, "protocol": "tcp", "frontendPort": "\[variables('httpsPort')\]", "backendPort": 443, "enableFloatingIP": false } } \] } }
There’s a lot going on in here so let’s work through it. First of all we connect our public IP address to the load balancer. We then create a back end configuration which we will later connect our VM to. Finally we create a set of NAT rules. I need to be able to RDP into the WAP server, which is the first block. The variables define the names of my resources. You can see that I specify the ports – external through a variable that I can change, and internal directlym because I need that to be the same each time because that’s what my VMs listen on. You can see that each NAT rule is associated with the frontendIPConfiguration – opening the port to the outside world. The next step is to create a NIC that will hook our VM up to the existing virtual network and the load balancer:{ "name": "\[variables('vmWAPNicName')\]", "type": "Microsoft.Network/networkInterfaces", "location": "\[parameters('resourceLocation')\]", "apiVersion": "2015-05-01-preview", "dependsOn": \[ "\[concat('Microsoft.Network/publicIPAddresses/', variables('vmWAPpublicipName'))\]", "\[concat('Microsoft.Network/loadBalancers/',variables('vmWAPlbName'))\]" \], "tags": { "displayName": "vmWAPNic" }, "properties": { "ipConfigurations": \[ { "name": "ipconfig1", "properties": { "privateIPAllocationMethod": "Static", "privateIPAddress": "\[variables('vmWAPIPAddress')\]", "subnet": { "id": "\[variables('vmWAPSubnetRef')\]" }, "loadBalancerBackendAddressPools": \[ { "id": "\[variables('vmWAPBEAddressPoolID')\]" } \], "loadBalancerInboundNatRules": \[ { "id": "\[variables('vmWAPRDPNATRuleID')\]" }, { "id": "\[variables('vmWAPhttpsNATRuleID')\]" } \] } } \] } }
Here you can see that the NIC is connected to a subnet on our virtual network with a static IP that I specify in a variable. It is then added to the load balancer back end address pool and finally I need to specify which of the NAT rules I created in the load balancer are hooked up to my VM. If I don’t include the binding here, traffic won’t be passed to my VM (as I discovered when developing this lot – I forgot to wire up https and as a result couldn’t access the website published by WAP!). The VM itself is basically the same as my ADFS server. I use the same Windows Sever 2012 R2 image, have a single disk and I’ve nested the extensions within the VM because that seems to work better than not doing:```
{ "name": "[variables('vmWAPName')]", "type": "Microsoft.Compute/virtualMachines", "location": "[parameters('resourceLocation')]", "apiVersion": "2015-05-01-preview", "dependsOn": [ "[concat('Microsoft.Network/networkInterfaces/', variables('vmWAPNicName'))]", ], "tags": { "displayName": "vmWAP" }, "properties": { "hardwareProfile": { "vmSize": "[variables('vmWAPVmSize')]" }, "osProfile": { "computername": "[variables('vmWAPName')]", "adminUsername": "[parameters('adminUsername')]", "adminPassword": "[parameters('adminPassword')]" }, "storageProfile": { "imageReference": { "publisher": "[variables('windowsImagePublisher')]", "offer": "[variables('windowsImageOffer')]", "sku": "[variables('windowsImageSKU')]", "version": "latest" }, "osDisk": { "name": "[concat(variables('vmWAPName'), '-os-disk')]", "vhd": { "uri": "[concat('http://', variables('storageAccountName'), '.blob.core.windows.net/', variables('vmStorageAccountContainerName'), '/', variables('vmWAPName'), 'os.vhd')]" }, "caching": "ReadWrite", "createOption": "FromImage" } }, "networkProfile": { "networkInterfaces": [ { "id": "[resourceId('Microsoft.Network/networkInterfaces', variables('vmWAPNicName'))]" } ] } }, "resources": [ { "type": "extensions", "name": "IaaSDiagnostics", "apiVersion": "2015-06-15", "location": "[parameters('resourceLocation')]", "dependsOn": [ "[concat('Microsoft.Compute/virtualMachines/', variables('vmWAPName'))]" ], "tags": { "displayName": "[concat(variables('vmWAPName'),'/vmDiagnostics')]" }, "properties": { "publisher": "Microsoft.Azure.Diagnostics", "type": "IaaSDiagnostics", "typeHandlerVersion": "1.4", "autoUpgradeMinorVersion": "true", "settings": { "xmlCfg": "[base64(variables('wadcfgx'))]", "StorageAccount": "[variables('storageAccountName')]" }, "protectedSettings": { "storageAccountName": "[variables('storageAccountName')]", "storageAccountKey": "[listKeys(variables('storageAccountid'),'2015-05-01-preview').key1]", "storageAccountEndPoint": "https://core.windows.net/" } } }, { "type": "Microsoft.Compute/virtualMachines/extensions", "name": "[concat(variables('vmWAPName'),'/WAPserver')]", "apiVersion": "2015-05-01-preview", "location": "[parameters('resourceLocation')]", "dependsOn": [ "[resourceId('Microsoft.Compute/virtualMachines', variables('vmWAPName'))]", "[concat('Microsoft.Compute/virtualMachines/', variables('vmWAPName'),'/extensions/IaaSDiagnostics')]" ], "properties": { "publisher": "Microsoft.Powershell", "type": "DSC", "typeHandlerVersion": "1.7", "settings": { "modulesURL": "[concat(variables('vmDSCmoduleUrl'), parameters('_artifactsLocationSasToken'))]", "configurationFunction": "[variables('vmWAPConfigurationFunction')]", "properties": { "domainName": "[variables('domainName')]", "adminCreds": { "userName": "[parameters('adminUsername')]", "password": "PrivateSettingsRef:adminPassword" } } }, "protectedSettings": { "items": { "adminPassword": "[parameters('adminPassword')]" } } } }, { "type": "Microsoft.Compute/virtualMachines/extensions", "name": "[concat(variables('vmWAPName'),'/wapScript')]", "apiVersion": "2015-05-01-preview", "location": "[parameters('resourceLocation')]", "dependsOn": [ "[concat('Microsoft.Compute/virtualMachines/', variables('vmWAPName'))]", "[concat('Microsoft.Compute/virtualMachines/', variables('vmWAPName'),'/extensions/WAPserver')]" ], "properties": { "publisher": "Microsoft.Compute", "type": "CustomScriptExtension", "typeHandlerVersion": "1.4", "settings": { "fileUris": [ "[concat(parameters('_artifactsLocation'),'/WapServer.ps1', parameters('_artifactsLocationSasToken'))]", "[concat(parameters('_artifactsLocation'),'/PSPKI.zip', parameters('_artifactsLocationSasToken'))]", "[concat(parameters('_artifactsLocation'),'/tuServDeployFunctions.ps1', parameters('_artifactsLocationSasToken'))]" ], "commandToExecute": "[concat('powershell.exe -file WAPServer.ps1',' -vmAdminUsername ',parameters('adminUsername'),' -vmAdminPassword ',parameters('adminPassword'),' -fsServiceName ',variables('vmWAPpublicipDnsName'),' -adfsServerName ',variables('vmADFSName'),' -vmDCname ',variables('vmDCName'), ' -resourceLocation "', parameters('resourceLocation'),'"')]" } } } ] }
1
2The DSC Modules
3---------------
4
5As with the other two servers, the files copied into the VM by the DSC extension are common. I then call the appropriate configuration for the WAP server, held within my common configuration file. The WAP server configuration is shown below:```
6configuration WAPserver { param ( \[Parameter(Mandatory)\] \[String\]$DomainName, \[Parameter(Mandatory)\] \[System.Management.Automation.PSCredential\]$Admincreds, \[Int\]$RetryCount=20, \[Int\]$RetryIntervalSec=30 ) Import-DscResource -ModuleName xComputerManagement,xActiveDirectory Node localhost { WindowsFeature WAPInstall { Ensure = "Present" Name = "Web-Application-Proxy" } WindowsFeature WAPMgmt { Ensure = "Present" Name = "RSAT-RemoteAccess" } WindowsFeature ADPS { Name = "RSAT-AD-PowerShell" Ensure = "Present" } xWaitForADDomain DscForestWait { DomainName = $DomainName DomainUserCredential= $Admincreds RetryCount = $RetryCount RetryIntervalSec = $RetryIntervalSec DependsOn = "\[WindowsFeature\]ADPS" } xComputer DomainJoin { Name = $env:COMPUTERNAME DomainName = $DomainName Credential = New-Object System.Management.Automation.PSCredential ("${DomainName}$($Admincreds.UserName)", $Admincreds.Password) DependsOn = "\[xWaitForADDomain\]DscForestWait" } LocalConfigurationManager { DebugMode = $true RebootNodeIfNeeded = $true } } }
7```As with ADFS, the configuration joins the domain and adds the required features for WAP. Note that I install the RSAT tools for Remote Access. If you don’t do this, you can’t configure WAP because the powershell modules aren’t installed!
8
9The Custom Scripts
10------------------
11
12The WAP script performs much of the same work as the ADFS script. I need to install the certificate for my service, so that’s copied onto the server by the script before it runs an invoke-command block. The main script is run as the local system account and can successfully connect to the DC as the computer account. I then run my invoke-command with domain admin credentials so I can configure WAP, and once inside the invoke-command block network access gets tricky, so I don’t do it!```
13\# # WapServer.ps1 # param ( $vmAdminUsername, $vmAdminPassword, $fsServiceName, $adfsServerName, $vmDCname, $resourceLocation ) $password = ConvertTo-SecureString $vmAdminPassword -AsPlainText -Force $credential = New-Object System.Management.Automation.PSCredential("$env:USERDOMAIN$vmAdminUsername", $password) Write-Verbose -Verbose "Entering Domain Controller Script" Write-Verbose -verbose "Script path: $PSScriptRoot" Write-Verbose -Verbose "vmAdminUsername: $vmAdminUsername" Write-Verbose -Verbose "vmAdminPassword: $vmAdminPassword" Write-Verbose -Verbose "fsServiceName: $fsServiceName" Write-Verbose -Verbose "adfsServerName: $adfsServerName" Write-Verbose -Verbose "env:UserDomain: $env:USERDOMAIN" Write-Verbose -Verbose "resourceLocation: $resourceLocation" Write-Verbose -Verbose "===================================" # Write an event to the event log to say that the script has executed. $event = New-Object System.Diagnostics.EventLog("Application") $event.Source = "tuServEnvironment" $info\_event = \[System.Diagnostics.EventLogEntryType\]::Information $event.WriteEntry("WAPserver Script Executed", $info\_event, 5001) $srcPath = "\\"+ $vmDCname + "src" $fsCertificateSubject = $fsServiceName + "."+($resourceLocation.Replace(" ","")).ToLower()+".cloudapp.azure.com" $fsCertFileName = $fsCertificateSubject+".pfx" $certPath = $srcPath + "" + $fsCertFileName #Copy cert from DC write-verbose -Verbose "Copying $certpath to $PSScriptRoot" # $powershellCommand = "& {copy-item '" + $certPath + "' '" + $workingDir + "'}" # Write-Verbose -Verbose $powershellCommand # $bytes = \[System.Text.Encoding\]::Unicode.GetBytes($powershellCommand) # $encodedCommand = \[Convert\]::ToBase64String($bytes) # Start-Process -wait "powershell.exe" -ArgumentList "-encodedcommand $encodedCommand" copy-item $certPath -Destination $PSScriptRoot -Verbose Invoke-Command -Credential $credential -ComputerName $env:COMPUTERNAME -ScriptBlock { param ( $workingDir, $vmAdminPassword, $domainCredential, $adfsServerName, $fsServiceName, $vmDCname, $resourceLocation ) # Working variables # Write an event to the event log to say that the script has executed. $event = New-Object System.Diagnostics.EventLog("Application") $event.Source = "tuServEnvironment" $info\_event = \[System.Diagnostics.EventLogEntryType\]::Information $event.WriteEntry("In WAPserver scriptblock", $info\_event, 5001) #go to our packages scripts folder Set-Location $workingDir $zipfile = $workingDir + "PSPKI.zip" $destination = $workingDir \[System.Reflection.Assembly\]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null \[System.IO.Compression.ZipFile\]::ExtractToDirectory($zipfile, $destination) Import-Module .tuServDeployFunctions.ps1 $fsCertificateSubject = $fsServiceName + "."+($resourceLocation.Replace(" ","")).ToLower()+".cloudapp.azure.com" $fsCertFileName = $workingDir + "" + $fsCertificateSubject+".pfx" Write-Verbose -Verbose "Importing sslcert $fsCertFileName" Import-SSLCertificate -certificateFileName $fsCertFileName -certificatePassword $vmAdminPassword $fsIpAddress = (Resolve-DnsName $adfsServerName -type a).ipaddress Add-HostsFileEntry -ip $fsIpAddress -domain $fsCertificateSubject Set-WapConfiguration -credential $domainCredential -fedServiceName $fsCertificateSubject -certificateSubject $fsCertificateSubject } -ArgumentList $PSScriptRoot, $vmAdminPassword, $credential, $adfsServerName, $fsServiceName, $vmDCname, $resourceLocation
14```The script modifies the HOSTS file on the server so it can find the ADFS service and then configures the Web Application Proxy for that ADFS service. It’s worth mentioning at this point the $fsCertificateSubject, which is also my service name. When we first worked on this environment using the old Azure PowerShell commands the name of the public endpoint was always <something>.cloudapp.net. When I use the new Resource Manager model I discovered that is now <something>.<Azure Location>.cloudapp.azure.com. The <something> is in our control – we specify it. The <Azure Location> isn’t quite, and is the resource location for our deployment (converted to lowercase with no spaces). You’ll find that same line of code in the DC and ADFS scripts and it’s creating the hostname our service will use based on the resource location specified in the template, passed into the script as a parameter. The functions called by that script are shown below:```
15function Import-SSLCertificate { \[CmdletBinding()\] param ( $certificateFileName, $certificatePassword ) Write-Verbose -Verbose "Importing cert $certificateFileName with password $certificatePassword" Write-Verbose -Verbose "---" Import-Module .PSPKIpspki.psm1 Write-Verbose -Verbose "Attempting to import certificate" $certificateFileName # import it $password = ConvertTo-SecureString $certificatePassword -AsPlainText -Force Import-PfxCertificate –FilePath ($certificateFileName) cert:localMachinemy -Password $password } function Add-HostsFileEntry { \[CmdletBinding()\] param ( $ip, $domain ) $hostsFile = "$env:windirSystem32driversetchosts" $newHostEntry = "\`t$ip\`t$domain"; if((gc $hostsFile) -contains $NewHostEntry) { Write-Verbose -Verbose "The hosts file already contains the entry: $newHostEntry. File not updated."; } else { Add-Content -Path $hostsFile -Value $NewHostEntry; } } function Set-WapConfiguration { \[CmdletBinding()\] Param( $credential, $fedServiceName, $certificateSubject ) Write-Verbose -Verbose "Configuring WAP Role" Write-Verbose -Verbose "---" #$certificate = (dir Cert:LocalMachineMy | where {$\_.subject -match $certificateSubject}).thumbprint $certificateThumbprint = (get-childitem Cert:LocalMachineMy | where {$\_.subject -match $certificateSubject} | Sort-Object -Descending NotBefore)\[0\].thumbprint # install WAP Install-WebApplicationProxy –CertificateThumbprint $certificateThumbprint -FederationServiceName $fedServiceName -FederationServiceTrustCredential $credential }
What’s Left?
This sequence of posts has talked about Resource Templates and how I structure mine based on my experience of developing and repeatedly deploying a pretty complex environment. It’s also given you specific config advice for doing the same as me: Create a Domain Controller and Certificate Authority, create an ADFS server and publish that server via a Web Application Proxy. If you only copy the stuff so far you’ll have an isolated environment that you can access via the WAP server for remote management. I’m still working on this, however. I have a SQL server to configure. It turns out that DSC modules for SQL are pretty rich and I’ll blog on those at some point. I am also adding a BizTalk server. I suspect that will involve more on the custom script side. I then need to deploy my application itself, which I haven’t even begun yet (although the guys have created a rich set of automation PowerShell scripts to deal with the deployment). Overall, I hope you take away from this series of posts just how powerful Azure Resource Templates can bee when pushing out IaaS solutions. I haven’t even touched on the PaaS components of Azure, but they can be dealt with in the same way. The need to learn this stuff is common across IT, Dev and DevOps, and it’s really interesting and fun to work on (if frustrating at times). I strongly encourage you to go play!
Credits
As with the previous posts, stuff I’ve talked about has been derived in part from existing resources:
- The Azure Quick Start templates were invaluable in having something to look at in the early days of resource templates before the tooling was here.
- PSPKI is a fantastic set of PowerShell modules for dealing with certs.
- The individual VM scripts are derived from work that was done in Black Marble by Andrew Davidson and myself to build the exact same environment in the older Azure manner without resource templates.