Today, I thought I'd share my solution to a recent Iron Scripter challenge. I thought this was a fascinating task and one with a practical result. I'd encourage you to try your hand at the challenge and come back later to see how I tackled it.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
The challenge is not only a good test of your PowerShell scripting skills but also a test of how well you understand PowerShell remoting. In the long run, I have no doubt that PowerShell remoting will mean SSH. But that isn't coming anytime soon. Traditional PowerShell remoting using WSMan is still the way most IT Pros manage remote servers. This means you should understand it and be able to manage it.
The scripting challenge was to create a tool that would query a remote server for WSMan connections and write a custom object to the pipeline with specific pieces of information. Here's how I approached this.
Know Your Processes
The first thing to know is that when a PowerShell remoting connection is established, it runs in a wsmprovhost process. Querying for this process is simple.
I could also have used Get-CimInstance. A useful feature of Get-Process is the ability to get the process owner, or username, for each process. However, this only works when Get-Process is run on the remote host. No problem. I can use Invoke-Command.
I'm filtering out the process ID of the wsmprovhost process that is temporarily created by Invoke-Command. The process is transient in this situation, but if I was using an existing PSSession to see who else was connected, I want to filter it out. That was one of the challenge criteria as well.
The process object can provide a number of pieces of information the challenge was looking for. Although, one of the requirements was to get associated child processes. I found using Get-CimInstance to be useful for this task.
Get-WSManInstance
The other parts of the challenge require a bit more effort. When a PowerShell remoting connection is established, there is a corresponding WSMan instance. As you may know, when you connect via PowerShell remoting, you are connecting to a session endpoint. The Get-PSSessionConfiguration cmdlet displays these endpoints. Almost always, these endpoints initiate a PowerShell session on the remote computer. In WSMan-speak, this runs in a shell.
PowerShell has a cmdlet called Get-WSManInstance that allows you to enumerate these connections. As you can see, there is some useful information. One of the challenge tasks was to get the shell runtime. That is listed here but because this result is essentially an XML document, it isn't in a friendly format.
Fortunately, there is an XML method that will convert these values.
Get-PSRemoteSessionUser
Those where the tricky bits. Tasks like getting the source hostname from the IP address is easy with Resolve-DNSName. With all of this in mind, I wrote a PowerShell function called Get-PSRemoteSessionUser.
Function Get-PSRemoteSessionUser {
[cmdletbinding(DefaultParameterSetName = "ComputerName")]
Param(
[Parameter(ParameterSetName = 'Session', Position = 0)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Runspaces.PSSession[]]$Session,
[Parameter(ParameterSetName = 'ComputerName', Mandatory, Position = 0)]
[Alias('Cn')]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName,
[Parameter(ParameterSetName = 'ComputerName', ValueFromPipelineByPropertyName)]
[pscredential]$Credential,
[Parameter(ParameterSetName = 'ComputerName')]
[System.Management.Automation.Runspaces.AuthenticationMechanism]$Authentication,
[Parameter(ParameterSetName = 'ComputerName')]
[Parameter(ParameterSetName = 'Session')]
[int]$ThrottleLimit
)
Begin {
Write-Verbose "[BEGIN ] Starting: $($MyInvocation.Mycommand)"
#define the scriptblock to run remotely
$run = {
$VerbosePreference = $using:VerbosePreference
Write-Verbose "[$([environment]::machinename)] Querying for local WSMan sessions"
$process = 'wsmprovhost'
#these helper functions will be used to get and format connection data
Function _enumWsMan {
[cmdletbinding()]
Param()
Get-WSManInstance -ResourceURI Shell -Enumerate |
Select-Object -Property Name, State, ShellID, Owner, ClientIP, ProcessID,
@{Name = "Memory"; Expression = { _parseMemoryString $_.memoryUsed } },
@{Name = "ShellRunTime"; Expression = { [System.Xml.XmlConvert]::ToTimeSpan($_.ShellRunTime) } },
@{Name = "ShellInactivity"; Expression = { [System.Xml.XmlConvert]::ToTimeSpan($_.ShellInactivity) } },
@{Name = "MaxIdleTimeout"; Expression = { [System.Xml.XmlConvert]::ToTimeSpan($_.MaxIdleTimeout) } },
@{Name = "SessionConfiguration"; Expression = { Split-Path -path $_.resourceuri -leaf } }
}
Function _parseMemoryString {
#convert values like 11MB to 11534336
[cmdletbinding()]
Param([string]$StringValue)
switch -Regex ($StringValue ) {
"\d+KB" {
$val = 1KB
}
"\d+MB" {
$val = 1MB
}
"\d+GB" {
$val = 1GB
}
} #switch
if ($val) {
[int]$i = ([regex]"\d+").Match($StringValue).value
$i * $val
}
else {
Write-Warning "Failed to parse $StringValue"
$stringValue
}
} #close function
try {
Write-Verbose "[$([environment]::machinename)] Getting $process process excluding id $PID"
$p = (Get-Process -Name $process -IncludeUserName -erroraction stop).where({ $_.id -ne $pid })
}
Catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
Write-Warning "Could not find process $process on this computer."
}
Catch {
Throw $_
}
if ($p) {
foreach ($item in $p) {
if ($item.username) {
Write-Verbose "[$([environment]::machinename)] Getting the SID for $($item.username)"
$SID = [System.Security.Principal.NTAccount]::new("$($item.username)").Translate([System.Security.Principal.SecurityIdentifier]).value
}
else {
$SID = $null
}
#call a private function to enumerate WSMan connection associated with this process ID
Write-Verbose "[$([environment]::machinename)] Enumerating WSMan connections"
$connData = $(_enumWsMan).where({ $_.processid -eq $item.ID })
#get child process IDs
Write-Verbose "[$([environment]::machinename)] Getting child processes for id $($item.id)"
$childProcs = (Get-CimInstance -ClassName win32_process -filter "ParentProcessId = $($item.id)" -Property ProcessID).ProcessID
#resolve the hostname
#temporarily disable Verbose to eliminate verbose messages from loading the DNSClient module
$VerbosePreference = "SilentlyContinue"
Import-Module DNSClient
$VerbosePreference = $using:VerbosePreference
Try {
Write-Verbose "[$([environment]::machinename)] Resolving the hostname for $($conndata.ClientIP)"
$rHost = (Resolve-DnsName -Name $connData.ClientIP -ErrorAction Stop).NameHost
}
Catch {
Write-Verbose "[$([environment]::machinename)] Failed to resolve a hostname for $($connData.ClientIP)."
$rHost = $connData.clientIP
}
Write-Verbose "[$([environment]::machinename)] Returning connection data"
#Send data back to the host to construct a custom object
@{
rHost = $rHost
Item = $item
SID = $SID
Computername = [environment]::MachineName
ChildProcs = $childProcs
ConnData = $connData
}
} #foreach item
}
else {
Write-Verbose "[$([environment]::machinename)] No running $process process(e$as) found"
}
} #close scriptblock
$PSBoundParameters.add("Scriptblock", $Run)
$PSBoundParameters.Add("HideComputername", $True)
} #begin
Process {
Write-Verbose "[PROCESS] Getting remote connection data"
$data = Invoke-Command @PSBoundParameters
foreach ($result in $data) {
Write-Verbose "[PROCESS] Processing data for $($result.computername)"
[pscustomobject]@{
PSTypename = "PSRemoteSessionUser"
Computername = $result.Computername
DateUTC = (Get-Date).ToUniversalTime()
StartTimeUTC = $result.item.StartTime.ToUniversalTime()
ProcessID = $result.item.id
ChildProcesses = $result.childProcs
Username = $result.item.Username
SID = $result.sid
State = $result.connData.State
RemoteHost = $result.rHost
RemoteIPAddress = $result.connData.ClientIP
ShellRunTime = $result.conndata.ShellRunTime
SessionConfiguration = $result.connData.SessionConfiguration
Memory = $result.connData.Memory
}
} #foreach result
} #process
End {
Write-Verbose "[END ] Ending: $($MyInvocation.Mycommand)"
} #end
} #close Get-RemoteSessionUser
The function is designed to let me query remote computers by name or through an existing session. If I use an existing session, it will be filtered out from the results. Most of the core querying happens remotely. The "raw" data is returned to the local host from Invoke-Command and turned into a custom object.
My function uses verbose output and even passes my verbose preference to the remote computer.
What did I get back?
The custom object has properties that should meet all of the challenge requirements.
Custom Formatting
But there is one more thing. The last part of the challenge was to create a custom formatting file. The results I have a great, but maybe I typically only need to see a formatted subset of properties. Using New-PSFormatXML from the PSScriptTools module, I created a format.ps1xml. After a bit of tweaking in VS Code, I can load it into my session.
Update-FormatData psremotesessionuser.format.ps1xml
And now get nicely formatted results.
Here's the format file.
<?xml version="1.0" encoding="UTF-8"?>
<!--
format type data generated 09/15/2020 08:54:32 by BOVINE320\Jeff
using New-PSFormatXML from the PSScriptTools module.
Install-Module PSScriptTools
-->
<Configuration>
<ViewDefinitions>
<View>
<!--Created 09/15/2020 08:54:32 by BOVINE320\Jeff-->
<Name>default</Name>
<ViewSelectedBy>
<TypeName>PSRemoteSessionUser</TypeName>
</ViewSelectedBy>
<GroupBy>
<PropertyName>Computername</PropertyName>
</GroupBy>
<TableControl>
<!--Delete the AutoSize node if you want to use the defined widths.-->
<AutoSize />
<TableHeaders>
<TableColumnHeader>
<Label>SessionConfiguration</Label>
<Width>23</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Address</Label>
<Width>14</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Username</Label>
<Width>15</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>State</Label>
<Width>12</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Runtime</Label>
<Width>15</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>MemMB</Label>
<Width>12</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>PID</Label>
<Width>12</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<TableColumnItem>
<PropertyName>SessionConfiguration</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>RemoteIPAddress</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>Username</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>State</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>ShellRunTime</PropertyName>
</TableColumnItem>
<TableColumnItem>
<ScriptBlock>$_.Memory/.1MB -as [int]</ScriptBlock>
</TableColumnItem>
<TableColumnItem>
<PropertyName>ProcessID</PropertyName>
</TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
Limitations
I now have a PowerShell tool I can run to see who has connections to servers and the state of those sessions.
Remember, this is only showing remote connections via WSMan. If someone is connected with SSH, or PowerShell Direct (to a virtual machine), I won't "see" them. But in my situation, those would be special use edge cases anyway.
Next Steps
I hope you tried your hand at the challenge and will keep an eye on the Iron Scripter site for future challenges. They are a great way to test your knowledge and expand your skills. If you are looking to learn a bit more about PowerShell, you might take a look at my PowerShell Remoting Fundamentals course from Pluralsight. Or visit my Books and Training page.
I hope you found this informative and useful. Comments and feedback are always welcome.
1 thought on “Answering the WSMan PowerShell Challenge”
Comments are closed.