Modules/Carbon.Windows.HttpServer/Carbon.Windows.HttpServer.psm1

# Copyright WebMD Health Services
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License

#Requires -Version 5.1
Set-StrictMode -Version 'Latest'

# Functions should use $script:moduleRoot as the relative root from which to find
# things. A published module has its function appended to this file, while a
# module in development has its functions in the Functions directory.
$script:moduleRoot = $PSScriptRoot

Add-Type -TypeDefinition (Get-Content -Raw -Path (Join-Path -Path $script:moduleRoot -ChildPath 'src\Http.cs' -Resolve))

# Store each of your module's functions in its own file in the Functions
# directory. On the build server, your module's functions will be appended to
# this file, so only dot-source files that exist on the file system. This allows
# developers to work on a module without having to build it first. Grab all the
# functions that are in their own files.
$functionsPath = Join-Path -Path $script:moduleRoot -ChildPath 'Functions\*.ps1'
if( (Test-Path -Path $functionsPath) )
{
    foreach( $functionPath in (Get-Item $functionsPath) )
    {
        . $functionPath.FullName
    }
}



function Get-CHttpsCertificateBinding
{
    <#
    .SYNOPSIS
    Gets the HTTPS certificate bindings on this computer.
 
    .DESCRIPTION
    The `Get-CHttpsCertificateBinding` returns all the HTTPS certificate bindings on the current computer. You can get
    specific bindings using an IP address, port, certificate thumbprint, and/or application ID, by using the
    `IPAddress`, `Port`, `Thumbprint`, and `ApplicationID` parameters. If a certificate that matches all the search
    criteria isn't found, the function writes an error.
 
    Uses the Windows API.
 
    .OUTPUTS
    Carbon.Windows.HttpServer.HttpsCertificateBinding.
 
    .EXAMPLE
    > Get-CHttpsCertificateBinding
 
    Demonstrates how to gets all the HTTPS certificate bindings on the local computer.
 
    .EXAMPLE
    > Get-CHttpsCertificateBinding -IPAddress 42.37.80.47 -Port 443
 
    Demonstrates how to get the binding for a specific IP address and port.
 
    .EXAMPLE
    Get-HttpsCertificateBinding -IPAddress '1.2.3.4'
 
    Demonstrates how to get all bindings on a specific IP address by passing the IP address number to the `IPAddress`
    parameter.
 
    .EXAMPLE
    > Get-CHttpsCertificateBinding -Port 443
 
    Demonstrates how to get all bindings on a specific port by passing the port number to the `Port` parameter.
 
    .EXAMPLE
    Get-CHttpsCertificateBinding -Thumbprint '4789073458907345907434789073458907345907'
 
    Demonstrates how to get all bindings using a specific certificate by passing the certificate's thumbprint to the
    `Thumbprint` parameter.
 
    .EXAMPLE
    Get-CHttpsCertificateBinding -ApplicationID '0c5a28db-f7e0-42f8-912b-9524fb49f054'
 
    Demonstrates how to get all bindings for a specific application by passing the application id to the
    `ApplicationID` parameter.
    #>

    [CmdletBinding()]
    [OutputType([Carbon.Windows.HttpServer.HttpsCertificateBinding])]
    param(
        # An IP address. Only bindings with this IP address are returned.
        [ipaddress] $IPAddress,

        # A port. Only bindings with this port number are returned.
        [UInt16] $Port,

        # A certificate thumbprint. Only bindings whose certificate hash matches this thumbprint are returned.
        [String] $Thumbprint,

        # An application ID. Only bindings whose application ID matches this value are returned.
        [Guid] $ApplicationID
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState
    $WhatIfPreference = $false

    $searching = $IPAddress -or $Port -or $Thumbprint -or $ApplicationID
    $bindings = @()
    [Carbon.Windows.HttpServer.HttpsCertificateBinding]::GetHttpsCertificateBindings() |
        Where-Object {
            if( $IPAddress )
            {
                return $_.IPAddress -eq $IPAddress
            }
            return $true
        } |
        Where-Object {
            if( $Port )
            {
                return $_.Port -eq $Port
            }
            return $true
        } |
        Where-Object {
            if( $Thumbprint )
            {
                return $_.CertificateHash -eq $Thumbprint
            }
            return $true
        } |
        Where-Object {
            if( $ApplicationID )
            {
                return $_.ApplicationID -eq $ApplicationID
            }
            return $true
        } |
        Tee-Object -Variable 'bindings' |
        Write-Output

    if (-not $searching -or $bindings)
    {
        return
    }

    $ipPortMsg = ''
    if (-not $IPAddress)
    {
        $IPAddress = [ipaddress]'0.0.0.0'
    }

    $ipPortMsg = "$($IPAddress.IPAddressToString)"
    if ($IPAddress.AddressFamily -eq 'InterNetworkV6')
    {
        $ipPortMsg = "[$($ipPortMsg)]"
    }

    if ($Port)
    {
        $ipPortMsg = "$($ipPortMsg.TrimEnd()):$($Port)"
    }

    $thumbprintMsg = ''
    if ($Thumbprint)
    {
        $ipPortMsg = " using certificate $($Thumbprint)"
    }

    $appIdMsg = ''
    if ($ApplicationID)
    {
        $appIdMsg = " for application $($ApplicationID.ToString('B'))"
    }

    $msg = "HTTPS certificate binding $($ipPortMsg)$($thumbprintMsg)$($appIdMsg) does not exist."
    Write-Error -Message $msg -ErrorAction $ErrorActionPreference
}



function Invoke-Netsh
{
    <#
    .SYNOPSIS
    INTERNAL.
 
    .DESCRIPTION
    INTERNAL.
 
    .EXAMPLE
    INTERNAL.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The target of the action.
        [Parameter(Mandatory)]
        [String] $Target,

        # The action/command being performed.
        [Parameter(Mandatory)]
        [String] $Action,

        # The command to run.
        [Parameter(Mandatory, ValueFromRemainingArguments, Position=0)]
        [String[]] $ArgumentList,

        # A comment to show at the end of the information message.
        [String] $Comment
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

    if (-not $PSCmdlet.ShouldProcess($Target, $Action))
    {
        return
    }

    if ($Comment)
    {
        $Comment = " # $($Comment)"
    }

    Write-Information "netsh $($ArgumentList -join ' ')$($Comment)"
    $output = netsh $ArgumentList
    if( $LASTEXITCODE )
    {
        $output = $output -join [Environment]::NewLine
        $msg = "Netsh command ""$($Action)"" on ""$($Target)"" exited with code $($LASTEXITCODE): $($output)"
        Write-Error -Message $msg -ErrorAction $ErrorActionPreference
        return
    }

    $output | Where-Object { $null -ne $_ } | Write-Verbose
}



function Remove-CHttpsCertificateBinding
{
    <#
    .SYNOPSIS
    Removes HTTPS certificate bindings.
 
    .DESCRIPTION
    Uses the netsh command line application to remove HTTPS certificate bindings. Pass any combination of IP address,
    port, thumbprint, or application ID to the `IPAddress`, `Port`, `Thumbprint`, and `ApplicationID` parmeters,
    respectively. All bindings that match all of the parameters you pass will be deleted. You must pass at least one.
 
    .EXAMPLE
    > Remove-CHttpsCertificateBinding -IPAddress '45.72.89.57'
 
    Demonstrates how to remove all HTTPS certificate bindings on a specific IP address. In this example, all bindings
    to IP address `45.72.89.57` will be removed.
 
    .EXAMPLE
    > Remove-CHttpsCertificateBinding -Port 443
 
    Demonstrates how to remove all HTTPS certificate bindings on a specific port. In this example, all bindings to port
    `44444` will be removed.
 
    .EXAMPLE
    Remove-CHttpsCertificateBinding -Thumbprint '7d5ce4a8a5ec059b829ed135e9ad8607977691cc'
 
    Demonstrates how to remove all HTTPS certificate bindings using a specific certificate by passing its thumbprint to
    the `Thumbprint` parameter.. In this example, all bindings to certificate with thumbprint
    `7d5ce4a8a5ec059b829ed135e9ad8607977691cc` are deleted.
 
    .EXAMPLE
    Remove-CHttpsCertificateBinding -ApplicationID 'd27985ca-2fa5-4794-9a87-76de4ed7d3e8'
 
    Demonstrates how to remove all HTTPS certificate bindings for a specific application by passing the application ID
    to the `ApplicationID` parameter. In this example, all bindings for application
    `d27985ca-2fa5-4794-9a87-76de4ed7d3e8` will be removed.
 
    .EXAMPLE
    Get-CHttpsCertificateBinding -ApplicationID 'd27985ca-2fa5-4794-9a87-76de4ed7d3e8' | Remove-CHttpsCertificateBinding
 
    Demonstrates that you can pipe the output of `Get-CHttpsCertificateBinding` to `Remove-CHttpsCertificateBinding` to
    remove bindings.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The IP address whose bindings to remove.
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ipaddress] $IPAddress,

        # The port of the bindings to remove.
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [UInt16] $Port,

        # The thumbprint whose bindings to remove.
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('CertificateHash')]
        [String] $Thumbprint,

        # The application whose bindings to remove.
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Guid] $ApplicationID,

        # If calling `Remove-CHttpsCertificateBinding` with no arguments, the function prompts for confirmation to delete
        # all bindings. Use this switch to skip the confirmation prompt.
        [switch] $Force
    )

    process
    {
        Set-StrictMode -Version 'Latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

        if (-not $Force -and (-not $IPAddress -and -not $Port -and -not $Thumbprint -and -not $ApplicationID))
        {
            $query = 'Delete all HTTPS certificate bindings on this computer?'
            $caption = 'If you choose Yes, *all* HTTPS certificates will be deleted on this computer. This will ' +
                       'break any HTTPS applications. If you choose No, no changes will be made. To delete all ' +
                       'bindings without being prompted to confirm, use the Force (switch).'
            if (-not $PSCmdlet.ShouldContinue($query, $caption))
            {
                return
            }
        }

        $getArgs = @{}
        if ($IPAddress)
        {
            $getArgs['IPAddress'] = $IPAddress
        }

        if ($Port)
        {
            $getArgs['Port'] = $Port
        }

        if ($Thumbprint)
        {
            $getArgs['Thumbprint'] = $Thumbprint
        }

        if ($ApplicationID)
        {
            $getArgs['ApplicationID'] = $ApplicationID
        }

        $foundOne = $false
        foreach ($binding in (Get-CHttpsCertificateBinding @getArgs -ErrorAction Ignore))
        {
            $foundOne = $true
            if( $binding.IPAddress.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetworkV6 )
            {
                $ipPort = '[{0}]:{1}' -f $binding.IPAddress,$binding.Port
            }
            else
            {
                $ipPort = '{0}:{1}' -f $binding.IPAddress,$binding.Port
            }

            $target = "$($ipPort) that uses certificate $($binding.CertificateHash) for application " +
                    "$($binding.ApplicationID.ToString('B'))."
            Invoke-Netsh http delete sslcert "ipPort=$($ipPort)" `
                         -Comment "certhash=$($binding.CertificateHash) appid=$($binding.ApplicationID.ToSTring('B'))" `
                         -Target $target `
                         -Action "removing HTTPS certificate binding"
        }

        if ($foundOne)
        {
            return
        }

        $ipMsg = '0.0.0.0'
        if ($IPAddress)
        {
            $ipMsg = "$($IPAddress.IPAddressToString)"
            if ($IPAddress.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetworkV6)
            {
                $ipMsg = "[$($ipMsg)]"
            }
        }

        $portMsg = '*'
        if ($Port)
        {
            $portMsg = $Port
        }
        $ipMsg = "$($ipMsg):$($portMsg)"

        $thumbprintMsg = ''
        if ($Thumbprint)
        {
            $thumbprintMsg = " that uses certificate with thumbprint $($Thumbprint)"
        }

        $appIdMsg = ''
        if ($ApplicationID)
        {
            $appIdMsg = " for application $($ApplicationID.ToString('B'))"
        }

        "Unable to delete HTTPS certificate binding $($ipMsg)$($thumbprintMsg)$($appIdMsg) because it does not exist." |
            Write-Error -ErrorAction $ErrorActionPreference
    }
}




function Set-CHttpsCertificateBinding
{
    <#
    .SYNOPSIS
    Creates or updates an HTTPS certificate binding.
 
    .DESCRIPTION
    The `Set-CHttpsCertificateBinding` creates an HTTPS certificate binding. Pass the IP address of the binding to the
    `IPAddress` parameter. Pass the port number of the binding to the `Port` parameter. Pass the certificate thumbprint
    for the binding to the `Thumbprint` parameter. Pass the application ID of the binding to the `ApplicationID`
    parameter. Only one binding is allowed per IP address and port. If a binding exists on the given IP address and
    port that doesn't match the given application ID and certificate, the existing binding is removed, and a new
    binding is created.
 
    If you want an object representing the binding to be returned, use the `PassThru` switch.
 
    Uses the `netsh http add sslcert` command.
 
    .OUTPUTS
    Carbon.Windows.HttpServer.HttpsCertificateBinding.
 
    .EXAMPLE
    Set-CHttpsCertificateBinding -IPAddress 43.27.89.54 -Port 443 -ApplicationID 88d1f8da-aeb5-40a2-a5e5-0e6107825df7 -Thumbprint 4789073458907345907434789073458907345907
 
    Configures the computer to use the 478907345890734590743 certificate on IP 43.27.89.54, port 443.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSShouldProcess', '')]
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([Carbon.Windows.HttpServer.HttpsCertificateBinding])]
    param(
        # The IP address for the binding.
        [Parameter(Mandatory)]
        [ipaddress] $IPAddress,

        # The port for the binding.
        [Parameter(Mandatory)]
        [UInt16] $Port,

        # The thumbprint of the certificate to use. The certificate must be installed.
        [Parameter(Mandatory)]
        [ValidatePattern("^[0-9a-f]{40}$")]
        [String] $Thumbprint,

        # A unique ID representing the application using the binding. Create your own.
        [Parameter(Mandatory)]
        [Guid] $ApplicationID,

        # The name of the store where the certificate can be found. Defaults to `My`. Certificates must be stored in
        # the LocalMachine location/context.
        [String] $StoreName = 'My',

        # Return a `Carbon.Windows.HttpServer.HttpsCertificateBinding` for the configured binding.
        [switch] $PassThru
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

    # Only one binding can exist on an IP address and port.
    $bindingExists = Test-CHttpsCertificateBinding -IPAddress $IPAddress -Port $Port
    if ($bindingExists)
    {
        # If the existing binding is for the same application using the same thumbprint, we don't need to do anything.
        $bindingExists = Test-CHttpsCertificateBinding -IPAddress $IPAddress `
                                                     -Port $Port `
                                                     -Thumbprint $Thumbprint `
                                                     -ApplicationID $ApplicationID
        if ($bindingExists)
        {
            return
        }

        Remove-CHttpsCertificateBinding -IPAddress $IPAddress -Port $Port
    }

    if( $IPAddress.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetworkV6 )
    {
        $ipPort = '[{0}]:{1}' -f $IPAddress,$Port
    }
    else
    {
        $ipPort = '{0}:{1}' -f $IPAddress,$Port
    }

    $appID = $ApplicationID.ToString('B')

    Invoke-Netsh http add sslcert ipport=$ipPort certhash=$Thumbprint appid=$appID certstore=$StoreName `
                 -Target $ipPort `
                 -Action 'creating HTTPS certificate binding'

    if( $PassThru )
    {
        $errorActionArg = @{}
        if ($WhatIfPreference)
        {
            $errorActionArg['ErrorAction'] = 'Ignore'
        }
        Get-CHttpsCertificateBinding -IPAddress $IPAddress -Port $Port @errorActionArg
    }
}




