Public/Start-HealthZListener.ps1

function Start-HealthZListener {
    <#
    .SYNOPSIS
        Starts a PSHealthZ listener instance.
    .DESCRIPTION
        Starts a PSHealthZ listener instance.
    .PARAMETER Port
        The port the listener will listen on.
    .PARAMETER Path
        The URl path to listen on (e.g., '/health').
    .PARAMETER Token
        The authentication token to secure the endpoint with.
        If specified, the authentication scheme will be set to anonymous.
    .PARAMETER LogDir
        The log directory the listener will write to. A file with the listener instance Id will be created.
    .PARAMETER UseSSL
        Switch to indicate that a SSL certificate will be used. If specified the certificate thumbprint MUST be provided via the CertificateThumbprint parameter.
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use when using SSL. This parameter MUST be provided if using SSL. The certificate MUST be in the LocalMachine\My certificate store.
    .PARAMETER PassThru
        Return the newly created listener object.
    .EXAMPLE
        Start-HealthZListener
 
        Start a new PSHealthZ listener will default values.
    .EXAMPLE
        Start-HealthZListener -Port 8888 -Path myhealthendpoint
 
        Start a new PSHealthZ listener on a custom port and path.
    .EXAMPLE
        $token = New-HealthZToken
        $listener = Start-HealthZListener -Token $token -PassThru
        $testResults = Invoke-RestMethod -Uri "http://localhost:1938/health?token=$token?module=*"
 
        Setup the listener with token authentication and execute all available tests.
    .EXAMPLE
        $thumbprint = '7D85481FE7D35AC4306AF2C4281879B73701D001'
        $listener = Start-HealthZListener -Port 8443 -UseSSL -CertificateThumbprint $thumbprint -PassThru
 
        Start a new PSHealthZ listener using SSL on port 8443.
    #>

    [OutputType([pscustomobject])]
    [cmdletbinding(SupportsShouldProcess)]
    param(
        [int]$Port = 1938,

        [string]$Path = 'health/',

        [string]$Token,

        [string]$LogDir = (Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'PSHealthZ'),

        [switch]$UseSSL,

        [ValidatePattern("^[0-9a-f]{40}$")]
        [string]$CertificateThumbprint,

        [switch]$PassThru
    )

    begin {

        # Fail fast if on *nix and trying to use SSL
        # This should be added to a later release
        if ($UseSSL.IsPresent -and ($IsMacOS -or $IsLinux)) {
            throw 'Setting up a HTTPS listener on macOS or Linux is currently not supported :('
            return
        }

        #*nix method to get open
        function GetNixTcpPorts([string]$Port = '*') {
            $connections = lsof -i -P -n | grep LISTEN
            $ports = foreach ($conn in $connections) {
                $data = $conn -Split '\s+', 9
                $props = [ordered]@{
                    Command    = $data[0]
                    PID        = $data[1]
                    User       = $data[2]
                    FD         = $data[3]
                    Type       = $data[4]
                    Device     = $data[5]
                    SizeOffset = $data[6]
                    Node       = $data[7]
                    Name       = $data[8]
                    Port       = $null
                }
                if ($data[8] -like '*]:*') {
                    $props.Port = $data[8].Split(']:')[1].Split(' ')[0]
                } else {
                    $props.Port = $data[8].Split(':')[1].Split(' ')[0]
                }
                [pscustomobject]$props
            }
            $ports | Where-Object {$_.Port -like $Port}
        }

        # Validate that desired port is not already in use
        if ($IsMacOS -or $IsLinux) {
            if (GetNixTcpPorts -Port $Port) {
                throw "Port $Port is already in use!"
                return
            }
        } else {
            if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) {
                throw "Port $Port is already in use!"
                return
            }
        }

        # Always use anonymous authentication.
        # If token provided, we'll validate at that level
        $Auth = [System.Net.AuthenticationSchemes]::Anonymous
    }

    process {
        # Make sure path always ends with '/'
        if (($Path.Length -gt 0) -and (-not $Path.EndsWith('/'))) {
            $Path += '/'
        }

        # HTTP or HTTPS
        $protocol = 'http'
        if ($PSBoundParameters.ContainsKey('UseSSL')) {
            if (-not $PSBoundParameters.ContainsKey('CertificateThumbprint')) {
                throw 'A certificate thumbprint MUST be provided when using SSL.'
            }
            $protocol = 'https'
        }

        # Determine final endpoint URL
        if ($Path -eq '/') {
            $endpoint = "$($protocol)://*:$Port/"
        } else {
            $endpoint = "$($protocol)://*:$Port/$Path"
        }

        if ($PSCmdlet.ShouldProcess($endpoint, 'Start PSHealthZ listener')) {

            # Create log directory
            if (-not (Test-Path -Path $LogDir)) {
                New-Item -Path $LogDir -ItemType Directory -Force -Verbose:$false | Out-Null
            }

            $listenerScript = {
                #[cmdletbinding(DefaultParameterSetName = 'auth')]
                [cmdletbinding()]
                param(
                    [parameter(Mandatory)]
                    [int]$Port,

                    [parameter(Mandatory)]
                    [string]$Path,

                    [parameter(Mandatory)]
                    [string]$Endpoint,

                    [System.Net.AuthenticationSchemes]$Auth,

                    [string]$Token,

                    [bool]$UseSSL,

                    [string]$CertificateThumbprint,

                    [parameter(Mandatory)]
                    [string]$LogDir,

                    [parameter(Mandatory)]
                    [guid]$InstanceId
                )

                $VerbosePreference = 'Continue'

                $listener = New-Object System.Net.HttpListener

                $requestListener = {
                    [cmdletbinding()]
                    param($result)

                    [System.Net.HttpListener]$listener = $result.AsyncState;

                    # Call EndGetContext to complete the asynchronous operation.
                    $context = $listener.EndGetContext($result);

                    # Hand off the Context to the handler, it is in charge of responding.
                    & $requestProcessor $context

                    $listener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), $listener)
                }

                $requestProcessor = {
                    [cmdletbinding()]
                    param(
                        $Context
                    )

                    $request = $Context.Request
                    Write-Log -Message "[Listener] Received request from $($context.Request.RemoteEndPoint)"
                    Write-Log -Message "Request details:`n$($request | Format-List -Property * | Out-String)"

                    $buffer = $null
                    $cmdResponse = [string]::Empty
                    $response = $context.Response
                    $statusCode = [System.Net.HttpStatusCode]::OK
                    $continue = $false

                    # Authorize request if using tokens
                    if ($null -ne $Token -and $Token -ne [string]::Empty) {
                        Write-Log -Message 'Inspecting token...'

                        # Check for token in headers or query string
                        if ($request.Headers['token'] -or ($request.QueryString.HasKeys() -and $request.QueryString.Item('token'))) {

                            # Validate token
                            if (Validate-Token -Request $request -Token $Token) {
                                Write-Log -Message 'Token matches'
                                $continue = $true
                            } else {
                                $msg = 'Token provided does not match server'
                                Write-Log -Message $msg
                                $cmdResponse = $msg
                                $statusCode = [System.Net.HttpStatusCode]::Unauthorized
                            }
                        } else {
                            $msg = 'Authorization token not provided in request. Sending 403.'
                            Write-Log -Message $msg
                            $cmdResponse = $msg
                            $statusCode = [System.Net.HttpStatusCode]::Unauthorized
                        }
                    } else {
                        $statusCode = [System.Net.HttpStatusCode]::OK
                        $continue = $true
                    }

                    # Requested test to execute
                    if ($continue) {
                        $test = $null
                        $module = $null
                        if ($request.QueryString.HasKeys()) {
                            $test = $request.QueryString.Item('test')
                            $module = $request.QueryString.Item('module')
                        }
                        try {
                            $invokeOVFParams = @{}
                            if ($test) {
                                $invokeOVFParams.Test = $test
                            }
                            if ($module) {
                                $invokeOVFParams.Module = $module
                            }
                            if ($test -or $Module) {
                                Write-Log -Message "Requesting test [$test] in module [$Module]"
                            } else {
                                Write-Log -Message 'No specific test requested. Listing all available tests.'
                            }
                            $commandOutput = Invoke-Ovf @invokeOVFParams
                        } catch {
                            $commandOutput = $_ | ConvertTo-Json
                            $statusCode = [System.Net.HttpStatusCode]::InternalServerError
                        }
                        $cmdResponse = $commandOutput | ConvertTo-Json
                    }

                    # Setup response
                    $response.StatusCode = $statusCode
                    $response.ContentType = 'application/json'
                    $buffer = [System.Text.Encoding]::UTF8.GetBytes($cmdResponse)
                    $response.ContentLength64 = $buffer.Length
                    Write-Log -Message "Response: $($response | Format-List -Property * | Out-String)"

                    # Return response
                    $output = $response.OutputStream
                    $output.Write($buffer,0,$buffer.Length)
                    $output.Close()
                    $response.Close()
                }

                # Run a scriptblock from an .NET async callback via events
                # Thanks to http://poshcode.org/1382
                function New-ScriptBlockCallback {
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
                    param(
                        [parameter(Mandatory)]
                        [ValidateNotNullOrEmpty()]
                        [scriptblock]$Callback
                    )

                    # Is this type already defined?
                    if (-not ( 'CallbackEventBridge' -as [type])) {
                        Add-Type @'
                            using System;
 
                            public sealed class CallbackEventBridge {
                                public event AsyncCallback CallbackComplete = delegate { };
 
                                private CallbackEventBridge() {}
 
                                private void CallbackInternal(IAsyncResult result) {
                                    CallbackComplete(result);
                                }
 
                                public AsyncCallback Callback {
                                    get { return new AsyncCallback(CallbackInternal); }
                                }
 
                                public static CallbackEventBridge Create() {
                                    return new CallbackEventBridge();
                                }
                            }
'@

                    }
                    $bridge = [callbackeventbridge]::create()
                    Register-ObjectEvent -InputObject $bridge -EventName callbackcomplete -Action $Callback -MessageData $args > $null
                    $bridge.Callback
                }

                # Start HTTP(s) listener
                function Start-Server {
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
                    [cmdletbinding()]
                    param()

                    try {
                        Write-Log -Message "Prefix: $Endpoint"
                        $listener.Prefixes.Add($Endpoint)
                        $listener.AuthenticationSchemes = $Auth
                        if ($null -ne $Token) {
                            Write-Log -Message "Starting HTTP(s) server listening at [$Endpoint] with [token-based] authentication..."
                        } else {
                            Write-Log -Message "Starting HTTP(s) server listening at ]$Endpoint] with [$Auth] authentication..."
                        }
                        $listener.Start()

                        if ($UseSSL) {
                            Register-SSL
                        }

                        # Register the request listener scriptblock as the async callback
                        $listener.BeginGetContext((New-ScriptBlockCallback -Callback $requestListener), $listener) | Out-Null
                    } catch {
                        throw 'There were problems setting up the HTTP(s) listener.'
                        Stop-Server
                    }
                }

                # Stop HTTP(s) listener
                function Stop-Server {
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
                    [cmdletbinding()]
                    param()

                    Write-Log -Message 'Finished listening for requests. Shutting down HTTP server.'

                    # Remove SSL Binding
                    if ($UseSSL) {
                        $ipPort = "0.0.0.0:$Port"
                        Invoke-ConsoleCommand -Target $ipPort -Action 'removing SSL certificate binding' -ScriptBlock {
                            netsh http delete sslcert ipPort="$ipPort"
                        }
                        #netsh http delete sslcert ipport="0.0.0.0:$Port"
                    }

                    $listener.Close()
                    exit 0
                }

                # Bind SSL cert to listener port
                function Register-SSL {
                    [cmdletbinding()]
                    param()

                    begin {
                        $cert = Get-ChildItem -Path 'Cert:\LocalMachine\My' -Recurse  | Where-Object { $_.Thumbprint -eq $CertificateThumbprint }
                        if (-not $cert) {
                            throw "Unable to find certificate with thumbprint [$CertificateThumbprint]"
                        }
                    }

                    process {
                        try {
                            $ipPort = "0.0.0.0:$Port"

                            # Remove Previous SSL Bindings
                            Write-Log -Message "Removing any previous SSL bindings on [$ipPort]"
                            Invoke-ConsoleCommand -Target $ipPort -Action 'removing SSL certificate binding' -ScriptBlock {
                                netsh http delete sslcert ipPort="$ipPort"
                            }
                            #netsh http delete sslcert ipport="$ipPort"

                            # Add SSL Certificate
                            Write-Log -Message "Binding certificate [$CertificateThumbprint] to [$ipPort]"
                            Invoke-ConsoleCommand -Target $ipPort -Action 'creating SSL certificate binding' -ScriptBlock {
                                netsh http add sslcert ipport="$ipPort" certhash="$CertificateThumbprint" appid="{$InstanceId}"
                            }
                            #netsh http add sslcert ipport="$ipPort" certhash="$CertificateThumbprint" appid="{$InstanceId}"
                        } catch {
                            $msg = "Unable to bind SSL Certificate to port [$Port]. $($_.Exception.Message)"
                            Write-Log -Message $msg
                            throw $msg
                        }
                    }
                }

                # Invoke a console command and capture exit code
                function Invoke-ConsoleCommand {
                    [CmdletBinding(SupportsShouldProcess)]
                    param(
                        [Parameter(Mandatory)]
                        [string]$Target,

                        [Parameter(Mandatory)]
                        [string]$Action,

                        [Parameter(Mandatory)]
                        [scriptblock]$ScriptBlock
                    )

                    Set-StrictMode -Version 'Latest'

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

                    $output = Invoke-Command -ScriptBlock $ScriptBlock
                    if ($LASTEXITCODE) {
                        $output = $output -join [Environment]::NewLine
                        Write-Error ('Failed action ''{0}'' on target ''{1}'' (exit code {2}): {3}' -f $Action,$Target,$LASTEXITCODE,$output)
                    } else {
                        $output | Where-Object { $_ -ne $null } | Write-Verbose
                    }
                }

                # Execute specified OVF tests and return results
                function Invoke-Ovf {
                    [cmdletbinding()]
                    param(
                        [string]$Test = '*',
                        [string]$Module = '*'
                    )

                    $ovfModuleNames = @('OperationValidation')

                    # Track duration of testing
                    $sw = [System.Diagnostics.Stopwatch]::StartNew()

                    Import-Module -Name 'OperationValidation' -Verbose:$false -ErrorAction Stop

                    $resp = [ordered]@{
                        success = $true
                        time = (get-date).ToUniversalTime().ToString('u')
                        timeElapsedMS = $null
                        message = ''
                        availableTests = @()
                        testResults = @()
                        failedTests = @()
                    }

                    try {
                        # Get OVF tests on the system
                        $filter = {
                            $leaf = Split-Path -Path $_.ModuleName -Leaf
                            if ($leaf -as [Version]) {
                                $moduleName = Split-Path -Path (Split-Path -Path $_.ModuleName -Parent) -Leaf
                            } else {
                                $moduleName = $leaf
                            }
                            $moduleName -notin $ovfModuleNames
                        }
                        $ovfTests = OperationValidation\Get-OperationValidation -ModuleName $Module -Verbose:$false -ErrorAction SilentlyContinue |
                            Where-Object $filter |
                            Where-Object {$_.Name -like $Test}
                        $resp.availableTests = $ovfTests | ForEach-Object {
                            $r = [ordered]@{
                                name = $_.Name
                            }
                            $leaf = Split-Path -Path $_.ModuleName -Leaf
                            if ($leaf -as [Version]) {
                                $r.module = Split-Path -Path (Split-Path -Path $_.ModuleName -Parent) -Leaf
                            } else {
                                $r.module = $leaf
                            }
                            [pscustomobject]$r
                        }

                        if ($PSBoundParameters.ContainsKey('Test') -or $PSBoundParameters.ContainsKey('Module')) {

                            # Execute the Pester/OVF tests
                            Import-Module -Name Pester -Verbose:$false -ErrorAction Stop
                            Write-Log -Message "Executing tests: `n$($ovfTests.Name)"
                            $ovfResults = $ovfTests | Where-Object Name -like $Test | OperationValidation\Invoke-OperationValidation -Verbose:$false -ErrorAction SilentlyContinue
                            $resp.success = @($ovfResults | Where-Object Result -like 'Failed').Count -eq 0

                            # All test results
                            $resp.testResults = $ovfResults | ForEach-Object {
                                [pscustomobject]@{
                                    test = $_.RawResult.Name
                                    module = $_.Module
                                    passed = $_.RawResult.Passed
                                    result = $_.Result
                                    describe = $_.RawResult.Describe
                                    context = $_.RawResult.Context
                                    file = $_.FileName
                                    message = $_.RawResult.FailureMessage
                                    duration = $_.RawResult.Time.ToString()
                                }
                            }

                            # Only failed test results
                            $resp.failedTests = $ovfResults | Where-Object {$_.RawResult.Passed -eq $false } | ForEach-Object {
                                [pscustomobject]@{
                                    test = $_.RawResult.Name
                                    module = $_.Module
                                    passed = $_.RawResult.Passed
                                    result = $_.Result
                                    describe = $_.RawResult.Describe
                                    context = $_.RawResult.Context
                                    file = $_.FileName
                                    message = $_.RawResult.FailureMessage
                                    duration = $_.RawResult.Time.ToString()
                                }
                            }
                        } else {
                            $resp.message = "PSHealthZ responds but does not execute tests without being told to. Add query parameter '?test=<testname>' and/or '?module=<modulename>' to execute a specific tests. Available tests are listed in the 'availableTests' property of this response. Use '?test=*' or '?module=*' to execute all available tests regardless of module. Specific tests can be executed by filtering with the 'test' and 'module' query parameters."
                            $resp.success = $true
                        }
                    } catch {
                        $resp.success = $false
                        $resp.message = $_
                    }

                    # Add time it took to execute test(s)
                    $sw.Stop()
                    $resp.timeElapsedMS = $sw.Elapsed.TotalMilliseconds

                    $o = [pscustomobject]$resp
                    Write-Log -Message "Test results:`n$($o | Format-List -Property * | Out-String)"
                    return $o
                }

                # Validate provided token from HTTP request against what
                # is configured in the listener
                function Validate-Token {
                    param(
                        $Request,
                        $Token
                    )

                    # Token could be in headers or query string
                    if ($Request.Headers['token']) {
                        $providedToken = $Request.Headers['token']
                    } else {
                        $providedToken = $Request.QueryString.Item('token')
                    }

                    return ($providedToken -ceq $Token)
                }

                function Write-Log {
                    [cmdletbinding()]
                    param(
                        [object]$Message
                    )

                    $logsToKeep = 5
                    $maxLogSize = 1

                    $now = (Get-Date).ToString('yyyy-MM-dd hh:mm:ss')
                    $log = Join-Path -Path $LogDir -ChildPath "$($InstanceId).log"

                    # Roll log if necessary
                    if (Test-Path -Path $log) {
                        if ((($file = Get-Item -Path $log) -and ($file.Length/1mb) -gt $maxLogSize)) {
                            # Remove the last item if it would go over the limit
                            if (Test-Path -Path "$log.$logsToKeep") {
                                Remove-Item -Path "$log.$logsToKeep"
                            }
                            foreach ($i in $($logsToKeep)..1) {
                                if (Test-path -Path "$log.$($i-1)") {
                                    Move-Item -Path "$log.$($i-1)" -Destination "$log.$i"
                                }
                            }
                            Move-Item -Path $log -Destination "$log.$i"
                            New-Item -Path $log -Type File -Force | Out-Null
                        }
                    }

                    Write-Verbose -Message $Message
                    "[$now] $Message" | Out-File -FilePath $log -Encoding utf8 -Append -Force
                }

                Start-Server
                while($true) {
                    Start-Sleep -Milliseconds 100
                }
            }

            # Start the listener
            $instanceId = New-Guid
            $jobParams = @{
                Name = "PSHealthZHTTPListerner_$InstanceId"
                ScriptBlock = $listenerScript
                ArgumentList = @($Port, $Path, $endpoint, $Auth, $Token, $PSBoundParameters.ContainsKey('UseSSL'), $CertificateThumbprint, $LogDir, $instanceId)
            }
            $job = Start-Job @jobParams

            if ($null -ne $Token) {
                Write-Verbose -Message "PSHealthZ HTTP listener starting at [$endpoint] with [token-based] authentication"
            } else {
                Write-Verbose -Message "PSHealthZ HTTP listener starting at [$endpoint] with [$Auth] authentication"
            }
            Write-Verbose -Message "Job Id: $($job.Id)"
            Write-Verbose -Message "To stop the listener run: Stop-HealthZListener -Id $($job.Id)"

            # Track the listener
            $listenerTracker = @{
                jobId = $job.Id
                port = $Port
                path = $Path
                uri = $endpoint
                ssl = $PSBoundParameters.ContainsKey('UseSSL')
                certificateThumbprint = $CertificateThumbprint
                auth = $Auth
                log = (Join-Path -Path $LogDir -ChildPath "$($instanceId).log")
                instanceId = $instanceId
            }
            if ($PSBoundParameters.ContainsKey('Token')) {
                $listenerTracker.token = $Token
            }
            $script:httpListeners.Add($job.Id, $listenerTracker)

            # Return listener object if told to
            if ($PSBoundParameters.ContainsKey('PassThru')) {
                return Get-HealthZListener -Id $job.Id
            }
        }
    }
}