Private/FileServer/Start-VmFileServer.ps1
|
function Start-VmFileServer { <# .SYNOPSIS Starts an HTTP file server on the Hyper-V internal switch host adapter. .DESCRIPTION Creates a temporary staging directory, opens a Windows Firewall inbound rule for the port, starts System.Net.HttpListener, and spawns a background runspace that serves files from the staging directory. The caller MUST call Stop-VmFileServer in a finally block so the listener, firewall rule, and staging directory are always cleaned up. .PARAMETER VmIpAddress IPv4 address of any VM on the switch. Used to locate the host adapter IP via Get-VmSwitchHostIp. Mutually exclusive with -HostIp. .PARAMETER HostIp Explicit host IP to bind the listener to. Use this in tests or when the host IP is already known. Mutually exclusive with -VmIpAddress. .PARAMETER Port TCP port for the HTTP listener. Defaults to 8745. .OUTPUTS PSCustomObject with properties: HostIp, Port, BaseUrl, StagingDir, Listener, Runspace, PowerShell, FirewallRuleName .EXAMPLE $server = Start-VmFileServer -VmIpAddress '10.10.0.50' -Port 8745 try { ... } finally { Stop-VmFileServer -Server $server } #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ParameterSetName = 'ByVmIp')] [string] $VmIpAddress, [Parameter(Mandatory, ParameterSetName = 'ByHostIp')] [string] $HostIp, [Parameter()] [int] $Port = 8745 ) if ($PSCmdlet.ParameterSetName -eq 'ByVmIp') { $HostIp = Get-VmSwitchHostIp -VmIpAddress $VmIpAddress } $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) "VmFileServer-$Port" New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null # Open the firewall before the listener starts so no connection is accepted # before the rule is in place (defence in depth - the rule is what controls # which hosts can reach the port on the internal switch). $firewallRuleName = "VmFileServer-$Port" New-NetFirewallRule ` -DisplayName $firewallRuleName ` -Name $firewallRuleName ` -Direction Inbound ` -Protocol TCP ` -LocalPort $Port ` -Action Allow | Out-Null $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add("http://${HostIp}:${Port}/") $listener.Start() # Isolated runspace so the caller's thread is not blocked. # The loop exits when Listener.Stop() causes GetContext() to throw. $ps = [powershell]::Create() $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() $runspace.Open() $ps.Runspace = $runspace $null = $ps.AddScript({ param($Listener, $StagingDir) while ($true) { try { $ctx = $Listener.GetContext() } catch { # Listener.Stop() raises an exception here - that is the # intended exit signal, not an error condition. break } $req = $ctx.Request $resp = $ctx.Response # Strip the leading slash to obtain a bare filename. $fileName = $req.Url.LocalPath.TrimStart('/') $filePath = Join-Path $StagingDir $fileName if (Test-Path $filePath) { $fileInfo = [System.IO.FileInfo]::new($filePath) $resp.StatusCode = 200 $resp.ContentLength64 = $fileInfo.Length # CopyTo avoids PowerShell's byte[]-to-string coercion that # occurs when calling Write(byte[], int, int) directly. $fileStream = [System.IO.File]::OpenRead($filePath) $fileStream.CopyTo($resp.OutputStream) $fileStream.Dispose() } else { $resp.StatusCode = 404 } $resp.OutputStream.Close() } }) $null = $ps.AddParameters(@{ Listener = $listener StagingDir = $stagingDir }) $null = $ps.BeginInvoke() [PSCustomObject]@{ HostIp = $HostIp Port = $Port BaseUrl = "http://${HostIp}:${Port}" StagingDir = $stagingDir Listener = $listener Runspace = $runspace PowerShell = $ps FirewallRuleName = $firewallRuleName } } |