Plugins/WebSelfHost.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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
function Get-CurrentPluginType { 'http-01' }

function Add-HttpChallenge {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$Domain,
        [Parameter(Mandatory,Position=1)]
        [string]$Token,
        [Parameter(Mandatory,Position=2)]
        [string]$Body,
        [string]$WSHPort,
        [int]$WSHTimeout = 120,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    # Even though we're not directly using the plugin specific parameters here in the Add
    # function, we need to keep them so that things like `Get-PAPlugin WebSelfHost -Params`
    # will show the correct values to users.

    # setup a module variable to record the paths and bodies our
    # listener will response with
    if (!$script:WSHResponses) { $script:WSHResponses = @{} }

    # add the response
    $requestPath = "/.well-known/acme-challenge/$Token"
    Write-Debug "Adding response $requestPath -> $Body"
    $script:WSHResponses[$requestPath] = $Body


    <#
    .SYNOPSIS
        Publish an HTTP challenge to a self-hosted web server
 
    .DESCRIPTION
        Publish an HTTP challenge to a self-hosted web server. Properly using this function relies on also using the associated Save-HttpChallenge function.
 
    .PARAMETER Domain
        The fully qualified domain name to publish the challenge for.
 
    .PARAMETER Token
        The token value associated with this specific challenge.
 
    .PARAMETER Body
        The text that should make up the response body from the URL.
 
    .PARAMETER WSHPort
        The TCP port the server should listen on for requests. Defaults to 80 if not specified.
 
    .PARAMETER WSHTimeout
        The number of seconds to leave the server running for before automatically stopping.
 
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
 
    .EXAMPLE
        Add-HttpChallenge 'example.com' 'TOKEN' 'body-value' -WSHPort 8000
 
        Prepares a self-hosted HTTP challenge on the specified port. This must be followed by a call to Save-HttpChallenge in order to actually start the HTTP listener.
    #>

}

function Remove-HttpChallenge {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$Domain,
        [Parameter(Mandatory,Position=1)]
        [string]$Token,
        [Parameter(Mandatory,Position=2)]
        [string]$Body,
        [string]$WSHPort,
        [int]$WSHTimeout = 120,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    # setup a module variable to record the paths and bodies our
    # listener will response with
    if (!$script:WSHResponses) { $script:WSHResponses = @{} }

    $requestPath = "/.well-known/acme-challenge/$Token"

    # add the response
    if ($script:WSHResponses.ContainsKey($requestPath)) {
        Write-Debug "Removing response $requestPath"
        $script:WSHResponses.Remove($requestPath)
    }

    <#
    .SYNOPSIS
        Unpublish an HTTP challenge to a self-hosted web server
 
    .DESCRIPTION
        Unpublish an HTTP challenge to a self-hosted web server. Properly using this function relies on also using the associated Save-HttpChallenge function.
 
    .PARAMETER Domain
        The fully qualified domain name to publish the challenge for.
 
    .PARAMETER Token
        The token value associated with this specific challenge.
 
    .PARAMETER Body
        The text that should make up the response body from the URL.
 
    .PARAMETER WSHPort
        The TCP port the server should listen on for requests. Defaults to 80 if not specified.
 
    .PARAMETER WSHTimeout
        The number of seconds to leave the server running for before automatically stopping.
 
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
 
    .EXAMPLE
        Remove-HttpChallenge 'example.com' 'TOKEN' 'body-value' -WSHPort 8000
 
        Removes a prepared self-hosted HTTP challenge on the specified port. This must be followed by a call to Save-HttpChallenge in order to actually stop the HTTP listener.
    #>

}

