Summary: Boe Prox shows how to create a tool that can leverage a remote constrained endpoint to perform a task.
Hey, Scripting Guy! How can I create a tool that my Help Desk admins can use to audit and restart services, while limiting their access to the server?
—TJ
Hello SH, Honorary Scripting Guy, Boe Prox, here today filling in for my good friend, The Scripting Guy. This is the final part in a series of five posts about Remoting Endpoints. The series includes:
- Introduction to PowerShell Endpoints
- Build Constrained PowerShell Endpoint Using Startup Script
- Build Constrained PowerShell Endpoint Using Configuration File
- Use Delegated Administration and Proxy Functions
- Build a Tool that Uses Constrained PowerShell Endpoint (today’s post)
To wrap up our week about Windows PowerShell remoting endpoints, I am going to apply the knowledge that I shared this week to create a custom constrained endpoint and to make a UI that can be used to find and restart services that are stopped.
The requirements for this consist of:
- Allow only a specified security group to access the remote endpoint.
- Provide credentials to allow delegated access via a service account.
- Restrict available commands to only allow these specified commands:
- Get only stopped services that are set for automatic startup.
- Start only services that are stopped. (This is an all or nothing approach, meaning that users will not be allowed to select what services to try and start.)
- Display the stopped services in the UI, and include two buttons to scan for and to start stopped services.
Why create a UI?
This is something that all Windows administrators are familiar with in their day-to-day operations, and anyone can click a couple of buttons without much effort. By creating the UI, I can make sure that the administrators can still audit to ensure that any services are running on the remote systems. At the same time, I can limit their access to the remote server by delegating access through a service account and allowing only two commands to run.
Even if they were to view the source code of the UI and determine the mechanism to connect to the remote endpoint through a Windows PowerShell console, they would still be limited to a non-interactive endpoint, and they would only have access to the two commands specified in the startup script.
First, I will focus on the creation of the remote endpoint that will meet my requirements. The approach that I will take is using a startup script to lock down what commands can be used and create a couple of proxy functions:
#Define Custom Proxy functions
Function Get-StoppedService {
Get-WmiObject -Class Win32_Service -Filter "StartMode = 'Auto' AND State != 'Running'" |
Select DisplayName, Name, State,@{L='Status';E={''}}
}
Function Start-StoppedService {
$Filter = "StartMode = 'Auto' AND State != 'Running'"
$Services = Get-WmiObject -Class Win32_Service -Filter $Filter
ForEach ($service in $services) {
Switch ($Service.StartService().ReturnValue) {
"0" {
Start-Sleep -Seconds 5
$Service = Get-WmiObject Win32_Service -Filter ("Name='{0}'" -f $Service.Name)
If ($Service.State -ne 'Running') {
$state = "Service could not start." -f $Service.name
} Else {
Write-Verbose ("{0} status is {1}" -f $Service.Name,$Service.State)
$state = 'OK'
}
}
"1" {$state = "Not Supported"}
"2" {$state = "Access Denied"}
"3" {$state = "Dependent Services Running"}
"4" {$state = "Invalid Service Control"}
"5" {$state = "Service Cannot Accept Control"}
"6" {$state = "Service Not Active"}
"7" {$state = "Service Request Timeout"}
"8" {$state = "Unknown Failure"}
"9" {$state = "Path Not Found"}
"10" {$state = "Service Already Running"}
"11" {$state = "Service Database Locked"}
"12" {$state = "Service Dependency Deleted"}
"13" {$state = "Service Dependency Failure"}
"14" {$state = "Service Disabled"}
"15" {$state = "Service Logon Failure"}
"16" {$state = "Service Marked For Deletion"}
"17" {$state = "Service No Thread"}
"18" {$state = "Status Circular Dependency"}
"19" {$state = "Status Duplicate Name"}
"20" {$state = "Status Invalid Name"}
"21" {$state = "Status Invalid Parameter"}
"22" {$state = "Status Invalid Service Account"}
"23" {$state = "Status Service Exists"}
"24" {$state = "Service Already Paused"}
}
[pscustomobject]@{
DisplayName = $Service.DisplayName
Name = $service.Name
State = $Service.State
Status = $State
}
}
}
#Proxy functions
[string[]]$proxyFunction = 'Get-StoppedService','Start-StoppedService','Get-Command'
#Cmdlets
ForEach ($Command in (Get-Command)) {
If (($proxyFunction -notcontains $Command.Name)) {
$Command.Visibility = 'Private'
}
}
#Variables
Get-Variable | ForEach {
$_.Visibility = 'Private'
}
#Aliases
Get-Alias | ForEach {
$_.Visibility = 'Private'
}
$ExecutionContext.SessionState.Applications.Clear()
$ExecutionContext.SessionState.Scripts.Clear()
$ExecutionContext.SessionState.LanguageMode = "NoLanguage"
Now that I have my startup script defined and the proxy functions (Get-StoppedService and Start-StoppedService) defined within it, I simply need to spin up a constrained remote endpoint and specify some credentials from a previously created service account:
Copy-Item ConstrainedEndpoint-Services.ps1 -Destination "C:\PSSessions"
Register-PSSessionConfiguration -Name PowerShell.ServiceAuditSession `
-StartupScript "C:\PSSessions\ConstrainedSessionStartupScript.ps1" `
-RunAsCredential 'boe-pc\endpointservice' -ShowSecurityDescriptorUI –Force
Because I used –ShowSecurityDescriptor, another window shows up and lets me specify who has access to this endpoint. I’ll supply the security group of my users that I want to provide access to and click OK:
Now that I have completed the endpoint creation, I can quickly test access by running a couple of commands to ensure that things work the way I want them to:
Invoke-Command -ComputerName boe-pc -ScriptBlock {Get-StoppedService} `
-ConfigurationName PowerShell.ServiceAuditSession
Next is to create the UI that will be used by whoever is in charge of monitoring the systems.
Note I won’t be representing every part of the script here because it is rather long. This is due to the use of extra script to support runspaces to handle the UI thread and other actions, and because the nature of writing UIs in Windows PowerShell typically means that writing a fairly large script. If you want to view the entire script, it is available to download from the Script Center Repository: Constrained Remote Endpoint - Services Demo.
The following piece defines the synchronized collections (hash tables, in this case), which allow me to share variables and objects between Windows PowerShell runspaces. (For more information about this subject, see PowerShell and WPF: Writing Data to a UI From a Different Runspace.) I am also creating the runspace that will handle the UI thread so the console will not be held up hosting the UI.
#region Synchronized Collections
$uiHash = [hashtable]::Synchronized(@{})
$runspaceHash = [hashtable]::Synchronized(@{})
$jobCleanup = [hashtable]::Synchronized(@{})
$jobs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList))
#endregion Synchronized Collections
#region Runspace Creation
$runspaceHash.Host = $Host
$runspaceHash.runspace = [RunspaceFactory]::CreateRunspace()
$runspaceHash.runspace.ApartmentState = “STA”
$runspaceHash.runspace.ThreadOptions = “ReuseThread”
$runspaceHash.runspace.Open()
$runspaceHash.psCmd = {Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase}.GetPowerShell()
$runspaceHash.runspace.SessionStateProxy.SetVariable('uiHash',$uiHash)
$runspaceHash.runspace.SessionStateProxy.SetVariable('runspaceHash',$runspaceHash)
$runspaceHash.runspace.SessionStateProxy.SetVariable('jobCleanup',$jobCleanup)
$runspaceHash.runspace.SessionStateProxy.SetVariable('jobs',$jobs)
$runspaceHash.psCmd.Runspace = $runspaceHash.runspace
#endregion Runspace Creation
$runspaceHash.handle = $runspaceHash.psCmd.AddScript({…}).BeginInvoke()
I need a job clean-up process to handle actions, such as auditing for services and starting services:
Do {
Foreach($runspace in $jobs) {
If ($runspace.Runspace.isCompleted) {
$runspace.powershell.EndInvoke($runspace.Runspace) | Out-Null
$runspace.powershell.dispose()
$runspace.Runspace = $null
$runspace.powershell = $null
}
}
#Clean out unused runspace jobs
$temphash = $jobs.clone()
$temphash | Where {
$_.runspace -eq $Null
} | ForEach {
$jobs.remove($_)
}
Start-Sleep -Seconds 1
} while ($jobCleanup.Flag)
The last couple of pieces that I am showing are the events that occur when the Scan Service button is clicked, and how I prevent the users from selecting a service to start (because I want all services to be started).
First, the button event:
$UIHash.ScanService_btn.Add_Click({
$Script:Computername = $uiHash.inputbox.text
If ([string]::IsNullOrEmpty($Computername)) {
$UIHash.StatusTextBox.Text = "Please enter a Computername!"
} Else {
$ScriptBlock = {
Param (
$Computername,
$uiHash
)
#$uiHash.Services = Get-StoppedService
Try {
$uiHash.Services = Invoke-Command -ComputerName $Computername `
-ScriptBlock {Get-StoppedService} -ConfigurationName PowerShell.ServiceAuditSession -ErrorAction Stop
$uiHash.ListView.Dispatcher.Invoke("Normal",[action]{
$uiHash.listview.ItemsSource = $uiHash.Services
$uiHash.ScanService_btn.IsEnabled = $True
$uiHash.RestartService_btn.IsEnabled = $True
$UIHash.StatusTextBox.Text = "Scanning completed!"
})
} Catch {
$_ > "C:\users\Administrator\Desktop\error.txt"
$uiHash.ListView.Dispatcher.Invoke("Normal",[action]{
$uiHash.ScanService_btn.IsEnabled = $True
$uiHash.RestartService_btn.IsEnabled = $True
$uiHash.StatusTextBox.Text = "[{0}] {1}" -f $Computername,$Error[0].FullyQualifiedErrorId
})
}
}
$uiHash.ScanService_btn.IsEnabled = $False
$uiHash.RestartService_btn.IsEnabled = $False
$UIHash.StatusTextBox.Text = "Scanning $Computername"
$sessionstate = [system.management.automation.runspaces.initialsessionstate]::CreateDefault()
$runspaceHash.runspacepool = [runspacefactory]::CreateRunspacePool(1, 1, $sessionstate, $Host)
$runspaceHash.runspacepool.Open()
#Create the powershell instance and supply the scriptblock with the other parameters
$powershell = [powershell]::Create().AddScript($ScriptBlock).AddArgument($Computername).AddArgument($uiHash)
#Add the runspace into the powershell instance
$powershell.RunspacePool = $runspaceHash.runspacepool
#Create a temporary collection for each runspace
$temp = "" | Select-Object PowerShell,Runspace,Type
$Temp.Type = 'ServiceScan'
$temp.PowerShell = $powershell
#Save the handle output when calling BeginInvoke() that will be used later to end the runspace
$temp.Runspace = $powershell.BeginInvoke()
$Null = $jobs.Add($temp)
}
})
I am creating a script block that has ComputerName and a synchronized hash table so I can write back all of the data and status messages to the UI. This is then added to the Runspace pool, and the query is kicked off and sent to the $jobs collection to be tracked and eventually removed when completed.
The last piece I will show is how I prevent the users from making any selections and giving the false illusion that they can pick which services to start:
$UIHash.listview.Add_SelectionChanged({
$This.UnselectAll()
})
This is an automatic variable representing the ListView control, and I can then use the UnselectAll() method whenever a user attempts to click an item.
Note You can learn more about automatic variables by running Get-Help about_automatic_variables.
Now that the UI has been created, it is time to give it a run.
Whoops, looks like I am using the wrong account. Let’s try this again with an account that actually has rights to the endpoint. I created a .bat file at a location that the users have rights to, and then I placed a shortcut to the .bat file on the desktop. This will make is easier for the users monitoring the systems to run the UI. (This script is available from the Script Center Repository: Constrained Remote Endpoint - Services Demo.)
That looks better! Here we can see which services are not running, and we can move forward with the attempts to start them. I will click the Start Services button to see if we can get these services going again.
After a short wait, I get my results back:
Success this time! If any errors occurred during the start process, they would be displayed under the Status column. With that, we have created a useful tool for administrators to use to audit and restart services, while limiting their actual access to the server.
TJ, that is all there is to creating a tool that can leverage remote constrained endpoints, and this also concludes Remoting Endpoint Week.
I invite you to follow the Scripting Guys on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow.
Boe Prox, Honorary Scripting Guy