Start-HttpServer.ps1
|
<#PSScriptInfo
.VERSION 1.0.1 .GUID fed22550-fc5f-4809-9c9c-90c3be94432e .AUTHOR MrDRamos .TAGS http server web server static files python http.server .PROJECTURI https://github.com/MrDRamos/PsUtils/blob/master/WebServer/Start-HttpServer.ps1 .RELEASENOTES Initial release. .PRIVATEDATA #> <# .SYNOPSIS Starts a simple static HTTP server for a local directory. With the same behavior as Python's simple HTTP server (http.server). .DESCRIPTION Starts a lightweight static file server using the built-in .NET HttpListener class, with behavior similar to Python's `python -m http.server` command. No additional packages or web framework are required. The server serves files below SiteRoot, returns index.html for directory URLs when present, and generates a directory listing when no index.html exists. It supports GET and HEAD requests, common content types, directory redirects, and conditional caching with Last-Modified and If-Modified-Since. Missing paths and requests that resolve outside SiteRoot return 404. By default, the server listens on all network interfaces at port 8000, similar to Python's http.server. Use LocalOnly to restrict access to this computer at http://localhost:8000/. LAN access requires an elevated PowerShell session and an enabled inbound Windows Firewall rule for the selected TCP port. This script is intended for local development, testing, demonstrations, and quick file sharing. It is not intended to replace a production web server. .PARAMETER SiteRoot The root directory of the site to serve. Defaults to the directory containing this script. .PARAMETER Port The port on which the server will listen. Defaults to 8000. .PARAMETER LocalOnly Restricts the listener to localhost. Without this switch, the server listens on all network interfaces and requires elevation plus an inbound Windows Firewall rule. .EXAMPLE .\Start-HttpServer.ps1 Serves the directory containing this script at port 8000 on all network interfaces. .EXAMPLE .\Start-HttpServer.ps1 -SiteRoot 'C:\Websites\Example' -Port 8080 Serves the specified directory at port 8080 on all network interfaces. .EXAMPLE .\Start-HttpServer.ps1 -LocalOnly Serves the directory containing this script only to this computer at http://localhost:8000/. .EXAMPLE http://localhost:8000/assets/ After starting the server, opens the assets directory. The folder path comes after the port: http://host:port/folder/. .NOTES Press Ctrl+C to stop the server. #> [CmdletBinding()] param( [string]$SiteRoot = $null, [ValidateRange(1, 65535)] [int]$Port = 8000, [switch]$LocalOnly ) #Region Helper Functions function Get-LocalLanAddress { [array]$LanAddresseS = $null foreach ($Adapter in (Get-NetAdapter | Where-Object Status -EQ 'Up')) { [array]$IpAddresseS = Get-NetIPAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } foreach ($IpAddress in $IpAddresseS) { $LanAddresseS += [PsCustomObject]@{ AdapterName = $Adapter.Name; IpAddress = $IpAddress.IpAddress } } } return $LanAddresseS <# $AdapterIndexeS = Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -ExpandProperty ifIndex Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceIndex -in $AdapterIndexeS -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | Select-Object -ExpandProperty IPAddress #> } <## Unit Test Get-LocalLanAddress exit #> function Test-IsAdministrator { $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() $currentPrincipal = [Security.Principal.WindowsPrincipal]::new($currentIdentity) return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Test-LanFirewallPortOpen { param( [int]$Port ) $activeProfiles = Get-NetConnectionProfile | Where-Object { $_.IPv4Connectivity -ne 'Disconnected' } | Select-Object -ExpandProperty NetworkCategory -Unique $allowedRules = foreach ($rule in (Get-NetFirewallRule -Enabled True -Direction Inbound -Action Allow)) { if ($rule.Profile -eq 'Any' -or $activeProfiles | Where-Object { $_ -in $rule.Profile }) { $rule } } foreach ($rule in $allowedRules) { $portFilters = Get-NetFirewallPortFilter -AssociatedNetFirewallRule $rule if ($portFilters | Where-Object { $_.Protocol -eq 'TCP' -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq $Port) }) { return $true } } return $false } function Get-DirectoryListingHtml { param( [string]$DirectoryPath, [string]$RequestPath ) $displayPath = "/$($RequestPath.Trim('/'))/" -replace '^//$', '/' $encodedPath = [System.Net.WebUtility]::HtmlEncode($displayPath) $listing = [System.Text.StringBuilder]::new() [void]$listing.AppendLine('<!DOCTYPE html>') [void]$listing.AppendLine("<html><head><meta charset='utf-8'><title>Directory listing for $encodedPath</title></head><body>") [void]$listing.AppendLine("<h1>Directory listing for $encodedPath</h1><hr><ul>") if ($RequestPath.Trim('/')) { [void]$listing.AppendLine("<li><a href='../'>../</a></li>") } foreach ($entry in (Get-ChildItem -LiteralPath $DirectoryPath -Force | Sort-Object Name)) { $suffix = if ($entry.PSIsContainer) { '/' } else { '' } $href = [System.Uri]::EscapeDataString($entry.Name) + $suffix $name = [System.Net.WebUtility]::HtmlEncode($entry.Name + $suffix) [void]$listing.AppendLine("<li><a href='$href'>$name</a></li>") } [void]$listing.AppendLine('</ul><hr></body></html>') return $listing.ToString() } function Get-ContentType { param( [string]$FilePath ) $extension = [System.IO.Path]::GetExtension($FilePath).ToLowerInvariant() if ([string]::IsNullOrEmpty($extension)) { return 'application/octet-stream' } $registeredContentType = [Microsoft.Win32.Registry]::GetValue("HKEY_CLASSES_ROOT\$extension", 'Content Type', $null) if ($registeredContentType) { return $registeredContentType } $fallbackContentTypes = @{ '.avif' = 'image/avif' '.bmp' = 'image/bmp' '.css' = 'text/css; charset=utf-8' '.csv' = 'text/csv; charset=utf-8' '.gif' = 'image/gif' '.htm' = 'text/html; charset=utf-8' '.html' = 'text/html; charset=utf-8' '.ico' = 'image/x-icon' '.jpeg' = 'image/jpeg' '.jpg' = 'image/jpeg' '.js' = 'text/javascript; charset=utf-8' '.json' = 'application/json; charset=utf-8' '.map' = 'application/json; charset=utf-8' '.mjs' = 'text/javascript; charset=utf-8' '.mp3' = 'audio/mpeg' '.mp4' = 'video/mp4' '.ogg' = 'audio/ogg' '.ogv' = 'video/ogg' '.pdf' = 'application/pdf' '.png' = 'image/png' '.txt' = 'text/plain; charset=utf-8' '.wasm' = 'application/wasm' '.wav' = 'audio/wav' '.webm' = 'video/webm' '.webp' = 'image/webp' '.woff' = 'font/woff' '.woff2' = 'font/woff2' '.xml' = 'application/xml; charset=utf-8' } $contentType = $fallbackContentTypes[$extension] if ($null -eq $contentType) { $contentType = 'application/octet-stream' } return $contentType } #EndRegion Helper Functions #Region Initialization if (-not $LocalOnly) { if (!(Test-IsAdministrator)) { @" LAN access requires an elevated PowerShell session. Try again in an elevated PowerShell session (Run as Administrator)'. To serve this computer only, run '.\Start-HttpServer.ps1 -LocalOnly'. "@ | Write-Host -ForegroundColor Red exit 1 } if (!(Test-LanFirewallPortOpen -Port $Port)) { @" Windows Firewall does not allow inbound TCP traffic on port $Port for the active network profile. Run this command in an elevated PowerShell session, then start the server again: New-NetFirewallRule -DisplayName 'Allow inbound TCP traffic' -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Private "@ | Write-Host -ForegroundColor Red exit 1 } } if ([string]::IsNullOrWhiteSpace($SiteRoot)) { $SiteRoot = $PSScriptRoot } if (-not (Test-Path -LiteralPath $SiteRoot -PathType Container)) { Write-Host "Site root '$SiteRoot' is not an existing directory." -ForegroundColor Red exit 1 } $siteRoot = (Resolve-Path -LiteralPath $SiteRoot).Path $siteRootPrefix = $siteRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar $listener = [System.Net.HttpListener]::new() $hostName = if ($LocalOnly) { 'localhost' } else { '+' } $listener.Prefixes.Add("http://${hostName}:$Port/") #EndRegion Initialization try { #Region Server Setup try { $listener.Start() } catch { throw "Failed to start the server on port $Port. $($_.Exception.Message)" } Write-Host "Serving $siteRoot" -ForegroundColor Cyan if (-not $LocalOnly) { [array]$LanAddresseS = Get-LocalLanAddress if ($LanAddresseS.Count -eq 1) { Write-Host "Open http://$($LanAddresseS[0].IpAddress):$Port/" } elseif ($LanAddresseS.Count -gt 1) { Write-Host 'Open one of these LAN addresses:' foreach ($LanAddress in $LanAddresseS) { Write-Host ("{0,-30} - {1}" -f "http://$($LanAddress.IpAddress):$Port/", $LanAddress.AdapterName) } } else { Write-Host "Open http://localhost:$Port/ locally. No active physical IPv4 LAN address was found." } } else { Write-Host "Open http://localhost:$Port/" } Write-Host 'Press Ctrl+C to stop the server.' -ForegroundColor Cyan #EndRegion Server Setup #Region Request Handling while ($listener.IsListening) { # https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener.begingetcontext $pendingContext = $listener.BeginGetContext($null, $null) try { while (-not $pendingContext.AsyncWaitHandle.WaitOne(200)) { # Return to PowerShell regularly so Ctrl+C can interrupt the script. } $context = $listener.EndGetContext($pendingContext) } finally { $pendingContext.AsyncWaitHandle.Dispose() } if ($VerbosePreference -eq 'Continue') { Write-Host "Received request from $($context.Request.RemoteEndPoint): $($context.Request.HttpMethod) $($context.Request.Url.AbsolutePath)" } $requestPath = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath).TrimStart('/') $filePath = [System.IO.Path]::GetFullPath((Join-Path $siteRoot $requestPath)) $isWithinSiteRoot = $filePath -eq $siteRoot -or $filePath.StartsWith($siteRootPrefix, [System.StringComparison]::OrdinalIgnoreCase) if (!$isWithinSiteRoot -or !(Test-Path -LiteralPath $filePath)) { $context.Response.StatusCode = 404 $body = [System.Text.Encoding]::UTF8.GetBytes('Not found') $context.Response.ContentType = 'text/plain; charset=utf-8' $context.Response.ContentLength64 = $body.Length if ($context.Request.HttpMethod -ne 'HEAD') { $context.Response.OutputStream.Write($body, 0, $body.Length) } $context.Response.Close() continue } if (Test-Path -LiteralPath $filePath -PathType Container) { if (-not $context.Request.Url.AbsolutePath.EndsWith('/')) { $context.Response.StatusCode = 301 $context.Response.RedirectLocation = $context.Request.Url.AbsolutePath + '/' + $context.Request.Url.Query $context.Response.Close() continue } $indexPath = Join-Path $filePath 'index.html' if (Test-Path -LiteralPath $indexPath -PathType Leaf) { $filePath = $indexPath } else { $body = [System.Text.Encoding]::UTF8.GetBytes((Get-DirectoryListingHtml -DirectoryPath $filePath -RequestPath $requestPath)) $context.Response.ContentType = 'text/html; charset=utf-8' $context.Response.ContentLength64 = $body.Length if ($context.Request.HttpMethod -ne 'HEAD') { $context.Response.OutputStream.Write($body, 0, $body.Length) } $context.Response.Close() continue } } $context.Response.ContentType = Get-ContentType -FilePath $filePath $fileInfo = Get-Item -LiteralPath $filePath $lastModifiedUtc = $fileInfo.LastWriteTimeUtc.AddTicks(-($fileInfo.LastWriteTimeUtc.Ticks % [TimeSpan]::TicksPerSecond)) $context.Response.Headers['Last-Modified'] = $lastModifiedUtc.ToString('R') $ifModifiedSince = $context.Request.Headers['If-Modified-Since'] $requestedModifiedTime = [DateTime]::MinValue if ($ifModifiedSince -and [DateTime]::TryParse($ifModifiedSince, [ref]$requestedModifiedTime) -and $lastModifiedUtc -le $requestedModifiedTime.ToUniversalTime()) { $context.Response.StatusCode = 304 $context.Response.Close() continue } $bytes = [System.IO.File]::ReadAllBytes($filePath) $context.Response.ContentLength64 = $bytes.Length if ($context.Request.HttpMethod -ne 'HEAD') { $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) } $context.Response.Close() } #EndRegion Request Handling } finally { if ($listener.IsListening) { $listener.Stop() } $listener.Close() } |