使用 PowerShell 实现的 http 服务器

这篇文章最后更新的时间在六个月之前,文章所叙述的内容可能已经失效,请谨慎参考!

这是一个使用 PowerShell 实现的 http 服务器。 可以显示目录页,下载静态文件和响应 cgi 请求。

核心是调用 .NET 的类库

PSVersion 5.1

可以像这样运行

.\http-server.ps1
# 然后可以在浏览器里这样访问 http://localhost:8080/

参考了这几个仓库的实现

参考文档

源码


$websiteRoot = $(Get-Location).Path

$mimeHash = [ordered]@{
    ".html" = "text/html";
    ".htm" = "text/html";
    ".shtml" = "text/html";
    ".shtm" = "text/html";
    ".css" = "text/css";
    ".xml" = "text/xml";
    # ".csv" = "text/csv";
    ".gif" = "image/gif";
    ".jpeg" = "image/jpeg";
    ".jpg" = "image/jpeg";
    ".js" = "text/javascript";
    ".txt" = "text/plain";
    ".json" = "application/json";
    ".pdf" = "application/pdf";
    ".png" = "image/png";
    ".svg" = "image/svg+xml";
    ".webp" = "image/webp";
    ".ico" = "image/x-icon";
    ".bmp" = "image/x-ms-bmp";
    ".woff" = "font/woff";
    ".woff2" = "font/woff2";
    ".der" = "application/x-x509-ca-cert";
    ".pem" = "application/x-x509-ca-cert";
    ".crt" = "application/x-x509-ca-cert";
    ".xhtml" = "application/xhtml+xml";
    ".zip" = "application/zip";
    ".mid" = "audio/midi";
    ".midi" = "audio/midi";
    ".kar" = "audio/midi";
    ".mp3" = "audio/mpeg";
    ".ogg" = "audio/ogg";
    ".3gpp" = "video/3gpp";
    ".3gp" = "video/3gpp";
    ".mp4" = "video/mp4";
    ".mpeg" = "video/mpeg";
    ".mpg" = "video/mpeg";
    ".mov" = "video/quicktime";
    ".webm" = "video/webm";
    ".flv" = "video/x-flv";
    ".wmv" = "video/x-ms-wmv";
    ".avi" = "video/x-msvideo";
    ".md" = "text/plain";
}

$cgiHash = [ordered]@{
    ".php" = "php"
    ".pl" = "perl"
    ".py" = "python"
    ".rb" = "ruby"
    ".cgi" = "executablefile"
    ".exe" = "executablefile"
}

Add-Type -AssemblyName System.Web

# Http Server
$http = [System.Net.HttpListener]::new() 

# Hostname and port to listen on
$http.Prefixes.Add("http://localhost:8080/")

# Start the Http Server 
$http.Start()

# Log ready message to terminal 
if ($http.IsListening) {
    write-host " HTTP Server Ready!  " -f 'black' -b 'gre'
    write-host "now try going to $($http.Prefixes)" -f 'y'
    write-host "then try going to $($http.Prefixes)other/path" -f 'y'
}

