Public/Invoke-HttpChallengeListener.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
function Invoke-HttpChallengeListener {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('PoshACME.PAAuthorization')]
    param (
        [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('domain', 'fqdn')]
        [string]$MainDomain,
        [Parameter()]
        [Alias('TTL')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$ListenerTimeout = 120,
        [Parameter()]
        [ValidateRange(1,65535)]
        [int]$Port,
        [Parameter()]
        [string[]]$ListenerPrefixes
    )

    Begin {

        # Make sure we have an account configured
        if (-not ($acct = Get-PAAccount)) {
            throw "No ACME account configured. Run Set-PAAccount or New-PAAccount first."
        }

        # account present, lets start
        # if ListenerTimeout is set to zero, write a warning
        if ($ListenerTimeout -eq 0) {
            Write-Warning 'ListenerTimeout is set to 0. If domain can''t be validated, listener will run indefinitely or until manually stopped.'
        }

        # set port suffix for http listener
        $portSuffix = if ($Port) { ":$Port" } else { [string]::Empty }

        # set TTL to at least 6 seconds to be sure at least one validation check can be executed
        if ($ListenerTimeout -ne 0 -and $ListenerTimeout -lt 6) {
            Write-Warning ('Set ListenerTimeout from {0} to 6 seconds so validation check will be executed at least once' -f $ListenerTimeout)
            $ListenerTimeout = 6
        }
    }

    Process {

        # init prevRuntime
        $prevRunTime = 0

        # get a reference to the order we're going to use
        if (-not $MainDomain) {
            # grab the current order and set $MainDomain
            $order = Get-PAOrder
            $MainDomain = $order.MainDomain
        }
        else {
            # try to get the order specified by $MainDomain
            if (-not ($order = Get-PAOrder -MainDomain $MainDomain)) {
                throw "No order found for domain $MainDomain"
            }
        }

        # get pending authorizations for the order
        $openAuthorizations = @($order | Get-PAAuthorizations -Verbose:$false |
            Where-Object { $_.status -eq 'pending' -and $_.HTTP01Status -eq 'pending' })

        # return if there's nothing to do
        if ($openAuthorizations.Count -eq 0) {
            Write-Warning "No pending authorizations found for Domain `"$MainDomain`""
            return
        }
        Write-Verbose ('Authorizations found with HTTP01Status pending: {0}' -f $openAuthorizations.Count)

        # create array with all necessary information for http listener
        $httpPublish = @( $openAuthorizations | Select-Object `
            'fqdn',
            'HTTP01Url',
            'HTTP01Token',
            @{L = 'subUrl'; E = { ('/.well-known/acme-challenge/{0}' -f $_.HTTP01Token) } },
            @{L = 'Body'; E = { Get-KeyAuthorization $_.HTTP01Token $acct } }
        )

        # initialize and start WebServer
        try {
            # create http listener
            $httpListener = [System.Net.HttpListener]::new()

            # add listener prefix(es)
            if (-not $ListenerPrefixes) {
                $prefix = 'http://+{0}/.well-known/acme-challenge/' -f $portSuffix
                Write-Verbose "Adding listener prefix $prefix"
                $httpListener.Prefixes.Add($prefix)
            }
            else {
                foreach ($prefix in $ListenerPrefixes) {
                    Write-Verbose "Adding listener prefix $prefix"
                    $httpListener.Prefixes.Add($prefix)
                }
            }

            # start the listener
            $httpListener.Start()
            $startTime = Get-Date
            Write-Verbose ('HttpListener started with {0} second timeout' -f $ListenerTimeout)
        }
        catch { throw }

        # time to interact with the listener
        try {
            # inform ACME server that challenge is ready
            foreach ($pub in $httpPublish) {
                Write-Verbose ('Send-ChallengeAck for {0}' -f $pub.fqdn)
                Write-Debug (' {0}' -f $pub.HTTP01Url)
                if ($PSCmdlet.ShouldProcess($pub.fqdn, "Send-ChallengeAck")) {
                    Send-ChallengeAck $pub.HTTP01Url -Account $acct -Verbose:$false
                }
            }

            # enter listening loop
            while ($httpListener.IsListening) {

                # get context async so we can do other logic while listener is running
                $contextTask = $httpListener.GetContextAsync()

                # other logic
                while (-not $contextTask.AsyncWaitHandle.WaitOne(200)) {

                    # get runtime in seconds
                    $runTime = [Math]::Round( ((Get-Date) - $startTime).TotalSeconds, 0)

                    # process timeout - if timeout is 0 server runs until challenge is valid
                    if ($ListenerTimeout -ne 0 -and $runTime -ge $ListenerTimeout) {
                        Write-Verbose 'timeout reached, stopping HttpListener'
                        $httpListener.Stop()
                        return
                    }

                    # check challenge state every 5 seconds
                    if ($prevRunTime -ne $runTime -and $runTime % 5 -eq 0) {

                        $prevRunTime = $runTime
                        Write-Verbose 'Checking authorization status'

                        # check if the published authorizations are no longer pending
                        # valid or invalid doesn't matter because we can't retry, so there's no need to wait longer
                        $completeAuths = @( $order | Get-PAAuthorizations -Verbose:$false |
                            Where-Object { $_.fqdn -in $httpPublish.fqdn -and $_.status -ne 'pending' } )

                        if ($completeAuths.Count -eq $httpPublish.Count) {
                            Write-Verbose 'No pending authorizations remaining, stopping HttpListener'
                            $httpListener.Stop()
                            return
                        }
                    }
                }

                # get actual request context
                $context = $contextTask.GetAwaiter().GetResult()

                # deal with X-Forwarded-For header to get proper remote IP
                # for servers behind load balancers or reverse proxies
                $remoteIP = $context.Request.RemoteEndPoint.Address.ToString()
                if ($context.Request.Headers['X-Forwarded-For']) {
                    $remoteIP = $context.Request.Headers['X-Forwarded-For']
                }

                # short - if requested url matches answer
                if ($context.Request.HttpMethod -eq 'GET' -and $context.Request.RawUrl -in $httpPublish.subUrl) {
                    $responseData = $httpPublish | Where-Object { $_.subUrl -eq $context.Request.RawUrl }

                    # verbose out response
                    Write-Verbose ('Responding to {0} for {1}' -f $remoteIP, $responseData.fqdn)
                    Write-Debug (' {0}' -f $responseData.Body )
                    #respond to the request
                    $context.Response.Headers.Add("Content-Type", "text/plain")
                    $context.Response.StatusCode = 200
                    $buffer = [System.Text.Encoding]::UTF8.GetBytes($responseData.Body)
                    $context.Response.ContentLength64 = $buffer.Length
                    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
                    $context.Response.OutputStream.Close()
                }
                # responsd with 404 to anything else
                else {
                    # verbose out response
                    Write-Verbose ('Unexpected request from {0}' -f $remoteIP)
                    Write-Debug (' {0} {1}' -f $context.Request.HttpMethod, $context.Request.RawUrl)
                    #respond to the request
                    $context.Response.Headers.Add("Content-Type", "text/plain")
                    $context.Response.StatusCode = 404
                    $buffer = [System.Text.Encoding]::UTF8.GetBytes('')
                    $context.Response.ContentLength64 = $buffer.Length
                    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
                    $context.Response.OutputStream.Close()
                }
            }
        }
        catch {
            $errorMSG = $_
            Write-Error ('HttpListener failed! ({0})' -f $errorMSG)
        }
        finally {

            # initial integration to capture CTRL+C and stop listener - will also fetch unexpected behavior
            if ($httpListener.IsListening) {
                Write-Verbose ('Stopping HttpListener')
                $httpListener.Stop()
            }

            # dispose if necessary
            if ($null -ne $httpListener) {
                $httpListener.Dispose()
            }

            # return PAAuthorizations for MainDomain if output may be used in a variable/pipe
            $order | Get-PAAuthorizations -Verbose:$false
        }
    }

    <#
    .SYNOPSIS
        Starts a local web server to answer pending http-01 ACME challenges.

    .DESCRIPTION
        Uses System.Net.HttpListener to answer http-01 ACME challenges for the current or specified order. If MainDomain is not specified, the current Order is used.

        If running on Windows with non-admin privileges, Access Denied errors may be thrown unless a URL reservation is added using netsh that matches the HttpListener prefix that will be used. The default wildcard prefix is http://+/.well-known/acme-challenge and the netsh command might look something like this:

            netsh http add urlacl url=http://+/.well-known/acme-challenge/ user=Everyone

    .PARAMETER MainDomain
        The primary domain associated with an order.

    .PARAMETER ListenerTimeout
        The timeout in seconds for the webserver. When reached, the http listener stops regardless of challenge status.

    .PARAMETER Port
        The TCP port on which the http listener is listening. 80 by default. This parameter is ignored when ListenerPrefixes is specified.

    .PARAMETER ListenerPrefixes
        Overrides the default wildcard listener prefix with the specified prefixes instead. Be sure to include the port if necessary and a trailing '/' on all included prefixes. See https://docs.microsoft.com/en-us/dotnet/api/system.net.httplistener for details.

    .EXAMPLE
        Invoke-HttpChallengeListener

        Start listener on default port 80 for pending challenges for the current order.

    .EXAMPLE
        Invoke-HttpChallengeListener -MainDomain 'test.example.com' -Port 8080 -ListenerTimeout 30

        Start listener on port 8080 with a timeout of 30 seconds for the specified order.

    .EXAMPLE
        $prefixes = 'http://example.com/.well-known/acme-challenge/','http://www.example.com/.well-known/acme-challenge'
        PS C:\>Invoke-HttpChallengeListener -ListenerPrefixes $prefixes

        Start listener using the specified prefixes for the current order.

    .LINK
        Project: https://github.com/rmbolger/Posh-ACME

    .LINK
        Get-PAOrder

    .LINK
        Get-PAAuthorizations
    #>

}