MDTApi - a RESTful API in pure PowerShell
In this post I’ll show how I quickly put together a RESTful API to execute powershell, complete with API Key management and validation, all written in pure PowerShell!
I have previously had use cases for allowing PowerShell scripts to be consumed through http. I for example built a module for interacting with Microsoft DNS Server some five years ago, as a step in providing Self-Service to the engineers. However, that exercise ended up in me asking an engineer to build a full-scale C# MVC api to run in IIS, just to execute a few PowerShell commands.
Once again, a valid use case for executing PowerShell scripts through http materialized. And once again I approached a developer for help in setting it up (coincidently the same developer who helped me previously, and now actually also work at my current employer!)
The helpful developer remembered the REST API he built for me many years ago. He also remembered it was a lot of hassle to get working, just for such a simple use case. His strong recommendation was to use .net core with an out-of-the box web server. He even kindly offered to produce the boiler plate for me, complete with instructions on how to compile and deploy.
One hour later he had committed the source in our repo and I was ready to put the final touches on it to make my script run.
After 4 hours of trying to understand C# I silently admitted defeat and instead started to once again google for a suitable pure powershell approach.
The use case
My team is currently implementing a complete, homebrewed Infrastructure Self-Service and asset management system. A part in this system is the ability to give the engineering teams Self-Service in deploying their own servers (Virtual and physical, Linux and Windows). Since the guys who have built this system are on the Linux side of things, they wanted help in streamlining the deployment of Windows servers.
My Tech Lead (who is more on the windows side) did some research and decided we’d go with Microsoft Deployment Toolkit (MDT) to achieve a streamlined, zero touch experience also when deploying Windows machines. Part of the process would be for the system, through Ansible, to insert the basic info for a new machine to be deployed into the MDT database.
The Tech Lead had also found a set of PowerShell scripts to interact with the MDT database and it was these scripts the Linux guys needed to run trough Ansible. And they were quite clear that they intended to do it through REST and not WinRM…
Polaris
So, having given up on quickly being able to provide a http interface to Ansible using C#, I googled like crazy for a simple, powershell native solution. I quickly found out I was easily able to start a webserver in powershell using the .net HTTPListener. However, all examples i found were too simple for my use case (I needed GET/POST/DELETE, json body and url parameters).
So I kept on searching for the simple (but not too simple) solution. Until i stumbled upon Polaris! I quickly realized this was exactly what I was looking for! It had all the features I needed, was written in pure PowerShell (so even I was able to understand what was going on) and straight forward to use.
In reality Polaris is an advanced wrapper around HTTPListener
, but for me, not having to learn the ins and outs of it, was a real win!
The Design
So I arranged a quick design workshop with the team, and we decided on parameters and methods for the api. This is the specification of the MDTAPI that we came up with.
MDTAPI Endpoint:
Method | GET /mdtapi | POST /mdtapi | DELETE /mdtapi |
---|---|---|---|
Input | Url parameters | Json body | Url parameters |
Parameters | Serial [string, optional] Deployed [boolian, optional] |
Serial [string, required] TestSequence [string, required] IPAddress [string, required] NetMask [string, required] Gateway [string, required] DNSServers [array, required] MACAddress [string, required] FQDN [string, required] |
Serial [string, required] |
Example | /mdtapi?Serial=xyz123 /mdtapi?Deployed=true |
{ “Serial”:”654321abcd”, “TestSequence”:”a1”, “IPAddress”:”192.168.0.12”, “NetMask”:”255.255.255.0”, “Gateway”:”192.168.0.1”, “DNSServers”: [ “192.168.0.10”, “192.168.0.20”, “192.168.0.30” ], “MACAddress”:”00-11-22-AA-CC-CC”, “FQDN”:”vm01.my.domain” } |
/mdtapi?Serial=xyz123 |
Description | Get all or specifc machines | Post a new machine to deploy | Delete specific machine |
The ‘Serial’ parameter is either the physical serial number or the VM generated one.
I also decided I’d like a way to authenticate and control the consumers of the api, so I decided to add the below endpoint.
APIKEYADMIN Endpoint:
Method | GET /apikeyadmin | DELETE /apikeyadmin |
---|---|---|
Input | None | Url parameters |
Parameters | None | MDTClientId [string, required] |
Example | /apikeyadmin | /apikeyadmin?MDTClientId=abc123 |
Description | Generate new api key | Delete specific api key |
The Setup
So what’s needed to set this up?
- First of all, you need to setup MDT. I’m not going to cover that here.
- Secondly you’d need to get the Polaris module. Should be as easy as to
Install-Module -Name Polaris
. - Thirdly, you’d need to download the MDTDB module.
- Fourthly you’d take a look at my code and steal the pieces that suites you. For convenience, I’ll walk you through the gist of it below.
- Fifthly you’d need a way for powershell to always run in order to receive requests even when not on the console. Check out this guide for setting up a powershell script to run as a service.
- Sixthly you should really run Polaris using https. Check out this guide on how to make Polaris run using tls.
- Sevently you may want to consider some sort of authentication schema to your api. Polaris have some nice out-of-the-box options, but I decided to roll my own API key management and validation.
modules/MDTApiCredential.psm1
The MDTApiCredential module contains a handful of helper functions to manage apikeys through the /apikeyadmin
endpoint and also validate authentication. Below is a quick description of the functions:
-
New-MDTApiCredential is used to generate new
MDTClientId
andMDTApiKey
and store it encrypted on disk. -
Get-MDTApiCredential is used to retrieve and decrypt
MDTApiKey
stored for specifiedMDTClientId
. -
Remove-MDTApiCredential is used to, well, delete a stored
MDTApiKey
for a specifiedMDTClientId
. -
Set-MDTApiAdminCredential is used to set a
MDTApiKey
for the adminMDTClientId
, MDTApiAdmin. -
Test-MDTApiCredential is used to test the request header and authenticate the http call.
The stuff in this module is quite straight forward and I’m not going to go through it detail here.
modules/MDTApiParameter.psm1
The MDTApiParameter module contains only one function. However, it’s a large and important function since it’s used to validate input and make sure crap is not entered into the MDT Database, so I decided to slap it into a psm1 to not clutter up the main script too much.
- Test-MDTParameters is used to validate parameter input through regex and exact matching.
This function may be a bit tricky to understand so I’m gonna run it through for you:
The param ($Parameters, $Body)
receives the parameters for a specific method route and validates them against the request body.
The $[*]MatchVar
variables are used to create validation patterns for each parameter. The pattern can either be an array of exact matches or a regex.
The $ParametersAmountRequred
hashtable is used to set the allowed amount of inputted parameter values.
Then the foreach ($Parameter in $Parameters)
loop is fired and validation of all parameters against the request body is done.
When finished validating the parameters, a $Result
object is returned populated with parameters that was Validated, Malformated and Empty. That $Result object is then used to make decisions on what (and what not) to execute in the main script.
All of this will hopefully make more sense when looking at the main script that actually sets up the endpoints and routes and starts listening for http requests!
RunMDTApi.ps1
The RunMDTApi.ps1 script is the main script that does the magic (with much help from the Polaris module). The main script creates all routes and methods to listen for, and what scriptblock to run when a request is received.
In the first section of the script, some general variables are set.
$ModulePath = 'C:\SomePath\Modules' # Where to find the modules
$MDTKeyPath = 'C:\SomePath\APIKeys' # Where to store the APIKeys
$MyServerName = 'my.server.domain' # The fqdn to listen on
$APIPath = '/mdtapi' # Http route for the mdt endpoint
$APIKeyAdminPath = '/mdtapikeyadmin' # Http route for the APIKey admin endpoint
$AdminClientId = 'MDTApiAdmin' # MDTClientId for the special admin user
$MDTDatabaseServer = 'my.mdtdb.server' # The dbserver to use
$MDTDatabse = 'MDT' # Name of mdt database
# Below is a string object containg the code to populate a hash table. This can be invoked when needed through 'Invoke-Expression'
$MDTComputerSettings = @'
$CurrentSettings = @{
'OSDComputerName' = ($Request.Body.FQDN -split '.')[0];
'AdminPassword' = (Get-MDTApiCredential "WindowsLocalAdminPassword");
'TaskSequenceID' = $Request.Body.TestSequence;
'OSDAdapter0DNSServerList' = ($Request.Body.DNSServers -join ',');
'OSDAdapter0DNSSuffix' = 'my.domain';
'OSDAdapter0EnableDHCP' = $false;
'OSDAdapter0Gateways' = $Request.Body.Gateway;
'OSDAdapter0IPAddressList' = $Request.Body.IPAddress;
'OSDAdapter0MacAddress' = $Request.Body.MACAddress.ToUpper();
'OSDAdapter0SubnetMask' = $Request.Body.NetMask;
'OSDAdapterCount' = 1
}
'@
In the second part, the modules covered above (Polaris, MDTApiCredential, MDTApiParameter and MDTDB) are loaded.
Import-Module $ModulePath\Polaris\Polaris.psm1
Import-Module $ModulePath\MDTApiCredential.psm1
Import-Module $ModulePath\MDTApiParameter.psm1
Import-Module $ModulePath\MDTDB.psm1
Once the basics are out of the way, let’s create our first method route (GET /mdtapi).
The first thing to do is to validate if the request is authenticated using Test-MDTApiCredential
function.
If the request is authorized (containing a valid MDTClientId and matching MDTApiKey), the next step of the script is to specify parameters and run them. Since this is the GET method (described above), we allow the Serial and Deployed parameters. These parameters, if used, are passed in the Url. Polaris puts them in the $Request.Query
object, where we can extract them and run them through the Test-MDTParameters
function.
A $TestedParameters
object is received, containg parameters that are Validated,Malformated and/or Empty. At this point it’s just a matter of executing the MDTDB command needed to match the input in the request (unless any of the parameters are Malformated).
New-PolarisGetRoute -Path $APIPath -ScriptBlock {
# test if request is authorized
if (Test-MDTApiCredential $Request.Headers)
{
# Available parameters for method
$Parameters = @('Serial','Deployed')
# Haxx 2 build a psobject from strange string in request query
$QueryObject = @{}
foreach ($Param in $Parameters)
{
$QueryObject.$Param = $Request.Query[$Param]
}
# Test the parameters in request
$TestedParameters = Test-MDTParameters -Parameters $Parameters -Body $QueryObject
# Check if malformated input in request
if ($TestedParameters.Malformated)
{
# return error
$response.SetStatusCode(400)
$response.Send("Parameter malformated: ($($TestedParameters.Malformated -join ', ')).")
Return
}
# check if to return all computers
elseif ($TestedParameters.Empty.Count -eq $Parameters.Count)
{
# return everything
Connect-MDTDatabase -sqlServer $MDTDatabaseServer -database $MDTDatabase | Out-Null
$response.SetStatusCode(200)
$response.Send(((Get-MDTComputer) | ConvertTo-Json))
Return
}
# build a filtered get
else
{
# filter on specific serial
if ($TestedParameters.Validated -contains 'Serial')
{
Connect-MDTDatabase -sqlServer $MDTDatabaseServer -database $MDTDatabase | Out-Null
$response.SetStatusCode(200)
$response.Send(((Get-MDTComputer -serialNumber $QueryObject.Serial) | ConvertTo-Json))
Return
}
# filter on deployed status
elseif ($TestedParameters.Validated -contains 'Deployed')
{
Connect-MDTDatabase -sqlServer $MDTDatabaseServer -database $MDTDatabase | Out-Null
$response.SetStatusCode(200)
$response.Send(((Get-MDTComputer -isDeployed $QueryObject.Deployed) | ConvertTo-Json))
Return
}
# catch all rule
else
{
$response.SetStatusCode(500)
$response.Send("Unable to parse parameters ($($QueryObject -join ', '))")
Return
}
}
}
# request not authorized
else
{
$response.SetStatusCode(401)
$response.Send('Not authorized.')
Return
}
} -Force
Now it’s time to create our (DELETE /mdtapi) Polaris method route.
The first thing to do is to validate if the request is authenticated using Test-MDTApiCredential
function.
If the request is authorized (containg a valid MDTClientId and matching MDTApiKey), the next step of the script is to specify parameters and run them. Since this is the DELETE method (described above), we only allow (and require) the Serial parameter. The Serial parameter is passed in the Url. Polaris puts them in the $Request.Query
object, where we can extract it and run it through the Test-MDTParameters
function.
A $TestedParameters
object is received, containing parameters that are Validated,Malformated and/or Empty. At this point it’s just a matter of executing the MDTDB command needed to match the input in the request (unless the parameter is Malformated).
New-PolarisDeleteRoute -Path $APIPath -ScriptBlock {
# Test if request is authorized
if (Test-MDTApiCredential $Request.Headers)
{
# Available parameters for method
$Parameters = @('Serial')
# Haxx 2 build a psobject from strange string in request query
$QueryObject = @{}
foreach ($Param in $Parameters)
{
$QueryObject.$Param = $Request.Query[$Param]
}
# Test the parameters in request
$TestedParameters = Test-MDTParameters -Parameters $Parameters -Body $QueryObject
# Check if malformated input
if ($TestedParameters.Malformated)
{
# return error
$response.SetStatusCode(400)
$response.Send("Parameter malformated: ($($TestedParameters.Malformated -join ', ')).")
Return
}
# Test if parameters empty
elseif ($TestedParameters.Empty.Count -eq $Parameters.Count)
{
# return error
$response.SetStatusCode(400)
$response.Send("Parameter missing: ($($TestedParameters.Empty -join ', ')).")
Return
}
# Parameters validated delete machine(s)
else
{
# Collect the machine(s) to delete
Connect-MDTDatabase -sqlServer $MDTDatabaseServer -database $MDTDatabase | Out-Null
$DeleteMDTMachines = Get-MDTComputer -serialNumber $QueryObject.Serial
$response.SetStatusCode(200)
$response.Send((($DeleteMDTMachines.id | %{Remove-MDTComputer -id $_}) | ConvertTo-Json))
Return
}
}
# request not authorized
else
{
$response.SetStatusCode(401)
$response.Send('Not authorized.')
Return
}
} -Force
Finally, let’s create the (POST /mdtapi) Polaris method route to wrap up the mdtapi part.
The first thing to do is to validate if the request is authenticated using Test-MDTApiCredential
function.
If the request is authorized (containing a valid MDTClientId and matching MDTApiKey), the next step of the script is to specify parameters and run them. Since this is the POST method (described above), we specify all the mandatory parameters. The parameters are passed in the Json body. Polaris puts them in the $Request.BodyString
object, where we can extract and convert it to a psobject and run it through the Test-MDTParameters
function.
A $TestedParameters
object is received, containing parameters that are Validated,Malformated and/or Empty. At this point it’s just a matter of executing the MDTDB command needed to match the input in the request (unless a parameter is Malformated).
New-PolarisPostRoute -Path $APIPath -ScriptBlock {
# test if request is authorized
if (Test-MDTApiCredential $Request.Headers)
{
# Available parameters for method
$TestParameters = @('Serial','TestSequence','IPAddress','NetMask','Gateway','DNSServers','MACAddress','FQDN')
# test if payload json exist
if ([string]::IsNullOrEmpty($Request.BodyString))
{
# return json
$response.SetStatusCode(400)
$response.Send('Missing json body.')
Return
}
# payload json exist, continue
else
{
# convert payload json to psobject
try {$Request.Body = ($Request.BodyString | ConvertFrom-Json -ea stop)}
catch {
# json is malformated
$response.SetStatusCode(400)
$response.Send('Malformated json.')
Return
}
# Test the parameters in request
$TestedParameters = Test-MDTParameters -Parameters $TestParameters -Body $Request.Body
# create an array to hold any validation error messages
$ParameterValidationErrors = @()
# collect missing parameters and add to error
if ($TestedParameters.Empty)
{
$ParameterValidationErrors += "Parameter missing: ($($TestedParameters.Empty -join ', '))."
}
# collect malformated parameter values and add to error
if ($TestedParameters.Malformated)
{
$ParameterValidationErrors += "Bad parameter value on: ($($TestedParameters.Malformated -join ', '))."
}
# everything is hunky dory, execute the stuff!
if (($TestedParameters.Validated.count -eq $TestParameters.count) -and (!$ParameterValidationErrors))
{
# Ready to rock and roll
Connect-MDTDatabase -sqlServer $MDTDatabaseServer -database $MDTDatabase | Out-Null
# populate the mdt settings hash with input values to $CurrentSettings variable
Invoke-Expression $MDTComputerSettings
# execute and return
$response.SetStatusCode(200)
$response.Send(((New-MDTComputer -settings $CurrentSettings) | ConvertTo-Json))
Return
}
# something is wrong with input, return error
else
{
$response.SetStatusCode(400)
$response.Send("Invalid input. $($ParameterValidationErrors -join '. ')")
Return
}
}
}
# authentication failed
else
{
$response.SetStatusCode(401)
$response.Send('Not authorized.')
Return
}
} -Force
Well, that’s about it for the /mdtapi endpoint!
Let’s move on to setup the /mdtapikeyadmin endpoint.
We’ll start by setting up the route for the get method (GET /mdtapikeyadmin).
Since this route method requires no parameters, only authentication validation is done. Since only the admin is allowed to generate new api keys, the MDTClientId
need to be the admin user.
New-PolarisGetRoute -Path $APIKeyAdminPath -ScriptBlock {
# test if request is authorized as admin
if (($Request.Headers['MDTClientId'] -eq $AdminClientId) -and ($Request.Headers['MDTApiKey'] -eq (Get-MDTApiCredential $AdminClientId -ea SilentlyContinue)))
{
# authorized, create key and return it
$response.SetStatusCode(201)
$response.Send(((New-MDTApiCredential) | ConvertTo-Json))
Return
}
# request not authorized
else
{
$response.SetStatusCode(401)
$response.Send('Not authorized.')
Return
}
} -Force
Now, let’s move on to setting up the delete method route (DELETE /mdtapikeyadmin) as specified above. This method route only accepts (and requires) the parameter MDTClientId
. The parameter is passed through the url, so therefore Polaris puts in in $Request.Query
.
The script make sure you’re authorized (if admin you can delete any key, if not you can only delete your own).
New-PolarisDeleteRoute -Path $APIKeyAdminPath -ScriptBlock {
# test if request is authorized
if (Test-MDTApiCredential $Request.Headers)
{
# Available parameters for method
$Parameters = @('MDTClientId')
# Haxx 2 build a psobject from strange string in request query
$QueryObject = @{}
foreach ($Param in $Parameters)
{
$QueryObject.$Param = $Request.Query[$Param]
}
# Test the parameters in request
$TestedParameters = Test-MDTParameters -Parameters $Parameters -Body $QueryObject
# Check if malformated input
if ($TestedParameters.Malformated)
{
# return error
$response.SetStatusCode(400)
$response.Send("Parameter malformated: ($($TestedParameters.Malformated -join ', ')).")
Return
}
# check if parameters missing
elseif ($TestedParameters.Empty.Count -eq $Parameters.Count)
{
# return error
$response.SetStatusCode(400)
$response.Send("Parameter missing: ($($TestedParameters.Empty -join ', ')).")
Return
}
# input validated, continue
else
{
# check if admin or trying to delete own key
if ((($Request.Headers['MDTClientId'] -eq $AdminClientId) -or ($Request.Headers['MDTClientId'] -eq $QueryObject.MDTClientId)) -and ($QueryObject.MDTClientId -notlike $AdminClientId))
{
# remove key
try {Remove-MDTApiCredential -MDTClientId $QueryObject.MDTClientId -ea stop}
catch {
# failed, key probably non existing
$response.SetStatusCode(500)
$response.Send("Unable to delete key for MDTClientId ($($QueryObject.MDTClientId -join ', ')). Key may not exist.")
Return
}
# key deleted, return response
$response.SetStatusCode(200)
$response.Send("Deleted Key for MDTClientId ($($QueryObject.MDTClientId -join ', ')).")
Return
}
# not authorized to delete requested key
else
{
$response.SetStatusCode(401)
$response.Send("Not authorized to delete key for MDTClientId ($($QueryObject.MDTClientId -join ', ')).")
Return
}
}
}
# request not authorized
else
{
$response.SetStatusCode(401)
$response.Send('Not authorized.')
Return
}
} -Force
Last, but not least, we’re ready to start Polaris! This could be done like this:
# Make it go!
Start-Polaris -Port 443 -MinRunspaces 1 -MaxRunspaces 5 -Https -HostName $MyServerName
Et voila!
A feature rich RESTful API is up and running, ready to accept requests to execute PowerShell commands!
As mentioned above, some stuff should be done that is not covered in this guide:
- Bind a certificate to the listener like for example in this guide.
- Make the script run as a windows service like for example in this guide.
The personal experiences, viewpoints and opinions expressed in this blog post are my own and in no way represent those of the company.