Proxy functions - Advanced PowerShell - PowerShell in Depth, Second Edition (2015)

PowerShell in Depth, Second Edition (2015)

Part 4. Advanced PowerShell

Chapter 37. Proxy functions

This chapter covers

· Understanding proxy functions

· Creating proxy functions

· Adding and removing parameters

Proxy functions are a neat—if little-used—aspect of PowerShell. A proxy is someone with the authority to act as you. A proxy function lets you replace a PowerShell command or function with a custom version while leveraging the internal functionality of the original. They’ve also been called “wrapper functions,” which is a good description, because they “wrap around” existing commands to provide a sort of custom interface to them.

37.1. The purpose of proxy functions

Proxy functions are often used as a way of restricting or enhancing a PowerShell command. For example, you might take away parameters from a command so that potentially dangerous functionality is disabled. Or you might add parameters to a command, enhancing its functionality.

PowerShell’s implicit remoting capability, which we discussed in chapter 10, uses proxy functions. When you import commands from a remote computer, you’re creating local proxy functions. They’re very sparse functions: They contain enough functionality to take whatever parameters you type, pass them to the remote machine, and execute the command there. But they make it look like the commands are available locally on your computer.

37.2. How proxy functions work

Proxy functions take advantage of PowerShell’s command-name conflict-resolution system. When you load multiple commands having the same name, PowerShell defaults to running the one most recently loaded or defined. So if you create a function named Get-Content, it’ll “override” the original Get-Content for the current PowerShell session. That lets you add or remove parameters and enhance the command’s functionality. Also at play is the fact that when you run a command, PowerShell first matches against aliases, then against functions, then against cmdlets—so a function will always “override” a cmdlet.

There are a few caveats here. In a normal shell, you can’t stop someone from unloading or otherwise removing your proxy function, which then gives them access to the original command. You also can’t stop someone from explicitly accessing the original command using fully qualified command names, such as ActiveDirectory\ Get-ADUser. So from this perspective, proxy functions aren’t a security mechanism—they’re a convenience feature.

There’s also a caveat related to the help system. When it finds two commands with the same name, such as a proxy function and the original underlying cmdlet, it’ll list them both when asked for help on either. So proxy functions can make it a bit trickier to get to the help for the original command.

It might seem, with these caveats, that proxy functions aren’t helpful, but they’re useful when combined with custom PowerShell Remoting endpoints. As we described in chapter 10, a custom endpoint—or custom configuration, to use PowerShell’s technical term—can be restricted. You can set it up so that only your proxy functions can run, thus restricting access to the original, underlying cmdlet and providing a much better security mechanism. In fact, it’s when combined with those custom endpoints that proxy functions start to become intriguing.

37.3. Creating a basic proxy function

To demonstrate, you’ll be extending the ConvertTo-HTML cmdlet via a proxy function. Now, you’re not going to name your proxy function ConvertTo-HTML; instead, you’ll create a proxy function named Export-HTML. Your goal is to both convert the data and write it out to a file, instead of having to pipe the HTML to Out-File on your own. So your proxy function will pick up a new parameter, -FilePath, that accepts the name of an output file. Because you’re not overriding its name, the original cmdlet will remain available for someone who needs it in its original form.

Start by creating the shell for the proxy function:

PS C:\> $metadata = New-Object System.Management.Automation.CommandMetaData

(Get-Command ConvertTo-HTML)

The metadata you’re creating looks like this:

Name : ConvertTo-Html

CommandType :

Microsoft.PowerShell.Commands.ConvertToHtmlCommand

DefaultParameterSetName : Page

SupportsShouldProcess : False

SupportsPaging : False

PositionalBinding : True

SupportsTransactions : False

HelpUri : http://go.microsoft.com/fwlink/?LinkID=113290

RemotingCapability : None

ConfirmImpact : Medium

Parameters : {[InputObject,

System.Management.Automation.ParameterMetadata],

[Property,

System.Management.Automation.ParameterMetadata],

[Body,

System.Management.Automation.ParameterMetadata],

[Head,

System.Management.Automation.ParameterMetadata]

...}

The Parameters section stores the parameter definitions—for instance:

PS C:\> $metadata.Parameters["InputObject"]

Name : InputObject

ParameterType : System.Management.Automation.PSObject

ParameterSets : {[__AllParameterSets,

System.Management.Automation.ParameterSetMetadata]}

IsDynamic : False

Aliases : {}

Attributes : {__AllParameterSets}

SwitchParameter : False

Once you’ve generated the metadata, it’s time to create the proxy function:

PS C:\> [System.Management.Automation.ProxyCommand]::Create($metadata) |

Out-File Export-HTML.ps1

As you’ll see, this is digging pretty deeply into PowerShell’s internals. The result of these two commands is a file, Export-HTML.ps1, which contains your proxy function’s starting point, as shown in listing 37.1. Compare the metadata for the parameters to what’s written out to your proxy function.

Listing 37.1. A new proxy function’s starting point