function Save-HttpChallenge {
    [CmdletBinding()]
    param(
        [string]$WSHPort,
        [int]$WSHTimeout = 120,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    # setup a module variable to record the paths and bodies our
    # listener will response with
    if (!$script:WSHResponses) { $script:WSHResponses = @{} }

    # Check for an existing listener to see whether we need to start or stop
    if (-not $script:WSHListenerJob) {
        # START
        Write-Debug "No existing listener job, time to start"

        # determine the listener prefix
        $portSuffix = if ($WSHPort) { ":$WSHPort" } else { [string]::Empty }
        $prefix = 'http://+{0}/.well-known/acme-challenge/' -f $portSuffix

        Write-Debug "Starting listener job with prefix $prefix"
        $script:WSHListenerJob = Start-Job -ScriptBlock {
            param(
                [string[]]$ListenerPrefix,
                [hashtable]$KnownResponses,
                [int]$Timeout
            )

            $VerbosePreference = 'Continue'
            $DebugPreference = 'Continue'

            try {
                # create the listener and add the prefixes
                $listener = [System.Net.HttpListener]::new()
                $ListenerPrefix | ForEach-Object {
                    $listener.Prefixes.Add($_)
                }

                $listener.Start()
                $startTime = Get-Date
                Write-Debug "HttpListener started with $Timeout second timeout"
            }
            catch { throw }

            try {
                # listen loop
                while ($listener.IsListening) {

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

                    # check for timeout
                    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 ($Timeout -ne 0 -and $runTime -ge $Timeout) {
                            Write-Verbose 'timeout reached, stopping HttpListener'
                            $listener.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 += ' (fwd {0})' -f $context.Request.Headers['X-Forwarded-For']
                    }

                    $method = $context.Request.HttpMethod.ToString()
                    $requestPath = $context.Request.RawUrl

                    # respond to the requests we're expecting
                    if ($method -eq 'GET' -and $KnownResponses[$requestPath]) {
                        $responseData = $KnownResponses[$requestPath]

                        # verbose out response
                        Write-Verbose "Responding to $remoteIP for $requestPath"
                        Write-Debug $responseData
                        #respond to the request
                        $context.Response.Headers.Add("Content-Type", "text/plain")
                        $context.Response.StatusCode = 200
                        $buffer = [System.Text.Encoding]::UTF8.GetBytes($responseData)
                        $context.Response.ContentLength64 = $buffer.Length
                        $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
                        $context.Response.OutputStream.Close()
                    }
                    # and 404 anything else
                    else {
                        # verbose out response
                        Write-Verbose "Unexpected request from $remoteIP"
                        Write-Debug "$method $($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 {
                Write-Error "HttpListener failed: $($_.Exception.Message)"
            }
            finally {
                # initial integration to capture CTRL+C and stop listener - will also fetch unexpected behavior
                if ($listener.IsListening) {
                    Write-Verbose 'Stopping HttpListener'
                    $listener.Stop()
                }

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

        } -ArgumentList $prefix,$script:WSHResponses,$WSHTimeout

    } else {
        # STOP
        Write-Debug "Found existing listener job, time to stop"

        $job = $script:WSHListenerJob

        $job | Stop-Job

        # We're not expecting any actual results from the job, but if want Debug/Verbose
        # messages to come back through to the client. Unfortunately, there's a known issue
        # with this such that even if the user doesn't have Verbose/Debug turned on, they'll
        # still come back through and end up on the console. It's just a cosmetic annoyance
        # though and most people automating this likely won't see the spam.
        # https://github.com/PowerShell/PowerShell/issues/9585
        $job | Receive-Job | Out-Null

        $job | Remove-Job

        $script:WSHListenerJob = $null
    }

    <#
    .SYNOPSIS
        Start or Stop the HTTP listener that will host the challenges prepared with Add-HttpChallenge.
 
    .DESCRIPTION
        This function toggles the state of the HTTP challenge listener and must be used after all calls to Add-HttpChallenge are complete and again after all calls to Remove-HttpChallenge are complete.
 
    .PARAMETER WSHPort
        The TCP port the server should listen on for requests. Defaults to 80 if not specified.
 
    .PARAMETER WSHTimeout
        The number of seconds to leave the server running for before automatically stopping.
 
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
 
    .EXAMPLE
        Save-HttpChallenge -WSHPort 8000
 
        Start or Stop the listener on the specified port.
    #>

}