src/WebServer/main.ps1
#Requires -Version 5.0 <# .SYNOPSIS run a web server to allow users to compile powershell scripts .DESCRIPTION run a web server to allow users to compile powershell scripts .PARAMETER HostUrl The url of the web server .PARAMETER MaxCompileThreads The maximum number of compile threads .PARAMETER MaxCompileTime The maximum compile time of a compile in seconds .PARAMETER ReqLimitPerMin The maximum number of requests per minute per IP .PARAMETER MaxCachedFileSize The maximum size of the cached file .PARAMETER MaxScriptFileSize The maximum size of the script file .PARAMETER Localize The language code to be used for server-side logging .PARAMETER help Display help message .EXAMPLE Start-ps12exeWebServer .EXAMPLE Start-ps12exeWebServer -HostUrl 'http://localhost:80/' #> [CmdletBinding()] param ( $HostUrl = 'http://localhost:8080/', $MaxCompileThreads = 8, $MaxCompileTime = 20, $ReqLimitPerMin = 5, $MaxCachedFileSize = 32mb, $MaxScriptFileSize = 2mb, [ArgumentCompleter({ Param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) . "$PSScriptRoot\..\LocaleArgCompleter.ps1" @PSBoundParameters })] [string]$Localize, [switch]$help ) $LocalizeData = . $PSScriptRoot\..\LocaleLoader.ps1 -Localize $Localize if ($help) { $MyHelp = $LocalizeData.WebServerHelpData . $PSScriptRoot\..\HelpShower.ps1 -HelpData $MyHelp | Out-Host return } # 创建 HttpListener 对象 $http = [System.Net.HttpListener]::new() $http.Prefixes.Add($HostUrl) $http.Start() if ($http.IsListening) { Write-Host $LocalizeData.ServerStarted -ForegroundColor Black -BackgroundColor Green Write-Host "$($LocalizeData.ServerListening) $($http.Prefixes)" -ForegroundColor Yellow } else { if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) { Write-Host $LocalizeData.TryRunAsRoot -ForegroundColor Yellow } Write-Host $LocalizeData.ServerStartFailed -ForegroundColor Black -BackgroundColor Red return } # Set Console Window Title $BackUpTitle = $Host.UI.RawUI.WindowTitle $Host.UI.RawUI.WindowTitle = "ps12exe Web Server" Write-Host $LocalizeData.ExitServerTip -ForegroundColor Yellow # Define a hashtable to track request counts per IP $ipRequestCount = @{} # 两个队列用于装载$AsyncResult和$Runspace,直到$AsyncResult结束我们才能对$Runspace进行Dispose。。。 $AsyncResultArray = New-Object System.Collections.ArrayList # Create a runspace pool $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxCompileThreads) $runspacePool.Open() function HandleRequest($context) { switch ($context.Request.RawUrl) { '/compile' { $Reader = New-Object System.IO.StreamReader($context.Request.InputStream) $userInput = $Reader.ReadToEnd() $Reader.Close() $Reader.Dispose() Write-Verbose "Compiling User Input: $userInput" if (!$userInput) { Write-Verbose "No data found when Handling Request, returning empty response" break } if ($userInput.Length -gt $MaxScriptFileSize -and $clientIP -ne '127.0.0.1') { Write-Verbose "User Input is too large, returning 413 error" $context.Response.StatusCode = 413 $context.Response.ContentType = "text/plain" $buffer = [System.Text.Encoding]::UTF8.GetBytes('File too large') break } $clientIP = $context.Request.RemoteEndPoint.Address.ToString() $ipRequestCount[$clientIP]++ # Check if the IP has exceeded the limit (e.g., 5 requests per minute) if ($ipRequestCount[$clientIP] -gt $ReqLimitPerMin -and $clientIP -ne '127.0.0.1') { Write-Verbose "IP $clientIP has exceeded the limit of $ReqLimitPerMin requests per minute, returning 429 error" $context.Response.StatusCode = 429 $context.Response.ContentType = "text/plain" $buffer = [System.Text.Encoding]::UTF8.GetBytes('Too many requests') break } # hash of user input $userInputHash = [System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($userInput)) $userInputHashStr = '' foreach ($byte in $userInputHash) { $userInputHashStr += $byte.ToString('x2') } $compiledExePath = "$PSScriptRoot/outputs/$userInputHashStr.bin" #if match cached file if (Test-Path -Path $compiledExePath -ErrorAction Ignore) { $context.Response.ContentType = "application/octet-stream" $buffer = [System.IO.File]::ReadAllBytes($compiledExePath) break } $runspace = [powershell]::Create() $runspace.RunspacePool = $runspacePool $AsyncResult = $runspace.AddScript({ param ($userInput, $Response, $ScriptRoot, $compiledExePath, $clientIP) # 加载ps12exe用于处理编译请求 Import-Module $ScriptRoot/../../ps12exe.psm1 -ErrorAction Stop New-Item -Path $ScriptRoot/outputs -ItemType Directory -Force | Out-Null # 编译代码 try { $userInput | ps12exe -outputFile $compiledExePath -GuestMode:$($clientIP -ne '127.0.0.1') -ErrorAction Stop $buffer = [System.IO.File]::ReadAllBytes($compiledExePath) $Response.ContentType = "application/octet-stream" } catch { # 若ErrorId不是ParseError则写入日志 if ($_.ErrorId -ine "ParseError") { Write-Host "${clientIP}:" -ForegroundColor Red Write-Host $_ -ForegroundColor Red } $Response.ContentType = "text/plain" $buffer = [System.Text.Encoding]::UTF8.GetBytes("$_") } $Response.ContentLength64 = $buffer.Length if ($buffer) { $Response.OutputStream.Write($buffer, 0, $buffer.Length) } $Response.Close() }). AddArgument($userInput).AddArgument($context.Response). AddArgument($PSScriptRoot).AddArgument($compiledExePath).AddArgument($clientIP). BeginInvoke() $AsyncResultArray.Add(@{ AsyncHandle = $AsyncResult Runspace = $runspace Time = Get-Date IP = $clientIP }) | Out-Null return } '/bgm' { # midi file $context.Response.ContentType = "audio/midi" $buffer = [System.IO.File]::ReadAllBytes("$PSScriptRoot/../bin/Unravel.mid") } '/favicon.ico' { $context.Response.ContentType = "image/x-icon" $buffer = [System.IO.File]::ReadAllBytes("$PSScriptRoot/../../img/icon.ico") } '/' { $body = Get-Content -LiteralPath "$PSScriptRoot/index.html" -Encoding utf8 -Raw $buffer = [System.Text.Encoding]::UTF8.GetBytes($body) } default { $context.Response.StatusCode = 404 $context.Response.ContentType = "text/plain" $buffer = [System.Text.Encoding]::UTF8.GetBytes('Not Found') } } $context.Response.ContentLength64 = $buffer.Length if ($buffer) { $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) } $context.Response.Close() } function AutoCacheClear { $Cache = Get-ChildItem -Path $PSScriptRoot/outputs -ErrorAction Ignore if ($MaxCachedFileSize -lt ($Cache | Measure-Object -Property Length -Sum).Sum) { $Cache | Sort-Object -Property LastAccessTime -Descending | Select-Object -First $([math]::Floor($Cache.Count / 2)) | Remove-Item -Force -ErrorAction Ignore } } function AutoRunspaceRecycle { while ($AsyncResultArray[0].AsyncHandle.IsCompleted) { $AsyncResultArray[0].Runspace.Dispose() $AsyncResultArray.RemoveAt(0) } $TimeNow = Get-Date while ($AsyncResultArray[0] -and ($TimeNow - $AsyncResultArray[0].Time).Seconds -ge $MaxCompileTime) { if ($AsyncResultArray[0].IP -eq '127.0.0.1') { break } $AsyncResultArray[0].Runspace.Stop() $AsyncResultArray[0].Runspace.Dispose() $AsyncResultArray.RemoveAt(0) } } try { # 无限循环,用于监听请求 直到用户按下 Ctrl+C # 变量用于一分钟计时 $Timer = 0 while ($http.IsListening) { $Async = $http.BeginGetContext($null, $null) while (-not $Async.AsyncWaitHandle.WaitOne(500)) { $Timer++ if ($Timer -ge 120) { $ipRequestCount = @{} AutoCacheClear $Timer = 0 } AutoRunspaceRecycle } HandleRequest($http.EndGetContext($Async)) } } finally { # 关闭 HttpListener $http.Stop() while ($AsyncResultArray.Count) { $AsyncResultArray[0].Runspace.Stop() $AsyncResultArray[0].Runspace.Dispose() $AsyncResultArray.RemoveAt(0) } $runspacePool.Close() $runspacePool.Dispose() Write-Host $LocalizeData.ServerStopped -ForegroundColor Yellow # Restore Console Window Title $Host.UI.RawUI.WindowTitle = $BackUpTitle # 清空缓存 Remove-Item $PSScriptRoot/outputs/* -Recurse -Force -ErrorAction Ignore } |