try {
# INFINTE LOOP
# Used to listen for requests
while ($http.IsListening) {

    # Get Request Url
    # When a request is made in a web browser the GetContext() method will return a request object
    # Our route examples below will use the request object properties to decide how to respond
    $contextTask = $http.GetContextAsync()

    # Waits in 200ms increments for a request. We do this to allow pipeline stops to be processed (i.e. CTRL+C)
    # Credit: https://www.reddit.com/r/PowerShell/comments/9n2q03/comment/e7ju5w4/?utm_source=share&utm_medium=web2x&context=3
    while (-not $contextTask.AsyncWaitHandle.WaitOne(200)) { }
    $context = $contextTask.GetAwaiter().GetResult()

    $dir = [System.Web.HttpUtility]::UrlDecode($context.Request.RawUrl)

    write-host "$($context.Request.UserHostAddress)  =>  $($context.Request.Url)" -f 'mag'

    $staticFilePath = $websiteRoot + $dir
    if ($context.Request.HttpMethod -eq 'GET' -and (Test-Path $staticFilePath) -and (Get-Item $staticFilePath) -is [IO.DirectoryInfo]) {

        $isRoot = $False
        if ($context.Request.RawUrl -gt '/') {
            $isRoot = $True
        }

        $li = "<li><a href=""../"">..</a></li>"
        ForEach ($item in Get-ChildItem -Path $staticFilePath) {
            if ($isRoot -eq $True) {
                $itemPath = $staticFilePath + $item.Name
            } else {
                $itemPath = $staticFilePath + "/" + $item.Name
            }
            if ((Get-Item $itemPath) -is [IO.DirectoryInfo]) {
                $li += "<li><a href=""$($item)/"">"+$item.Name+"</a></li>"
            } else {
                $li += "<li><a href=""$($item)"">"+$item.Name+"</a></li>"
            }
        }

        $title = "Directory listing for " + $dir
        $html = "<!DOCTYPE html><html><head><meta charset=""utf-8""> <title>$title</title></head><body><h1>$title</h1><hr><ul>$li</ul><hr></body></html>"

        #resposed to the request
        $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) # convert htmtl to bytes
        $context.Response.ContentType = "text/html; charset=utf-8"
        $context.Response.ContentLength64 = $buffer.Length
        $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
        $context.Response.OutputStream.Close() # close the response
        continue
    }

    if ($context.Request.RawUrl -match '^/cgi-bin(.*)|^/htcgi(.*)' -and (Test-Path $staticFilePath) -and (Get-Item $staticFilePath) -is [IO.fileinfo]) {

        $isExecutablefile = $false
        $ext = [System.IO.Path]::GetExtension($staticFilePath)
        $cgi = $cgiHash[$ext]
        if ($cgi -gt $null) {
            if ($cgi -eq ".cgi" -or $cgi -eq ".exe") {
                $isExecutablefile = $true
            }
        } else {
            $isExecutablefile = $true
        }

        $standardInput = [System.IO.StreamReader]::new($context.Request.InputStream).ReadToEnd()
        $p = [System.Diagnostics.Process]::new()

        if ($isExecutablefile -eq $false) {
            $p.StartInfo.FileName = $cgi
            $p.StartInfo.Arguments = $staticFilePath
        } else {
            $p.StartInfo.FileName = $staticFilePath
        }

        $p.StartInfo.UseShellExecute = $false
        $p.StartInfo.RedirectStandardOutput = $true
        $p.StartInfo.RedirectStandardInput = $true

         # 这里是设置环境变量
        $headers = $context.Request.Headers
        $headers.Add("REQUEST_METHOD", $($context.Request.HttpMethod))
        $headers.Add("SERVER_PROTOCOL", "HTTP/$($context.Request.ProtocolVersion)")
        ForEach ($key in $headers.AllKeys) {
            $p.StartInfo.Environment.Add($key, $headers[$key])
        }

        $p.Start()
        $pw = $p.StandardInput;
        $pw.WriteLine($standardInput) # 这里是设置标准输入
        $pw.Close()
        $responseRaw = $p.StandardOutput.ReadToEnd() # 这里是获得标准输出

        if ($p.ExitCode -eq 0) {

            # 分割响应头和响应报文
            $t = $responseRaw.Split("`r`n`r`n", 2)
            # $t = $responseRaw -split "`r`n`r`n", 2
            # 这里应该还需要一些容错的处理
            $responseHeaders = $t[0]
            $responseBody = $t[1]

            ForEach($headerLine in $responseHeaders.Split("`r`n")) {
                $keyValue = $headerLine.Split(": ")
                if ($keyValue.Length -gt 2) {
                    continue
                }
                $key = $keyValue[0]
                $value = $keyValue[1]
                $context.Response.AddHeader($key, $value)
            }

            $buffer = [System.Text.Encoding]::UTF8.GetBytes($responseBody) # convert htmtl to bytes
            # $context.Response.ContentType = "text/html; charset=utf-8"
            $context.Response.ContentLength64 = $buffer.Length
            $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
            $context.Response.OutputStream.Close() # close the response
        } else {
            [string]$html = "<h1>500 Server Error</h1>"
            $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) # convert htmtl to bytes
            $context.Response.StatusCode = 500
            $context.Response.StatusDescription = "Server Error"
            $context.Response.ContentType = "text/html; charset=utf-8"
            $context.Response.ContentLength64 = $buffer.Length
            $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
            $context.Response.OutputStream.Close() # close the response
        }

        continue
    }

    if ($context.Request.HttpMethod -eq 'GET' -and (Test-Path $staticFilePath) -and (Get-Item $staticFilePath) -is [IO.fileinfo]) {

        $buffer = [System.Text.Encoding]::UTF8.GetBytes((Get-Content -Raw -Encoding utf8 $staticFilePath))
        $mime = $mimeHash[[System.IO.Path]::GetExtension($staticFilePath)]

        if ($mime -gt $null) {
            # $context.Response.ContentType = $mime + "; charset=utf-8"
            if ($mime -match '^text') {
                $mime += "; charset=utf-8"
            }
            $context.Response.ContentType = $mime
        }
        $context.Response.ContentLength64 = $buffer.Length
        $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
        $context.Response.OutputStream.Close() # close the response
        continue
    }

    [string]$html = "<h1>404 Not Found</h1>"
    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) # convert htmtl to bytes
    $context.Response.StatusCode = 404
    $context.Response.StatusDescription = "Not Found"
    $context.Response.ContentType = "text/html; charset=utf-8"
    $context.Response.ContentLength64 = $buffer.Length
    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) #stream to broswer
    $context.Response.OutputStream.Close() # close the response
    continue
} 
}
catch {
  Write-Host "An error occurred:"
  Write-Host $_
}
finally {
    $http.Stop()
}

在笔者原本的设想里,是写一个纯 PowerShell 的脚本,即使要调用 .NET 的类库起码也要保证跨平台能用。 但后来笔者发觉,这个设想是实现起来有点困难,最后还是通过调用 .NET 的类库实现。 PowerShell 的强大主要是体现在可以很方便地调用 .NET 的类库,基本没有障碍。 但离开 .NET 的类库, PowerShell 的表现力其实也没有比 bash 好多少。