[CmdletBinding(DefaultParameterSetName='Page',

HelpUri='http://go.microsoft.com/fwlink/?LinkID=113290',

RemotingCapability='None')]

param(

[Parameter(ValueFromPipeline=$true)]

[psobject]

${InputObject},

[Parameter(Position=0)]

[System.Object[]]

${Property},

[Parameter(ParameterSetName='Page', Position=3)]

[string[]]

${Body},

[Parameter(ParameterSetName='Page', Position=1)]

[string[]]

${Head},

[Parameter(ParameterSetName='Page', Position=2)]

[ValidateNotNullOrEmpty()]

[string]

${Title},

[ValidateNotNullOrEmpty()]

[ValidateSet('Table','List')]

[string]

${As},

[Parameter(ParameterSetName='Page')]

[Alias('cu','uri')]

[ValidateNotNullOrEmpty()]

[System.Uri]

${CssUri},

[Parameter(ParameterSetName='Fragment')]

[ValidateNotNullOrEmpty()]

[switch]

${Fragment},

[ValidateNotNullOrEmpty()]

[string[]]

${PostContent},

[ValidateNotNullOrEmpty()]

[string[]]

${PreContent})

begin

{

try {

$outBuffer = $null

if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))

{

$PSBoundParameters['OutBuffer'] = 1

}

$wrappedCmd =

$ExecutionContext.InvokeCommand.GetCommand('ConvertTo-Html',

[System.Management.Automation.CommandTypes]::Cmdlet)

$scriptCmd = {& $wrappedCmd @PSBoundParameters }

$steppablePipeline =

$scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)

$steppablePipeline.Begin($PSCmdlet)

} catch {

throw

}

}

process

{

try {

$steppablePipeline.Process($_)

} catch {

throw

}

}

end

{

try {

$steppablePipeline.End()

} catch {

throw

}

}

<#

.ForwardHelpTargetName ConvertTo-Html

.ForwardHelpCategory Cmdlet

#>

There’s some cool stuff here. There are three script blocks named BEGIN, PROCESS, and END—just like the advanced functions we showed you in earlier chapters (24 and 32). Those manage the execution of the original, underlying cmdlet. There’s also, at the end, some comment-based help that forwards to the original cmdlet’s help. This is technically an advanced script, because it isn’t contained within a function; scripts are a bit easier to test, so we’ll leave it that way for now.

37.4. Adding a parameter

Start by adding the definition for a mandatory –FilePath parameter to the top of the parameter block (shown in bold in the following code):

param(

[Parameter(Mandatory=$true)]

[string]

$FilePath,

[Parameter(ValueFromPipeline=$true)]

[psobject]

${InputObject},

[Parameter(Position=0)]

[System.Object[]]

${Property},

[Parameter(ParameterSetName='Page', Position=3)]

[string[]]

${Body},

[Parameter(ParameterSetName='Page', Position=1)]

[string[]]

${Head},

[Parameter(ParameterSetName='Page', Position=2)]

[ValidateNotNullOrEmpty()]

[string]

${Title},

[ValidateNotNullOrEmpty()]

[ValidateSet('Table','List')]

[string]

${As},

[Parameter(ParameterSetName='Page')]

[Alias('cu','uri')]

[ValidateNotNullOrEmpty()]

[System.Uri]

${CssUri},

[Parameter(ParameterSetName='Fragment')]

[ValidateNotNullOrEmpty()]

[switch]

${Fragment},

[ValidateNotNullOrEmpty()]

[string[]]

${PostContent},

[ValidateNotNullOrEmpty()]

[string[]]

${PreContent})

You now have a few things that you need to code:

· You need to strip off your –FilePath parameter before the underlying cmdlet is run, because it won’t understand that parameter and will throw an error. This happens in the BEGIN block.

· You need to call the underlying cmdlet and pipe its output to Out-File.

Within the proxy function, you have access to a variable called $wrappedCmd, which is the original cmdlet. That variable is set up for you by the generated proxy function code. You also have access to a hash table called $PSBoundParameters, which contains all the parameters that your proxy function was run with. You’ll use those two variables to do your magic; the entire script appears in the following listing.

Listing 37.2. Export-HTML.ps1

In listing 37.2 you added only two lines and commented out one line. You used the splatting technique to pass all your parameters, except –FilePath, to the underlying cmdlet. Now you can test it:

PS C:\> get-process | .\Export-HTML.ps1 -filepath procs.html

It works perfectly, creating an HTML file with the designated filename.

37.5. Removing a parameter

Suppose, for some internal political reasons in your company, you don’t want anyone setting the –Title parameter of the output HTML. Instead, you always want the HTML page title to be “Generated by PowerShell.” Simple enough. Start by removing the -Title parameter from the parameter declaration block in your script. Then, programmatically add the parameter when your function is run, passing a hardcoded value. The next listing shows the new version.

Listing 37.3. Export-HTML.ps1, version 2

