Azure Pipelines in action pt. III - reasonably secure deploys to IIS
Update: the standard way of doing IIS deployments is hosting the agent on the app server and running tools such as IisWebAppDeployment.
- simple, default
- agent account has wide access to e.g. all applications and sites on the server
- ADO yaml based (task is v0 at the moment)
This post describes an approach where there is one centralized agent, and the deployments to app servers are done via Powershell remoting
- complex
- agent has no direct access to the app servers
- one account per app, limited to only being able to do deploys
- mostly powershell
I'll return to the tradeoffs later, when I've had a chance to implement and play with the first one. As to why we didn't go with the default: long story short - we inherited the original idea and ran with it.
Trying to implement a deployment flow from Azure Devops to an on-prem IIS server, where every account is not an administrator, is a surprisingly difficult task. Here's an approach that works for us, and might for you too!
One of the major wins on our automation journey thus far has been automating deployments to our internal IIS servers. This includes things like permission minimization, application pool manipulation and a huge number of gotchas. Here I'll try to go into as much detail as I can, but have to note from the get go: I'm not a Windows administrator, have little knowledge about networking and fledgling skills in PowerShell. Regardless, I feel like we've achieved something that is worth sharing.
The requirements
- self hosted Azure DevOps agent
- Windows Server with IIS
- Windows Server 2022 has the IISAdministration module we need
- networking access from the agent machine to the server
- domain account to do the installations
- no need for this to be an administrator
- PowerShell 7
- apparently some newer version of PowerShell 5 works too, but 7 is recommended
- patience
The template
The goal is to achieve a pipeline template that:
- moves the specified artifact to the target machine
- shuts down the IIS application pool
- overwrites the running application files with new ones from the artifact
- restarts the pool
The template we're going to work with is a simple one with as little bells and whistles we can have to get this thing working. To start with, we expect to have an artifact ready to go.
See the whole thing if you want to absorb it all first before analyzing piece by piece.
Let's dig in!
The deployment job
We're running everything in a single job. This is important to guarantee that the whole operation is ran on the same agent instance.
jobs:
- deployment:
${{ if not(eq(length(parameters.dependsOn), 0))}}:
dependsOn: ${{ parameters.dependsOn }}
workspace:
clean: all # always clean workspace on self-hosted agents
displayName: Update application on on-prem IIS (${{ parameters.deploymentEnvironmentName }})
environment: ${{ parameters.deploymentEnvironmentName }}
${{ if parameters.pool }}:
pool: ${{parameters.pool}}
variables:
- ${{ each g in parameters.deploymentVariableGroups }}: # take in one or more variable group containing the deployment variables
- group: ${{g}}
- name: zipFileOnBuildMachine
value: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- name: artifactZipPath
value: $(Pipeline.Workspace)/${{ parameters.artifactName }}/**/*.zip
strategy:
runOnce:
deploy:
steps:
The backbone for running pwsh on the target machine
Here's a helper template for running PowerShell on the target machines. The original PowershellOnTargetMachines requires an administrator account (when it seems this could be changed in a single line), which we do NOT want. As a workaround we must write quite a lot of PowerShell, which of course beggars the question of why we're even using YAML in the first place...
parameters:
- name: username
type: string
- name: password
type: string
# The name of the JEA config which is providing the installation user the proper access
- name: jeaConfig
type: string
default: ''
# Name of the target server
- name: machineName
type: string
# the script block to run
- name: scriptBlock
type: string
- name: displayName
type: string
- name: script
type: string
default: ''
steps:
- pwsh: |
$securePassword = ConvertTo-SecureString -AsPlainText '${{ parameters.password }}' -Force
$credential = New-Object System.Management.Automation.PSCredential('${{ parameters.username }}', $securePassword)
$sessionParams = @{
ComputerName = '${{ parameters.machineName }}'
Credential = $credential
}
if ('${{ parameters.jeaConfig }}' -ne '') {
$sessionParams.ConfigurationName = '${{ parameters.jeaConfig }}'
}
$session = New-PSSession @sessionParams
$start = @{
Session = $session
ScriptBlock = {
${{ parameters.scriptBlock }}
}
}
Invoke-Command @start
${{ parameters.script }}
$session | Remove-PsSession
displayName: ${{ parameters.displayName }}
Now that the base is set up, let's walk through each step in this deployment:
Move the artifact
Here we first download the artifact to the agent machine and move it over to the target machine.
- checkout: none
- download: none
- task: DownloadPipelineArtifact@2
displayName: Download build artifact
inputs:
source: current
runVersion: latest
artifactName: ${{ parameters.artifactName }}
targetPath: $(zipFileOnBuildMachine)
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
machineName: $(machineName)
scriptBlock: |
if (Test-Path -Path "${{parameters.artifactDirectory}}") {
Remove-Item -Path "${{parameters.artifactDirectory}}" -Recurse -Force
}
script: Copy-Item $(zipFileOnBuildMachine) -Destination "${{parameters.artifactDirectory}}" -ToSession $session -Recurse
displayName: Transfer artifact to target machine
First snag: If you created a fresh domain account for this, it has no way to access the target machine. Let's fix that first with this Powershell script:
$user = <your domain user>
$directories = <the artifact and app directories the user needs access to>
$remoteMgtGroupName = "Remote Management Users"
Write-Output "Adding user to ${remoteMgtGroupName} group"
try {
net localgroup $remoteMgtGroupName $user /add
}
catch [System.Management.Automation.RemoteException] {
if ($_ -like "System error 1378 has occurred.") {
"User ${user} is already a member of ${remoteMgtGroupName} group."
}
else {
throw
}
}
foreach ($directory in $directories) {
if (-Not (Test-Path -Path $directory)) {
New-Item -ItemType Directory -Path $directory
Write-Output "Directory '$directory' created."
}
$acl = Get-Acl $directory
$accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule($user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
$acl.SetAccessRule($accessRule)
Set-Acl -Path $directory -AclObject $acl
}
Remote Management Users seems overkill for this, but WinRMRemoteWMIUsers_
has been kinda deprecated in newer versions so I don't know if that should be dependent upon. Please let me know if you have better options here!
After this, the artifact should be available in the target machine in the directory you defined in the artifactDirectory
parameter.
Shut down the application pool
Here we use the IISAdministration
module to manipulate the application pool.
Since we're using JEA, we can't just directly call Get-IISAppPool
in yaml, since the language mode is limited. Instead, we create a shut-down-iis-app-pool.ps1
script on the target machine:
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PoolName
)
$appPool = Get-IISAppPool -Name $PoolName;
if ($appPool.State -eq "Stopped")
{
Write-Output "Application pool $AppPoolName is already stopped."
return;
}
Write-Output "Stopping Application pool $AppPoolName"
try
{
$appPool.Stop()
}
catch
{
Write-Output $appPool
Throw "Failed to stop Application pool $AppPoolName. Error: $_"
}
# app pool shutdown occasionally takes a while
$timeout = 20 # seconds
$elapsed = 0
while ($appPool.State -eq "Stopping" -and $elapsed -lt $timeout)
{
Start-Sleep -Seconds 2
$elapsed += 2
}
if ($appPool.State -ne "Stopped")
{
Write-Output $appPool.State
Throw "Application pool $AppPoolName could not be stopped within the timeout period."
}
Once this in in place, we can call it from the pipeline:
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
jeaConfig: ${{parameters.jeaConfig}}
machineName: $(machineName)
scriptBlock: '& C:\scripts\shut-down-iis-app-pool.ps1 -PoolName $(appPoolName)'
displayName: Stop application pool
But of course, we haven't yet allowed access to the script file. We want to limit the capabilities of the installation account to the bare minimum, so we only allow it to manipulate this specific application pool. Let's see about the JEA config at this point.
We have a .ps1 script for managing these, but I'm hesitant to share it at this point since it's pretty fickle and I'm sure there's bugs buried in there somewhere. Email me if you want a peek!
Instead of the management script, here's a JEA config that allows the user to call the specific script files and only for a specific application pool:
RoleCapability file created with New-PsRoleCapabilityFile:
@{
# ID used to uniquely identify this document
GUID = '9c85f746-8b9e-4866-95b7-20e19ac9dc94'
# Description of the functionality provided by these settings
Description = 'Allows specific management of IIS Application Pools'
# Modules to import when applied to a session
ModulesToImport = 'IISAdministration'
# Cmdlets to make visible when applied to a session
VisibleCmdlets = @{
'Name' = 'Get-IISAppPool'
'Parameters' = @{
'Name' = 'Name'
'ValidateSet' = 'app-pool-name' } }
# External commands (scripts and applications) to make visible when applied to a session
VisibleExternalCommands = 'C:\scripts\shut-down-iis-app-pool.ps1', 'C:\scripts\start-IIS-app-pool.ps1'
# Providers to make visible when applied to a session
VisibleProviders = 'IISAdministration'
}
SessionConfiguration file created with New-PSSessionConfigurationFile:
@{
# Version number of the schema used for this document
SchemaVersion = '2.0.0.0'
# ID used to uniquely identify this document
GUID = 'b9f59eb5-904a-4747-b777-0414af95b2f8'
# Description of the functionality provided by these settings
Description = 'JEA Role for managing IIS Application Pool'
# Session type defaults to apply for this session configuration. Can be 'RestrictedRemoteServer' (recommended), 'Empty', or 'Default'
SessionType = 'RestrictedRemoteServer'
# Directory to place session transcripts for this session configuration
TranscriptDirectory = 'C:\Transcripts\'
# Whether to run this session configuration as the machine's (virtual) administrator account
RunAsVirtualAccount = $true
# User roles (security groups), and the role capabilities that should be applied to them when applied to a session
RoleDefinitions = @{
'yourUser' = @{
'RoleCapabilityFiles' = 'path\to\file.psrc' } }
}
As far as I can tell, RunAsVirtualAccount
is required here, although it immediately makes the account a "virtual administrator". I guess you could configure the groups the virtual account runs as, but seeing as the default is local Administrators, and this is the recommended approach, it's all good 🤷. I would imagine that "Just Enough Administration" would start at "no permissions at all", but apparently we go with "full access by default". I swear, PowerShell remoting...
After you've registered the JEA config, the pipeline should be allowed to pass and the application pool should be shut down.
Overwriting the application directory
Once the app pool is stopped, we overwrite the files in the app directory with the new ones from the artifact. If need be, we could keep e.g. web.config or appsettings.Production.json here, but here we assume your artifact has everything the app needs.
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
machineName: $(machineName)
scriptBlock: |
$TargetPath = "${{parameters.appDirectory}}"
$SourcePath = "${{parameters.artifactDirectory}}"
Write-Output "Checking existing Application folder."
if (Test-Path $TargetPath)
{
Write-Output "Application folder $TargetPath found. Cleaning folder contents."
Get-ChildItem -Path $TargetPath | Remove-Item -Recurse -Force
}
else
{
Throw "Application folder not found."
}
# Update application and configuration
Write-Output "Updating application at $TargetPath from $SourcePath."
Get-ChildItem $SourcePath -Filter *.zip -Recurse | Expand-Archive -DestinationPath $TargetPath
script: Copy-Item $(zipFileOnBuildMachine) -Destination "${{parameters.artifactDirectory}}" -ToSession $session -Recurse
displayName: Update the application
Invoke-Command @copy
displayName: Update the application
Restarting the pool
Similar to stopping, we call the IISAdministration module to start the pool
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
jeaConfig: ${{parameters.jeaConfig}}
machineName: $(machineName)
scriptBlock: '& C:\ss\scripts\v8\start-iis-app-pool.ps1 -PoolName $(appPoolName)'
displayName: Start application pool
And the corresponding start-iis-app-pool.ps1
:
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PoolName
)
$appPool = Get-IISAppPool -Name $PoolName;
if ($appPool.State -eq "Started")
{
Write-Output "Application pool $AppPoolName is already started."
return
}
Write-Output "Starting Application pool $AppPoolName"
try
{
$appPool.Start()
}
catch
{
Write-Output $appPool
Throw "Failed to start Application pool $AppPoolName. Error: $_"
}
# wait until the pool has actually started
$timeout = 20 # Timeout in seconds
$elapsed = 0
while ($appPool.State -ne "Started" -and $elapsed -lt $timeout)
{
Start-Sleep -Seconds 2
$elapsed += 2
}
if ($appPool.State -ne "Started")
{
Write-Output $appPool.State
Throw "Application pool $AppPoolName could not be started within the timeout period."
}
Complete
Here's the completed pipeline:
# ASP.NET Core Web application on-premises update script
# Requires these variables, defined in parameters.deploymentVariableGroups:
# username - deployment user credentials
# userPassword
# machineName - the target server
# appPoolName - the application pool to manipulate
parameters:
#! Generic pipeline parameters
- name: artifactName
type: string
default: drop
# The name of the Azure Pipelines environment targeted by the update
- name: deploymentEnvironmentName
type: string
# The name of the JEA config which is providing the installation user the proper access
- name: jeaConfig
type: string
# Names of the variable group(s) containing the deployment configuration
- name: deploymentVariableGroups
type: object
- name: pool
type: string
default: ''
- name: deploymentName
type: string
default: DEPLOY_DOTNET
# where application should be deployed to. Local path in the target machine, e.g. S:/wwwroot/myapp
- name: appDirectory
type: string
# Path where the artifact is created on the target machine
- name: artifactDirectory
type: string
- name: dependsOn
type: object
default: []
jobs:
- deployment: ${{ parameters.deploymentName }}
${{ if not(eq(length(parameters.dependsOn), 0))}}:
dependsOn: ${{ parameters.dependsOn }}
workspace:
clean: all
displayName: Update application on on-prem IIS (${{ parameters.deploymentEnvironmentName }})
environment: ${{ parameters.deploymentEnvironmentName }}
${{ if parameters.pool }}:
pool: ${{parameters.pool}}
variables:
- ${{ each g in parameters.deploymentVariableGroups }}:
- group: ${{g}}
- name: zipFileOnBuildMachine
value: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- name: artifactZipPath
value: $(Pipeline.Workspace)/${{ parameters.artifactName }}/**/*.zip
strategy:
runOnce:
deploy:
steps:
- checkout: none
- download: none
- task: DownloadPipelineArtifact@2
displayName: Download build artifact
inputs:
source: current
runVersion: latest
artifactName: ${{ parameters.artifactName }}
targetPath: $(zipFileOnBuildMachine)
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
machineName: $(machineName)
scriptBlock: |
if (Test-Path -Path "${{parameters.artifactDirectory}}") {
Remove-Item -Path "${{parameters.artifactDirectory}}" -Recurse -Force
}
script: Copy-Item $(zipFileOnBuildMachine) -Destination "${{parameters.artifactDirectory}}" -ToSession $session -Recurse
displayName: Transfer artifact to target machine
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
jeaConfig: ${{parameters.jeaConfig}}
machineName: $(machineName)
scriptBlock: '& C:\scripts\shut-down-iis-app-pool.ps1 -PoolName $(appPoolName)'
displayName: Stop application pool
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
machineName: $(machineName)
scriptBlock: |
$TargetPath = "${{parameters.appDirectory}}"
$SourcePath = "${{parameters.artifactDirectory}}"
Write-Output "Checking existing Application folder."
if (Test-Path $TargetPath)
{
Write-Output "Application folder $TargetPath found. Cleaning folder contents."
Get-ChildItem -Path $TargetPath | Remove-Item -Recurse -Force
}
else
{
Throw "Application folder not found."
}
# Update application and configuration
Write-Output "Updating application at $TargetPath from $SourcePath."
Get-ChildItem $SourcePath -Filter *.zip -Recurse | Expand-Archive -DestinationPath $TargetPath
script: Copy-Item $(zipFileOnBuildMachine) -Destination "${{parameters.artifactDirectory}}" -ToSession $session -Recurse
displayName: Update the application
- template: /steps/powershell-on-target-machine.yml
parameters:
username: $(username)
password: $(userPassword)
jeaConfig: ${{parameters.jeaConfig}}
machineName: $(machineName)
scriptBlock: '& C:\scripts\start-iis-app-pool.ps1 -PoolName $(appPoolName)'
displayName: Start application pool
Conclusion
Since we're really not doing anything complex here, it's quite surprising how complex this all looks. Most of the lines are wasted on handling the PowerShell sessions, while the functionality in each step itself is very simple.
This all seems simple in retrospect, but it has been a grueling climb to get here. The biggest hurdles have certainly been in getting a reasonably secure PowerShell remoting going. I think it's very dangerous that the default PowerShellOnTargetMachines task requires an administrator account, which makes the whole security model feel like a joke. Thankfully there's this workaround, but it should not be necessary at all.
Hope this helps you get to a working setup faster than we did!
Note: I only came up with the powershell-on-target-machine.yml
while writing this post. Since for the post I had to attempt to put everything in a single file, the amount of ps-boilerplate started to hurt badly. As always, there is a certain pain threshold you have to reach before making a change, and this was it.
Did I miss anything? Thoughts, comments? Send me an email!