PSLite.psm1
|
<# PSLite.psm1 A small web API server for Windows PowerShell 5.1 - like Pode, but one file. Built on System.Net.HttpListener (part of .NET), so nothing else to install. Install-Module PSLite -Scope CurrentUser Import-Module PSLite Add-PlRoute -Method GET -Path '/hello' -Description 'Say hello' -ScriptBlock { @{ message = 'hello' } } Start-PlServer -Port 8080 -ApiKey 'abc123' -LogPath 'C:\logs\mp.log' Open http://<server>:8080/docs for the Swagger-like page. Ctrl+C stops the server. Public functions: Add-PlRoute - add a GET / POST route Get-PlRoute - list the routes added so far New-PlResponse - return your own status code from a route Start-PlServer - start listening (HTTP or HTTPS) #> # Routes live here. Import-Module PSLite -Force starts with an empty list. $script:PlRoutes = @() # Fixed app id used for the HTTPS netsh binding, so you can see which bindings came from PSLite. $script:PlAppId = '7d3c2a51-6f0e-4b8a-9c1e-2f4d5b6a7c80' function Add-PlRoute { <# .SYNOPSIS Adds a route (URL + code to run). .DESCRIPTION The ScriptBlock gets one argument: the request object, with .Method .Path .Params .Query .Headers .Body .RawBody .ClientIP Params = values from {name} parts of the path, e.g. /user/{id} -> $Request.Params.id Body = JSON body already turned into an object (when Content-Type is JSON) Whatever the ScriptBlock returns is sent back as JSON. A plain string is sent as text. Return New-PlResponse to choose your own status code. Adding the same Method + Path again replaces the old route. .PARAMETER Query Names of query string parameters. Only used to draw input boxes on the /docs page. .PARAMETER BodyExample Example body (hashtable or object). Only used to pre-fill the body box on the /docs page. .EXAMPLE Add-PlRoute -Method GET -Path '/user/{id}' -Description 'Get one user' -ScriptBlock { param($Request) @{ id = $Request.Params.id } } .EXAMPLE Add-PlRoute -Method POST -Path '/user' -BodyExample @{ name = 'Alex' } -ScriptBlock { param($Request) @{ added = $Request.Body.name } } #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('GET', 'POST')][string]$Method, [Parameter(Mandatory)][ValidatePattern('^/')][string]$Path, [Parameter(Mandatory)][scriptblock]$ScriptBlock, [string]$Description = '', [string[]]$Query = @(), $BodyExample ) $Method = $Method.ToUpper() if ($Path.Length -gt 1) { $Path = $Path.TrimEnd('/') } # /user/{id} -> ^/user/(?<id>[^/]+)/?$ $pathParams = @([regex]::Matches($Path, '\{(\w+)\}') | ForEach-Object { $_.Groups[1].Value }) $regex = '^' + ([regex]::Escape($Path) -replace '\\\{(\w+)}', '(?<$1>[^/]+)') + '/?$' $bodyJson = '' if ($null -ne $BodyExample) { $bodyJson = ConvertTo-Json -InputObject $BodyExample -Depth 10 } $route = [pscustomobject]@{ Method = $Method Path = $Path Description = $Description Query = @($Query) PathParams = $pathParams BodyExample = $bodyJson Regex = $regex ScriptBlock = $ScriptBlock } $script:PlRoutes = @($script:PlRoutes | Where-Object { -not ($_.Method -eq $Method -and $_.Path -eq $Path) }) + $route } function Get-PlRoute { <# .SYNOPSIS Lists the routes added so far. #> [CmdletBinding()] param() $script:PlRoutes | Select-Object Method, Path, Description } function New-PlResponse { <# .SYNOPSIS Return this from a route to set your own status code. .EXAMPLE Add-PlRoute -Method GET -Path '/user/{id}' -ScriptBlock { param($Request) $user = Get-ADUser -Filter "SamAccountName -eq '$($Request.Params.id)'" if (-not $user) { return New-PlResponse -StatusCode 404 -Body @{ error = 'User not found' } } $user | Select-Object Name, SamAccountName } .EXAMPLE # Serve an HTML page (Body must be a string when -ContentType is used) Add-PlRoute -Method GET -Path '/' -ScriptBlock { New-PlResponse -StatusCode 200 -ContentType 'text/html' -Body (Get-Content 'C:\web\page.html' -Raw) } #> [CmdletBinding()] param( [Parameter(Mandatory)][int]$StatusCode, $Body, [string]$ContentType ) [pscustomobject]@{ PSTypeName = 'PlResponse' StatusCode = $StatusCode Body = $Body ContentType = $ContentType } } function Start-PlServer { <# .SYNOPSIS Starts the web server. Runs until Ctrl+C. .DESCRIPTION Handles one request at a time. Built-in pages: /docs Swagger-like page, open to everyone (the API key is typed into the page). / Goes to /docs, unless you added your own '/' route. -Hostname '+' (default) listens on every IP / name of the server. That needs PowerShell run as admin, or a one-time URL reservation (as admin): netsh http add urlacl url=http://+:8080/ user=DOMAIN\you -Hostname localhost works without admin, but only from the same machine. -Https binds the certificate to the port with netsh (needs admin the first time). The certificate must be in Cert:\LocalMachine\My and have a private key. If the port is already bound to a different certificate, nothing is changed and an error is shown - so another app's binding is never overwritten. .PARAMETER ApiKey If set, every route needs header X-API-Key with this value. Without it there is no auth. .PARAMETER LogPath If set, one line per request is added to this file. The folder is created if missing. .EXAMPLE Start-PlServer -Port 8080 -Hostname localhost .EXAMPLE Start-PlServer -Port 8443 -Https -CertThumbprint 'A1B2...' -ApiKey 'abc123' -LogPath 'C:\logs\mp.log' #> [CmdletBinding()] param( [int]$Port = 8080, [string]$Hostname = '+', [switch]$Https, [string]$CertThumbprint, [string]$ApiKey, [string]$LogPath, [string]$Title = 'PSLite API' ) if ($Https) { if (-not $CertThumbprint) { throw "-Https needs -CertThumbprint." } Set-PlSslBinding -Port $Port -CertThumbprint $CertThumbprint } if ($LogPath) { $logDir = Split-Path -Path $LogPath -Parent if ($logDir -and -not (Test-Path -Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } } $scheme = if ($Https) { 'https' } else { 'http' } $prefix = "{0}://{1}:{2}/" -f $scheme, $Hostname, $Port $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add($prefix) try { $listener.Start() } catch { $listener.Close() $msg = $_.Exception.Message if ($_.Exception.InnerException) { $msg = $_.Exception.InnerException.Message } if ($msg -match 'denied') { throw ("Access denied on $prefix. Run PowerShell as admin, or use -Hostname localhost, " + "or once as admin: netsh http add urlacl url=$prefix user=$env:USERDOMAIN\$env:USERNAME") } throw "Could not start on $prefix : $msg (is the port already in use?)" } $shownHost = if ($Hostname -in '+', '*') { $env:COMPUTERNAME } else { $Hostname } $baseUrl = "{0}://{1}:{2}" -f $scheme, $shownHost, $Port Write-Host "" Write-Host "PSLite listening on $prefix" -ForegroundColor Green Write-Host " Docs : $baseUrl/docs" -ForegroundColor Cyan Write-Host " Routes : $(@($script:PlRoutes).Count)" Write-Host " Auth : $(if ($ApiKey) { 'API key (header X-API-Key)' } else { 'none' })" Write-Host " Log : $(if ($LogPath) { $LogPath } else { 'none' })" Write-Host "Ctrl+C to stop." -ForegroundColor Yellow Write-Host "" Write-PlLog -LogPath $LogPath -Line ("{0} | SERVER STARTED | {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $prefix) try { while ($listener.IsListening) { # Wait in short steps so Ctrl+C can break in (a plain GetContext() would block it). $task = $listener.GetContextAsync() while (-not $task.AsyncWaitHandle.WaitOne(500)) { } Invoke-PlRequest -Context $task.Result -ApiKey $ApiKey -LogPath $LogPath -Title $Title } } finally { $listener.Stop() $listener.Close() Write-PlLog -LogPath $LogPath -Line ("{0} | SERVER STOPPED | {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $prefix) Write-Host "PSLite stopped." -ForegroundColor Yellow } } # --------------------------------------------------------------------------- # Internal helpers (not meant to be called directly) # --------------------------------------------------------------------------- function Invoke-PlRequest { param($Context, [string]$ApiKey, [string]$LogPath, [string]$Title) $ErrorActionPreference = 'Stop' $req = $Context.Request $method = $req.HttpMethod $path = $req.Url.AbsolutePath if ($path.Length -gt 1) { $path = $path.TrimEnd('/') } $clientIp = $req.RemoteEndPoint.Address.ToString() # read now - it is gone once the response is closed $status = 500 $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # --- built-in pages --- if ($method -eq 'GET' -and $path -eq '/docs') { $status = 200 Send-PlResponse -Context $Context -StatusCode $status -ContentType 'text/html' -Text (Get-PlDocsHtml -Title $Title) return } if ($method -eq 'GET' -and $path -eq '/' -and -not ($script:PlRoutes | Where-Object { $_.Method -eq 'GET' -and $_.Path -eq '/' })) { $status = 302 $Context.Response.Redirect('/docs') $Context.Response.Close() return } # --- auth --- if ($ApiKey -and $req.Headers['X-API-Key'] -cne $ApiKey) { $status = 401 Send-PlJson -Context $Context -StatusCode $status -Object @{ error = 'Missing or wrong API key (header X-API-Key).' } return } # --- find route --- $route = $null $pathParams = @{} foreach ($r in $script:PlRoutes) { if ($r.Method -eq $method -and $path -match $r.Regex) { $route = $r foreach ($k in $Matches.Keys) { if ($k -ne 0) { $pathParams[$k] = [uri]::UnescapeDataString($Matches[$k]) } } break } } if (-not $route) { $status = 404 Send-PlJson -Context $Context -StatusCode $status -Object @{ error = "No route for $method $path" } return } # --- read request --- $query = @{} foreach ($k in $req.QueryString.AllKeys) { if ($k) { $query[$k] = $req.QueryString[$k] } } $headers = @{} foreach ($k in $req.Headers.AllKeys) { $headers[$k] = $req.Headers[$k] } $rawBody = $null if ($req.HasEntityBody) { $reader = New-Object System.IO.StreamReader($req.InputStream, $req.ContentEncoding) try { $rawBody = $reader.ReadToEnd() } finally { $reader.Close() } } $body = $rawBody if ($rawBody -and $req.ContentType -like '*json*') { try { $body = $rawBody | ConvertFrom-Json } catch { $status = 400 Send-PlJson -Context $Context -StatusCode $status -Object @{ error = "Body is not valid JSON: $($_.Exception.Message)" } return } } $request = [pscustomobject]@{ Method = $method Path = $path Params = $pathParams Query = $query Headers = $headers Body = $body RawBody = $rawBody ClientIP = $clientIp } # --- run the route --- $result = @(& $route.ScriptBlock $request) $code = 200 $contentType = 'text/plain' if ($result.Count -eq 1 -and $result[0] -and $result[0].PSObject.TypeNames -contains 'PlResponse') { $code = $result[0].StatusCode if ($result[0].ContentType) { $contentType = $result[0].ContentType } $result = @($result[0].Body) } $status = $code if ($result.Count -eq 0 -or ($result.Count -eq 1 -and $null -eq $result[0])) { if ($status -eq 200) { $status = 204 } Send-PlResponse -Context $Context -StatusCode $status } elseif ($result.Count -eq 1 -and $result[0] -is [string]) { Send-PlResponse -Context $Context -StatusCode $status -ContentType $contentType -Text $result[0] } elseif ($result.Count -eq 1) { Send-PlJson -Context $Context -StatusCode $status -Object $result[0] } else { Send-PlJson -Context $Context -StatusCode $status -Object $result } } catch { $status = 500 Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red Send-PlJson -Context $Context -StatusCode $status -Object @{ error = $_.Exception.Message } } finally { $sw.Stop() $line = "{0} | {1} | {2} {3} | {4} | {5} ms" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $clientIp, $method, $req.Url.PathAndQuery, $status, $sw.ElapsedMilliseconds $color = if ($status -ge 500) { 'Red' } elseif ($status -ge 400) { 'Yellow' } else { 'Gray' } Write-Host $line -ForegroundColor $color Write-PlLog -LogPath $LogPath -Line $line } } function Send-PlResponse { param($Context, [int]$StatusCode, [string]$ContentType = 'text/plain', [string]$Text) $res = $Context.Response try { $res.StatusCode = $StatusCode if ($Text) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) $res.ContentType = "$ContentType; charset=utf-8" $res.ContentLength64 = $bytes.Length $res.OutputStream.Write($bytes, 0, $bytes.Length) } } catch { Write-Warning "Could not send response: $($_.Exception.Message)" } finally { $res.Close() } } function Send-PlJson { param($Context, [int]$StatusCode, $Object) $json = ConvertTo-Json -InputObject $Object -Depth 10 Send-PlResponse -Context $Context -StatusCode $StatusCode -ContentType 'application/json' -Text $json } function Write-PlLog { param([string]$LogPath, [string]$Line) if (-not $LogPath) { return } try { Add-Content -Path $LogPath -Value $Line -Encoding UTF8 } catch { Write-Warning "Could not write log: $($_.Exception.Message)" } } function Set-PlSslBinding { param([int]$Port, [string]$CertThumbprint) # Copy-paste from the cert window often adds hidden characters - keep only hex. $thumb = ($CertThumbprint -replace '[^0-9a-fA-F]', '').ToUpper() $cert = Get-Item -Path "Cert:\LocalMachine\My\$thumb" -ErrorAction SilentlyContinue if (-not $cert) { throw "Certificate $thumb not found in Cert:\LocalMachine\My." } if (-not $cert.HasPrivateKey) { throw "Certificate $thumb has no private key - it cannot be used for HTTPS." } $ipport = "0.0.0.0:$Port" $show = netsh http show sslcert "ipport=$ipport" 2>&1 | Out-String if ($show -match 'Certificate Hash\s*:\s*([0-9a-fA-F]+)') { $current = $Matches[1].ToUpper() if ($current -eq $thumb) { Write-Host "HTTPS: port $Port already bound to certificate $thumb." -ForegroundColor Gray return } throw ("Port $Port is already bound to another certificate ($current). Not changing it. " + "If it is safe to remove, run as admin: netsh http delete sslcert ipport=$ipport") } $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "Binding the certificate to port $Port needs PowerShell run as admin (only the first time)." } $out = netsh http add sslcert "ipport=$ipport" "certhash=$thumb" "appid={$script:PlAppId}" 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "netsh could not bind the certificate: $out" } Write-Host "HTTPS: bound certificate $thumb to port $Port." -ForegroundColor Green } function Get-PlDocsHtml { param([string]$Title) $data = @($script:PlRoutes | ForEach-Object { [ordered]@{ Method = $_.Method Path = $_.Path Description = $_.Description PathParams = @($_.PathParams) Query = @($_.Query) BodyExample = $_.BodyExample } }) $json = if ($data.Count -eq 0) { '[]' } else { ConvertTo-Json -InputObject $data -Depth 5 -Compress } $json = $json.Replace('</', '<\/') $html = @' <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>__TITLE__</title> <style> body { font-family: "Segoe UI", Arial, sans-serif; margin: 0; background: #fafafa; color: #3b4151; } header { background: #1b1b1b; color: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } header h1 { font-size: 20px; margin: 0; flex: 1; } header input { padding: 6px 8px; width: 220px; border: 0; border-radius: 3px; } header button { padding: 6px 14px; border: 1px solid #49cc90; background: transparent; color: #49cc90; border-radius: 3px; cursor: pointer; font-weight: 600; } #saved { font-size: 12px; color: #49cc90; } main { max-width: 1000px; margin: 20px auto; padding: 0 16px; } .op { border: 1px solid; border-radius: 4px; margin-bottom: 10px; } .op.GET { border-color: #61affe; background: rgba(97,175,254,.1); } .op.POST { border-color: #49cc90; background: rgba(73,204,144,.1); } .sum { display: flex; align-items: center; gap: 12px; padding: 8px; cursor: pointer; } .badge { min-width: 64px; text-align: center; color: #fff; font-weight: 700; border-radius: 3px; padding: 6px 0; font-size: 14px; } .GET .badge { background: #61affe; } .POST .badge { background: #49cc90; } .path { font-family: Consolas, monospace; font-weight: 700; font-size: 15px; } .desc { color: #555; font-size: 13px; } .body { display: none; padding: 12px 16px; border-top: 1px solid #ddd; background: #fff; } .op.open .body { display: block; } label { display: block; font-size: 13px; font-weight: 600; margin: 10px 0 4px; } input.p { padding: 5px 8px; width: 300px; max-width: 100%; border: 1px solid #ccc; border-radius: 3px; } textarea { width: 100%; box-sizing: border-box; height: 120px; font-family: Consolas, monospace; font-size: 13px; } pre { background: #333; color: #fff; padding: 10px; border-radius: 4px; overflow: auto; white-space: pre-wrap; word-break: break-all; font-size: 13px; margin: 0; } .exec { background: #4990e2; color: #fff; border: 0; padding: 8px 24px; border-radius: 4px; margin-top: 12px; cursor: pointer; font-weight: 600; } .empty { color: #888; } </style> </head> <body> <header> <h1>__TITLE__</h1> <span id="saved"></span> <input id="key" type="password" placeholder="API key (X-API-Key)"> <button id="auth">Authorize</button> </header> <main id="list"></main> <script> var routes = __ROUTES__; var keyBox = document.getElementById('key'); try { keyBox.value = localStorage.getItem('psLiteKey') || ''; } catch (e) {} document.getElementById('auth').onclick = function () { try { localStorage.setItem('psLiteKey', keyBox.value); } catch (e) {} document.getElementById('saved').textContent = keyBox.value ? 'Key saved' : 'Key cleared'; }; function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function psCommand(url, method, body) { var cmd = "Invoke-RestMethod -Method " + method + " -Uri '" + location.origin + url + "'"; if (keyBox.value) cmd += " -Headers @{ 'X-API-Key' = '<your key>' }"; if (body) cmd += " -ContentType 'application/json' -Body '" + body.replace(/'/g, "''") + "'"; return cmd; } var list = document.getElementById('list'); if (!routes.length) list.appendChild(el('p', 'empty', 'No routes yet. Add some with Add-PlRoute.')); routes.forEach(function (r) { var op = el('div', 'op ' + r.Method); var sum = el('div', 'sum'); sum.appendChild(el('span', 'badge', r.Method)); sum.appendChild(el('span', 'path', r.Path)); sum.appendChild(el('span', 'desc', r.Description || '')); sum.onclick = function () { op.classList.toggle('open'); }; var b = el('div', 'body'); var pathBoxes = {}, queryBoxes = {}, bodyBox = null; (r.PathParams || []).forEach(function (n) { b.appendChild(el('label', null, n + ' (path, required)')); pathBoxes[n] = b.appendChild(el('input', 'p')); }); (r.Query || []).forEach(function (n) { b.appendChild(el('label', null, n + ' (query)')); queryBoxes[n] = b.appendChild(el('input', 'p')); }); if (r.Method !== 'GET') { b.appendChild(el('label', null, 'Body (JSON)')); bodyBox = b.appendChild(el('textarea')); bodyBox.value = r.BodyExample || ''; } var btn = b.appendChild(el('button', 'exec', 'Execute')); var out = b.appendChild(el('div')); btn.onclick = function () { var url = r.Path.replace(/\{(\w+)\}/g, function (m, n) { return encodeURIComponent(pathBoxes[n].value); }); var qs = []; for (var n in queryBoxes) { if (queryBoxes[n].value !== '') qs.push(encodeURIComponent(n) + '=' + encodeURIComponent(queryBoxes[n].value)); } if (qs.length) url += '?' + qs.join('&'); var opts = { method: r.Method, headers: {} }; if (keyBox.value) opts.headers['X-API-Key'] = keyBox.value; if (bodyBox && bodyBox.value.trim()) { opts.headers['Content-Type'] = 'application/json'; opts.body = bodyBox.value; } out.innerHTML = ''; out.appendChild(el('label', null, 'PowerShell')); out.appendChild(el('pre', null, psCommand(url, r.Method, opts.body))); var t0 = Date.now(); fetch(url, opts).then(function (res) { return res.text().then(function (txt) { var pretty = txt; try { pretty = JSON.stringify(JSON.parse(txt), null, 2); } catch (e) {} out.appendChild(el('label', null, 'Response: ' + res.status + ' ' + res.statusText + ' (' + (Date.now() - t0) + ' ms)')); out.appendChild(el('pre', null, pretty || '(empty)')); }); }).catch(function (e) { out.appendChild(el('label', null, 'Error')); out.appendChild(el('pre', null, String(e))); }); }; op.appendChild(sum); op.appendChild(b); list.appendChild(op); }); </script> </body> </html> '@ $safeTitle = [System.Net.WebUtility]::HtmlEncode($Title) $html.Replace('__TITLE__', $safeTitle).Replace('__ROUTES__', $json) } Export-ModuleMember -Function Add-PlRoute, Get-PlRoute, New-PlResponse, Start-PlServer |