<< Graph API App Webjob Azure DevOps CI Deployment Security |
In this post we will detail three features of our recent GitHub project that automatically deploys an Azure Virtual Desktop infrastructure (VDI), including host servers based on a Golden Image, and Azure DevOps pipelines to update and deploy new versions of the image.
The environment is configured with Azure Active Directory (AAD) joined hosts, and no line of sight is required to on-premises Active Directory (AD) controllers. Further, the Virtual Desktop hosts are configured with FSLogix containers so that users’ profiles can roam among the different hosts. Typically, the Virtual Desktop environment requires the hosts to be AD joined in order for users to have access to saving their profiles into an Azure File Share. However, in this project instead of a file share, we use a blob container to store the FSLogix profiles. Thus, neither the VDI hosts, nor the users, need to be registered in AD.
First, we will show the steps to programmatically join the virtual desktop hosts to AAD and make sure an AAD-only user can logon onto them. Then, we will go over the steps to enable FSLogix profiles stored on blob containers on the hosts. Lastly, we will detail the process for deploying the initial Golden Image version, plus the automated process for updating and deploying the image.
The source code for this project can be found in this public GitHub repository:
Better-Computing-Consulting/azure-virtual-desktop-infrastructure-deployment: Project to deploy Azure Virtual Desktop infrastructure, including custom golden image, compute gallery, virtualization resources, hostpool vm, and Azure DevOps pipelines to update the custom image and deploy updated servers to the hostpool. (github.com)We have also posted a YouTube video demonstrating the automated deployment of the VDI environment and DevOps pipelines, as well as testing access, FSLogix and the process to update and deploy new versions of the Golden Image.
The system joins hosts into AAD in two different scripts. First, on the initial deployment the deployResources.sh script adds the host right before adding it the host pool on line 194 by setting the AADLoginForWindows extension on the VM. The extension requires the VM to have a managed identity. Thus, on creating the VM we use the assign-identity option. See below.
182 183 184 185 186 187 188 189 190 191 192 193 194 |
az vm create -n $vmName \
--image $imgId \
--nsg "" \
--public-ip-address "" \
--admin-username $vdiHostAdminUsername \
--admin-password $vdiHostAdminPassword \
--enable-agent true \
--assign-identity \
--license-type Windows_Client \
--nic-delete-option Delete \
--os-disk-delete-option Delete --only-show-errors -o none
az vm extension set --publisher Microsoft.Azure.ActiveDirectory -n AADLoginForWindows --vm-name $vmName -o none
|
The system also joins hosts to AAD during the execution of the Deploy Image pipeline if there is new Golden Image version in the project’s Image gallery. The CronDeployImagePipeline executes the deployNewImage.ps1 script, which in line 100 runs the Set-AzVMExtension command to set the VM with the AADLoginForWindows extension.
110 111 112 113 114 115 116 117 |
Set-AzVMExtension `
-ResourceGroupName $rgName `
-VMName $newVMName `
-Name "AADLoginForWindows" `
-Location $VM.Location `
-Publisher "Microsoft.Azure.ActiveDirectory" `
-Type "AADLoginForWindows" `
-TypeHandlerVersion "0.4"
|
Once the VM is joined to AAD we still need to grant users permission to use Remote Desktop Services to access the computer. This step is only required for AAD joined VMs. And it is required even if you don’t intend to add the server to a Virtual Desktop host pool.
To grant the Remote Desktop access to the AAD joined VM the user must be assigned the Virtual Machine User Login role either at the VM or Resource Group level. To assist with the role assignment the GitHub repository contains an addAssignment.sh script. The administrator may run the script manually after the system is deployed. In addition to granting a user Remote Desktop access to the VM, the script also assigns the Virtual Desktop Application Group to the user. This second assignment allows the user to actually see the Remote Desktop published for her account when she logs on the Microsoft’s Virtual Desktop access site. The addAssignment.sh performs these assignments on lines 6 thru 12.
6 7 8 9 10 11 12 |
rgId=$(az group show --name $1-RG --query id -o tsv)
az role assignment create --assignee $2 --role 'Virtual Machine User Login' --scope $rgId --output none
agId=$(az desktopvirtualization applicationgroup show -g $1-RG -n $1-AG --query id -o tsv)
az role assignment create --assignee $2 --role 'Desktop Virtualization User' --scope $agId --output none
|
Next, to allow the user to use just his user ID (username@domain.com) to logon to the AAD joined VM in Azure’s Virtual Desktop web client or app, we must adjust the RDP properties of the host pool to indicate that the servers in the pool are AAD joined. This is not a default setting, and without it users cannot use the web interface for Virtual Desktop, and in the client application they will need to enter AzureAD\username@domain.com as their user id.
To enable plain user ID access to the Virtual Desktop we must add targetisaadjoined to the properties of the Virtual Desktop Host Pool. The deployResources.sh adds targetisaadjoined to the list of settings to be used during the creating of the host pool in line 154 of the script.
154 155 156 157 |
rdpSettings='audiomode:i:0;videoplaybackmode:i:1;devicestoredirect:s:*;enablecredsspsupport:i:1;redirectwebauthn:i:1;targetisaadjoined:i:1;redirectclipboard:i:1'
hostPoolId=$(az desktopvirtualization hostpool create -n $projectId-HP \
--custom-rdp-property "$rdpSettings" \
|
Typically, for FSLogix containers to work, each user must be able to authenticate to the Azure File Share to be able to create and access his or her own profile container. This setup requires the storage account to be registered in AD and line of sight to the domain controllers. This setup also requires the user to have an account in AD and to have the account synchronized to AAD.
In this deployment however, we do not have to have the storage or user accounts registered in AD, nor have line of sight to Domain Controllers. This is possible because we use blob containers to store the FSLogix profiles, instead of an Azure File Share. When blob containers are used, access is granted to the entire server instead of individual users. And access is granted to server by configuring it with the connection string to the storage account.
In this deployment we configure the Golden Image with FSLogix in by running the setFSLogixOneDrive.ps1 script on the initial base image VM. The deployResources.sh script runs the setFSLogixOneDrive.ps1 script on the VM on line 82.
79 80 81 82 83 84 85 |
connStr=$(az storage account show-connection-string -n $storageAccName)
tenantId=$(az account show --query tenantId)
cmdResult=$(az vm run-command invoke --command-id RunPowerShellScript -n $vmName \
--scripts @setFSLogixOneDrive.ps1 \
--parameters "connectionString=$connStr" "tennantId=$tenantId" \
--query value[0].message)
|
As you can see, one of the parameters we pass to the setFSLogixOneDrive.ps1 script is the connection string to the storage account.
To keep the connection string secure and prevent all users of the computer, including administrators, from seeing this sensitive piece of information, the setFSLogixOneDrive.ps1 stores the connection string in a secure key of the server. The setFSLogixOneDrive.ps1 script creates the secure key by running PsExec.exe as the System account to run the FSLogix frs.exe application. This creates the secure key under the System account.
36 37 38 39 |
if ((Test-Path ".\PSTools\PsExec.exe") -and (Test-Path "C:\Program Files\FSLogix\Apps\frx.exe")){
try {
.\PSTools\PsExec.exe -s -accepteula "C:\Program Files\FSLogix\Apps\frx.exe" add-secure-key -key='connectionString' -value="$connectionString"
}
|
This way, when the script configures the FSLogix CCDLocations property, which informs the system of the address of the storage account that will store the profiles, it enters the secure key name, connectionString, as the value of the property, instead of the actual connection string. See line 57 below.
49 50 51 52 53 54 55 56 57 |
if(Test-Path HKLM:\Software\FSLogix\Profiles){
New-ItemProperty -Path "HKLM:\Software\FSLogix\Profiles" `
-Name "Enabled" `
-PropertyType:DWord `
-Value 1 -Force
New-ItemProperty -Path "HKLM:\Software\FSLogix\Profiles" `
-Name "CCDLocations" `
-PropertyType:String `
-Value "type=azure,connectionString=|fslogix/connectionString|" -Force
|
The deployment of the FSLogix setup is further secured by limiting data access to the storage account to only the subnet hosting the virtual desktop severs. To achieve this, first when the deployResources.sh script deploys the storage account, it sets its default-action to Deny. This means that data access requests will be denied from every source unless specifically allowed.
28
|
saId=$(az storage account create -n $storageAccName --sku Standard_LRS --default-action Deny --bypass AzureServices --query id --only-show-errors)
|
Next, deployResources.sh script adds a network rule to the storage account to permit access from the subnet of virtual desktop servers.
32
|
az storage account network-rule add -n $storageAccName --subnet $subnetId -o none
|
One last design consideration in regard to the FSLogix setup is that to improve the responsiveness of the access to the storage account from the virtual desktop servers, the deployment includes a private endpoint between the storage account and the subnet hosting the virtual desktop servers. With a private endpoint the communication between these two resources goes through Microsoft’s backbone infrastructure, instead of over the public internet.
To deploy the private endpoint first the deployResources.sh specifies the service-endpoints parameter to deploy the subnet. With the service-endpoints parameter the subnet allows this type of private access.
24
|
subnetId=$(az network vnet subnet create --vnet-name VDIVNet -n VDIHostsSubnet --address-prefixes 172.23.3.0/24 --service-endpoints Microsoft.Storage --query id)
|
And then when the deployResources.sh script deploys the private endpoint, it specifies the ID of the subnet hosting the virtual desktop servers and the ID of the storage account. The script sets the group-id parameter to blob because we are setting this private endpoint to access blob containers. See below.
34 35 36 37 38 39 40 |
az network private-endpoint create \
--connection-name $projectId-Connection \
--name $projectId-Endpoint \
--private-connection-resource-id $saId \
--resource-group $rgName \
--subnet $subnetId \
--group-id blob -o none
|
As mentioned above, the virtual desktop hosts are created based on a Golden Image. This image is stored in an Image Gallery, which support versioning. Every time a new virtual desktop host is deployed, it is always done from the latest version of the Golden Image. Every time a new image version is captured, Sysprep must run on the VM from which the image will be captured, so the image can be used to deploy new hosts successfully. However, there is a limit on how many times Sysprep can be run on the same host. To avoid reaching this limit our deployment never runs Sysprep twice on the same host. Instead, every time Sysprep is about to run on a server, the system creates a snapshot of the operating system disk. And every time the system is deploys new programs or settings to an VM, this VM is created from the most recent snapshot. Thus, the updates are performed on VMs with disks that have never been Syspreped.
We can see how the snapshot are created right before running Sysprep in the two scripts that create new image definitions in our system. First, during the initial deployment the deployResources.sh script creates the snapshot on line 100.
100 101 102 |
az snapshot create -n ${vmName}-OSDisk-$(date +%Y%m%d%H%M) --source $osdsk --hyper-v-generation V2 -o none
az vm run-command invoke --command-id RunPowerShellScript -n $vmName --scripts @sysprepVM.ps1 -o jsonc
|
Then, during the execution of the DevOps pipeline that updates the Golden Image, the TriggeredUpdateImagePipeline, the updateImage.sh creates the snapshot on line 58, also right before running Sysprep on the server.
58 59 60 |
az snapshot create -n ${vmName}-OSDisk-$(date +%Y%m%d%H%M) --source $osDiskName --hyper-v-generation V2 --output none
az vm run-command invoke --command-id RunPowerShellScript -n $vmName --scripts @sysprepVM.ps1 -o jsonc
|
The TriggeredUpdateImagePipeline DevOps pipeline automatically updates the Golden Image with new programs, patches or settings. The execution of the pipeline is triggered by saving a new PowerShell script on the project's new_scripts directory. During the pipeline execution it runs the new PowerShell script on a VM provisioned from the latest snapshot, so that the new VM contains all the updates that have been deployed so far, and Sysprep never has ran on its operating system disk before. The updateImage.sh script executes the process of selecting the most recent snapshot, creating a new disk from the snapshot, and deploying a new VM using the disk in lines 26 through 35.
26 27 28 29 30 31 32 33 34 35 |
ssId=$(az snapshot list --query "[max_by(@, &timeCreated).id]")
osDiskName=osDisk$RANDOM
az disk create -n $osDiskName --sku Premium_LRS --hyper-v-generation V2 --source $ssId --output none
vmName=VDImageVM01
vmId=$(az vm create -n $vmName \
--attach-os-disk $osDiskName \
|
The next step after running Sysprep on the updated VM is to capture the VM as a new version of the Golden Image. When deployResources.sh performs the initial deployment, the image version is hard coded into the script because it is the first version. We use 0.1.0 as the initial version. See below.
130
|
imgId=$(az sig image-version create -r $imageGalery -i $imageDefinitionName -e 0.1.0 --virtual-machine $vmId --query id)
|
However, when the updateImage.sh script captures a new image version during the execution of the TriggeredUpdateImagePipeline DevOps pipeline, the script must find the latest image version and increment it before executing the az sig image-version create command. The updateImage.sh script does this on lines 77 through 82.
77 78 79 80 81 82 |
latestversion=$(az sig image-version list -r $imageGalery -i $imageDefinitionName --query "[max_by(@, &publishingProfile.publishedDate).name]")
parts=(${latestversion//./ })
nextVersion=${parts[0]}.${parts[1]}.$((parts[2]+1))
az sig image-version create -r $imageGalery -i $imageDefinitionName -e $nextVersion --virtual-machine $vmId --output none
|
Finally, after we update the project’s image definition with a new version of the Golden Image, the system will deploy new servers based on the latest version of the image on the next scheduled execution of the CronDeployImagePipeline DevOps pipeline. This pipeline will replace exiting virtual desktop hostpool servers that are based on a previous image version with servers that are based on the newest version. To do this the pipeline runs the deployNewImage.ps1 script, which first finds the most recent version of the image. See lines 29 through 34.
29 30 31 32 33 34 |
$imgVersions = Get-AzGalleryImageVersion -ResourceGroupName $rgName -GalleryName $imageGalery -GalleryImageDefinitionName Windows11MultiUser-VDI-Apps
$latestImage = $imgVersions | select -first 1
foreach ($ver in $imgVersions){
if ($latestImage.PublishingProfile.PublishedDate -lt $ver.PublishingProfile.PublishedDate){
$latestImage = $ver
}
|
And then the deployNewImage.ps1 script uses the most recent image version to deploy new VMs. See lines 104 through 107.
104 105 106 107 |
$VM = Set-AzVMSourceImage -VM $VM -Id $latestImage.id
"Deploying VM " + $newVMName
New-AzVM -ResourceGroupName $rgName -Location $location -VM $VM -LicenseType Windows_Client -DisableBginfoExtension
|
I hope you have found this post helpful. Please do not hesitate to contact Better Computing Consulting if you have any questions.
Thank you for reading.
IT Consultant
Better Computing Consulting
<< Graph API App Webjob Azure DevOps CI Deployment Security |
F: (310) 935-0341
Mon -Fri 9AM - 6PM Pacific Time