Notice that you left the parameter in and just commented it out. That way, if you ever change your mind about something so goofy, it’s easy to put back in. Also notice that you needed to change the position of the –Body parameter from 3 to 2. –Title used to be in 2, and you can’t have a position 3 without a 2, so –Body had to be moved up.

37.6. Turning it into a function

From a practical perspective, you probably should turn this script into a function so that it can be put into a module and loaded on demand. That’s easy: You just need to add the function keyword, the function’s name, and the opening and closing curly brackets:

Function Export-HTML {

...

}

The following listing shows the final result, which you should save as a script module named ExtraTools.

Listing 37.4. ExtraTools.psm1

function Export-HTML {

[CmdletBinding(DefaultParameterSetName='Page',

HelpUri='http://go.microsoft.com/fwlink/?LinkID=113290',

RemotingCapability='None')]

param(

[Parameter(Mandatory=$true)]

[string]

$FilePath,

[Parameter(ValueFromPipeline=$true)]

[psobject]

${InputObject},

[Parameter(Position=0)]

[System.Object[]]

${Property},

[Parameter(ParameterSetName='Page', Position=3)]

[string[]]

${Body},

[Parameter(ParameterSetName='Page', Position=1)]

[string[]]

${Head},

#[Parameter(ParameterSetName='Page', Position=2)]

#[ValidateNotNullOrEmpty()]

#[string]

#${Title},

[ValidateNotNullOrEmpty()]

[ValidateSet('Table','List')]

[string]

${As},

[Parameter(ParameterSetName='Page')]

[Alias('cu','uri')]

[ValidateNotNullOrEmpty()]

[System.Uri]

${CssUri},

[Parameter(ParameterSetName='Fragment')]

[ValidateNotNullOrEmpty()]

[switch]

${Fragment},

[ValidateNotNullOrEmpty()]

[string[]]

${PostContent},

[ValidateNotNullOrEmpty()]

[string[]]

${PreContent})

begin

{

try {

$outBuffer = $null

if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))

{

$PSBoundParameters['OutBuffer'] = 1

}

$wrappedCmd =

$ExecutionContext.InvokeCommand.GetCommand('ConvertTo-Html',

[System.Management.Automation.CommandTypes]::Cmdlet)

# we added this

$PSBoundParameters.Remove('FilePath') | Out-Null

$PSBoundParameters.Add('Title','Generated by PowerShell')

$scriptCmd = {& $wrappedCmd @PSBoundParameters |

Out-File -FilePath $FilePath }

# end of what we added

# we commented out the next line

# $scriptCmd = {& $wrappedCmd @PSBoundParameters }

$steppablePipeline =

$scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)

$steppablePipeline.Begin($PSCmdlet)

} catch {

throw

}

}

process

{

try {

$steppablePipeline.Process($_)

} catch {

throw

}

}

end

{

try {

$steppablePipeline.End()

} catch {

throw

}

}

<#

.ForwardHelpTargetName ConvertTo-Html

.ForwardHelpCategory Cmdlet

#>

}

Once the module in listing 37.4 is imported into your PowerShell session, you have access to the function. Tab completion works as well. If you try to tab-complete -Title, it won’t work because it has been removed. But tab completion for –FilePath will work.

There’s one final item we want to bring to your attention and that’s help. If you ask for help on Export-HTML, you’ll get complete help for ConvertTo-HTML. This is because help requests are forwarded:

<#

.ForwardHelpTargetName ConvertTo-Html

.ForwardHelpCategory Cmdlet

#>

The net result is that you won’t see the new –FilePath parameter and –Title will still be visible, although unusable, which might cause some confusion. You can delete these lines and the help URI in the cmdletbinding block:

[CmdletBinding(DefaultParameterSetName='Page',

HelpUri='http://go.microsoft.com/fwlink/?LinkID=113290',

RemotingCapability='None')]

Now you can create and insert comment-based help directly in the function, perhaps copying and pasting help information from ConvertTo-HTML. See chapter 29 for more information on writing help for your PowerShell scripts and functions.

37.7. Summary

This chapter demonstrated a useful way to employ proxy functions: to add functionality to a cmdlet without removing access to the original cmdlet. You could’ve simply added a –FilePath parameter to ConvertTo-HTML (this example was inspired by fellow PowerShell MVP Shay Levy, who took that approach for his own example at http://blogs.technet.com/b/heyscriptingguy/archive/2011/03/01/proxy-functions-spice-up-your-powershell-core-cmdlets.aspx). But doing so would’ve been inconsistent with the ConvertTo verb in PowerShell; by creating a new function with the proper Export verb, you maintained consistency within the shell and gained a useful command that Microsoft should have written for you. But now you don’t need to wait for them to do so!

Shay and another PowerShell MVP, Kirk Munro, have created a set of PowerShell extensions that make working with proxy functions much easier. If this is an area you want to explore, then download PowerShell Proxy Extensions from http://pspx.codeplex.com/.