Enhancing the Proxy Command
In this issue:
Last time we started exploring how to create and use proxy functions in PowerShell. These are functions that wrap around existing commands, allowing us to modify their behavior without changing the original command. We often use proxy functions when we want to alter the command. Usually, I think of proxy functions are used to offer a stripped down version of a command. Often we write proxy functions for users or situations where we want to limit the functionality of a command, generally by removing parameters. However, we can also use proxy functions to add functionality to a command. This is often the way I use proxy functions. Although, as I mentioned in the closing of the previous article, just because you can create a proxy function doesn't mean you should.
But in the interest of education, let's continue doing so.
At the end of the previous article, I had a proxy function for Get-Service that included a Status parameter that allowed you to filter the services by their status.
#requires -version 7.5
#cleaned up code
#new Status parameter
function Get-Service {
[CmdletBinding(DefaultParameterSetName = 'Default')]
param(
[Parameter(
ParameterSetName = 'Default',
Position = 0,
ValueFromPipeline,
ValueFromPipelineByPropertyName
)]
[Alias('ServiceName')]
[ValidateNotNullOrEmpty()]
[string[]]$Name,
[ValidateNotNullOrEmpty()]
[System.ServiceProcess.ServiceControllerStatus]$Status,
[Alias('DS')]
[switch]$DependentServices,
[Alias('SDO', 'ServicesDependedOn')]
[switch]$RequiredServices,
[Parameter(ParameterSetName = 'DisplayName', Mandatory)]
[string[]]$DisplayName,
[Parameter(
ParameterSetName = 'InputObject',
ValueFromPipeline
)]
[ValidateNotNullOrEmpty()]
[System.ServiceProcess.ServiceController[]]$InputObject
)
begin {
try {
$outBuffer = $null
if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) {
$PSBoundParameters['OutBuffer'] = 1
}
#remove Status parameter since the original Get-Service won't recognize it
If ($PSBoundParameters.ContainsKey('Status')) {
[void]$PSBoundParameters.Remove('Status')
}
#get the native command
$wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Management\Get-Service', [System.Management.Automation.CommandTypes]::Cmdlet)
if ($Status) {
$scriptCmd = { & $wrappedCmd @PSBoundParameters | Where-Object {$_.Status -eq $Status}}
}
else {
$scriptCmd = { & $wrappedCmd @PSBoundParameters }
}
$steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
$steppablePipeline.Begin($PSCmdlet)
} #Try
catch {
throw
}
}
process {
try {
$steppablePipeline.Process($_)
}
catch {
throw
}
}
end {
try {
$steppablePipeline.End()
}
catch {
throw
}
}
clean {
if ($null -ne $steppablePipeline) {
$steppablePipeline.Clean()
}
}
}
<#
.ForwardHelpTargetName Microsoft.PowerShell.Management\Get-Service
.ForwardHelpCategory Cmdlet
#>
Let's build on top of this.
Adding Remoting
In Windows PowerShell, the Get-Service cmdlet has a -ComputerName parameter that allows you to query services on remote computers. However, in PowerShell 7, this parameter was removed as it was using legacy RPC protocols. I want to add this functionality back in, but using PowerShell remoting instead. This will allow us to query services on remote computers in a more modern and secure way.
Defining Parameters
The first step is to define the parameters for our proxy function. We will add a -ComputerName parameter that accepts an array of computer names. We will also add a -Credential parameter to allow users to specify credentials for remote connections.
[Parameter(HelpMessage = 'The name of a remote computer to query')]
[Alias('CN')]
[ValidateNotNullOrEmpty()]
[string]$Computername,
[Parameter(HelpMessage = 'Alternate credentials for the alternate computer')]
[Alias('RunAs')]
[ValidateNotNullOrEmpty()]
[PSCredential]$Credential
> There is a design decision to be made here. As written, a less knowledgeable user could technically specify a credential without specifying a remote computer. I could add logic within the function to verify that if a credential is provided, a computer name must also be provided. Another alternative is to make $Credential a dynamic parameter which only becomes available when the -ComputerName parameter is used and the computer name does not match the local computer. However, for the sake of simplicity and this article, I will not implement either of these options in this example. I will just rely on users to use the parameters correctly, but I wanted to point out these potential issues and solutions.
Updating PSBoundParameters
As we saw last time, proxy functions splat the $PSBoundParameters to the original command. This means that any parameters we add to our proxy function that are not recognized by the original command need to be removed from the $PSBoundParameters before we call the original command. Otherwise, we will get an error because the original command does not recognize these parameters.
I'll loop through my list of parameters that are not recognized by the original Get-Service cmdlet (in PowerShell 7) and remove them from the $PSBoundParameters if they exist.
'Computername', 'Credential', 'Status' | ForEach-Object {
if ($PSBoundParameters.ContainsKey($_)) {
[void]$PSBoundParameters.Remove($_)
}
} #foreach key
The Remove() method returns a boolean indicating whether the key was successfully removed, but I don't need to see this, so I cast the expression to [void] to suppress the output. This is a better practice than piping to Out-Null.
Where Are the Parameters?
Now for the tricky bits. If you recall, proxy function will invoke the native, or wrapped command:
$wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Management\Get-Service', [System.Management.Automation.CommandTypes]::Cmdlet)
In a script block:
$scriptCmd = { & $wrappedCmd @PSBoundParameters }
$steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
However, in my remoting scenario, the wrapped command I am running will actually be Invoke-Command. The script block for this command will be the customized Get-Service expression that I want to run on the remote, or local, computer. I will need to construct several items.
To create the script block for Invoke-Command, I will define the command string first , beginning with the native Get-Service command.
[string]$cmd = 'Microsoft.PowerShell.Management\Get-Service'
Now I need to pass the bound parameters ($PSBoundParameters) to this command. In a remoting scenario, I need to indicate that $PSBoundParameters is defined locally. To do this, I will use the Using scope modifier. This tells PowerShell to look for the variable in the local scope and pass it to the remote session.
if ($Computername) {
#need to scope PSBoundParameters for a remote connection
$cmd += ' @using:PSBoundParameters'
}
else {
$cmd += ' @PSBoundParameters'
}
> When building command strings like this be careful to include proper spacing.
Next, I need to take into account my new Status parameter. If the user specified a status, I need to add a Where-Object filter to the command string. Again, if I am running this command remotely, I need to use the Using scope modifier to indicate that the $Status variable is defined locally.
if ($Status -and $Computername) {
Write-Verbose "Getting services with a status of $Status from $($Computername.ToUpper())"
#note the use of single quotes
$cmd += ' | Where-Object {$_.Status -eq $using:Status}'
}
elseif ($Status) {
Write-Verbose "Getting services with a status of $Status"
$cmd += ' | Where-Object {$_.Status -eq $Status}'
}
else {
Write-Verbose 'Getting all services regardless of status'
}
Creating the Script Block
At this point I have a script command that might look like Get-Service @using:PSBoundParameters | Where-Object {$_.Status -eq $using:Status}. This is a string that I need to turn into a script block that I can pass to Invoke-Command.
$scriptBlock = [scriptblock]::Create($cmd)