function Test-CHttpsCertificateBinding
{
    <#
    .SYNOPSIS
    Tests if an HTTPS certificate binding exists.
 
    .DESCRIPTION
    The `Test-CHttpsCertificateBinding` tests if an HTTPS certificate binding exists. You can check if a binding exists
    by passing an IP address, port, certificate thumbprint, and/or application ID to the `IPAddress`, `Port`,
    `Thumbprint`, and `ApplicationID` parameters, respectively. If a cert exists that matches all the criteria you
    pass, the function returns `$true`, otherwise it returns `$false`. If you pass no arguments, the function tests if
    *any* bindings exist.
 
    .EXAMPLE
    Test-CHttpsCertificateBinding -Port 443
 
    Tests if there are any bindings on port 443.
 
    .EXAMPLE
    Test-CHttpsCertificateBinding -IPAddress 10.0.1.1
 
    Tests if there are any bindings on IP address `10.0.1.1`.
 
    .EXAMPLE
    Test-CHttpsCertificateBinding -Thumbprint '7d5ce4a8a5ec059b829ed135e9ad8607977691cc'
 
    Tests if there are any bindings to certificate with thumbprint `7d5ce4a8a5ec059b829ed135e9ad8607977691cc`.
 
    .EXAMPLE
    Test-CHttpsCertificateBinding -ApplicationID '71740b45-ea65-48c4-a8bd-6f2110c52ba7'
 
    Tests if there are any bindings for application whose ID is `71740b45-ea65-48c4-a8bd-6f2110c52ba7`.
 
    .EXAMPLE
    Test-CHttpsCertificateBinding
 
    Tests if there are any bindings on the machine.
    #>

    [CmdletBinding()]
    param(
        # The IP address.
        [ipaddress] $IPAddress,

        # The port.
        [Uint16] $Port,

        # The certificate thumbprint.
        [String] $Thumbprint,

        # The application ID
        [Guid] $ApplicationID
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

    $getArgs = @{ }
    if ($IPAddress)
    {
        $getArgs['IPAddress'] = $IPAddress
    }

    if ($Port)
    {
        $getArgs['Port']= $Port
    }

    if ($Thumbprint)
    {
        $getArgs['Thumbprint'] = $Thumbprint
    }

    if ($ApplicationID)
    {
        $getArgs['ApplicationID'] = $ApplicationID
    }

    $binding = Get-CHttpsCertificateBinding @getArgs -ErrorAction Ignore

    if ($binding)
    {
        return $true
    }

    return $false
}




function Use-CallerPreference
{
    <#
    .SYNOPSIS
    Sets the PowerShell preference variables in a module's function based on the callers preferences.
 
    .DESCRIPTION
    Script module functions do not automatically inherit their caller's variables, including preferences set by common
    parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't
    get passed into any function that belongs to a module.
 
    When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the
    function's caller:
 
     * ErrorAction
     * Debug
     * Confirm
     * InformationAction
     * Verbose
     * WarningAction
     * WhatIf
     
    This function should be used in a module's function to grab the caller's preference variables so the caller doesn't
    have to explicitly pass common parameters to the module function.
 
    This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d).
 
    There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that
    causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add
    explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get
    fixed.
 
    .LINK
    about_Preference_Variables
 
    .LINK
    about_CommonParameters
 
    .LINK
    https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
 
    .LINK
    http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/
 
    .EXAMPLE
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
    Demonstrates how to set the caller's common parameter preference variables in a module function.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        #[Management.Automation.PSScriptCmdlet]
        # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]`
        # attribute.
        $Cmdlet,

        [Parameter(Mandatory)]
        # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the
        # `[CmdletBinding()]` attribute.
        #
        # Used to set variables in its callers' scope, even if that caller is in a different script module.
        [Management.Automation.SessionState]$SessionState
    )

    Set-StrictMode -Version 'Latest'

    # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken
    # from about_CommonParameters).
    $commonPreferences = @{
                              'ErrorActionPreference' = 'ErrorAction';
                              'DebugPreference' = 'Debug';
                              'ConfirmPreference' = 'Confirm';
                              'InformationPreference' = 'InformationAction';
                              'VerbosePreference' = 'Verbose';
                              'WarningPreference' = 'WarningAction';
                              'WhatIfPreference' = 'WhatIf';
                          }

    foreach( $prefName in $commonPreferences.Keys )
    {
        $parameterName = $commonPreferences[$prefName]

        # Don't do anything if the parameter was passed in.
        if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) )
        {
            continue
        }

        $variable = $Cmdlet.SessionState.PSVariable.Get($prefName)
        # Don't do anything if caller didn't use a common parameter.
        if( -not $variable )
        {
            continue
        }

        if( $SessionState -eq $ExecutionContext.SessionState )
        {
            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
        }
        else
        {
            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
        }
    }
}