Private/Helpers.ps1
|
function Invoke-LissDataSource { [CmdletBinding()] param( [Parameter(Mandatory)][scriptblock]$ScriptBlock ) try { $data = @(& $ScriptBlock) [pscustomobject]@{ Status = 'Available' Error = $null Data = $data } } catch { [pscustomobject]@{ Status = 'Unavailable' Error = $_.Exception.Message Data = @() } } } function Get-LissTimeContext { [CmdletBinding()] param() $now = Get-Date [pscustomobject]@{ Timestamp = $now TimestampUtc = $now.ToUniversalTime() TimeZoneId = [TimeZoneInfo]::Local.Id UtcOffsetMinutes = [int][TimeZoneInfo]::Local.GetUtcOffset($now).TotalMinutes } } function Invoke-LissPingSample { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Target, [Parameter(Mandatory)][int]$TimeoutMilliseconds ) $ping = New-Object System.Net.NetworkInformation.Ping try { $reply = $ping.Send($Target, $TimeoutMilliseconds) [pscustomobject]@{ Target = $Target Timestamp = Get-Date Success = $reply.Status -eq [Net.NetworkInformation.IPStatus]::Success Status = [string]$reply.Status LatencyMilliseconds = if ($reply.Status -eq [Net.NetworkInformation.IPStatus]::Success) { [double]$reply.RoundtripTime } else { $null } Address = if ($reply.Address) { $reply.Address.IPAddressToString } else { $null } Error = $null } } catch { [pscustomobject]@{ Target = $Target Timestamp = Get-Date Success = $false Status = 'Error' LatencyMilliseconds = $null Address = $null Error = $_.Exception.Message } } finally { $ping.Dispose() } } function Get-LissLatencySummary { [CmdletBinding()] param( [Parameter(Mandatory)][object[]]$Sample, [string]$Target ) $successful = @($Sample | Where-Object { $_.Success -and $null -ne $_.LatencyMilliseconds }) $latencies = @($successful | ForEach-Object { [double]$_.LatencyMilliseconds } | Sort-Object) $sent = @($Sample).Count $received = $latencies.Count $p95 = $null if ($received -gt 0) { $p95Index = [Math]::Max(0, [Math]::Ceiling($received * 0.95) - 1) $p95 = $latencies[$p95Index] } [pscustomobject]@{ PSTypeName = 'LISSTech.WindowsNetworkDiagnostics.LatencySummary' Target = $Target Sent = $sent Received = $received LossPercentage = if ($sent -gt 0) { [Math]::Round((($sent - $received) / [double]$sent) * 100, 2) } else { 0.0 } MinimumMilliseconds = if ($received -gt 0) { $latencies[0] } else { $null } AverageMilliseconds = if ($received -gt 0) { [Math]::Round(($latencies | Measure-Object -Average).Average, 2) } else { $null } Percentile95Milliseconds = $p95 MaximumMilliseconds = if ($received -gt 0) { $latencies[-1] } else { $null } } } function Invoke-LissLatencyWindow { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Target, [Parameter(Mandatory)][int]$DurationSeconds, [Parameter(Mandatory)][int]$IntervalMilliseconds, [Parameter(Mandatory)][int]$TimeoutMilliseconds, [Diagnostics.Process]$WhileProcessRuns ) $samples = New-Object 'Collections.Generic.List[object]' $deadline = [DateTime]::UtcNow.AddSeconds($DurationSeconds) do { if ($WhileProcessRuns -and $WhileProcessRuns.HasExited) { break } $samples.Add((Invoke-LissPingSample -Target $Target -TimeoutMilliseconds $TimeoutMilliseconds)) $remaining = ($deadline - [DateTime]::UtcNow).TotalMilliseconds if ($remaining -gt 0) { Start-Sleep -Milliseconds ([int][Math]::Min($IntervalMilliseconds, $remaining)) } } while ([DateTime]::UtcNow -lt $deadline) $samples.ToArray() } function Get-LissAdapterStatisticsSnapshot { [CmdletBinding()] param() if (-not (Get-Command Get-NetAdapterStatistics -ErrorAction SilentlyContinue)) { throw 'Get-NetAdapterStatistics is unavailable.' } @(Get-NetAdapterStatistics -ErrorAction Stop | Select-Object Name, InterfaceDescription, ReceivedBytes, SentBytes, ReceivedUnicastPackets, SentUnicastPackets, ReceivedPacketErrors, OutboundPacketErrors, ReceivedDiscardedPackets, OutboundDiscardedPackets) } function Test-LissTcpPort { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Target, [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][int]$TimeoutMilliseconds ) $client = New-Object Net.Sockets.TcpClient $stopwatch = [Diagnostics.Stopwatch]::StartNew() try { $async = $client.BeginConnect($Target, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMilliseconds, $false)) { throw "TCP connection timed out after $TimeoutMilliseconds ms." } $client.EndConnect($async) [pscustomobject]@{ Target = $Target Port = $Port Success = $true ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds Error = $null } } catch { [pscustomobject]@{ Target = $Target Port = $Port Success = $false ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds Error = $_.Exception.Message } } finally { $stopwatch.Stop() $client.Close() } } function Invoke-LissPingExecutable { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Target, [Parameter(Mandatory)][int]$PayloadBytes, [Parameter(Mandatory)][int]$TimeoutMilliseconds, [switch]$DontFragment ) $arguments = @('-4', '-n', '1', '-w', [string]$TimeoutMilliseconds) if ($DontFragment) { $arguments += '-f' } if ($PayloadBytes -ge 0) { $arguments += @('-l', [string]$PayloadBytes) } $arguments += $Target $output = @(& "$env:SystemRoot\System32\PING.EXE" @arguments 2>&1 | ForEach-Object { [string]$_ }) [pscustomobject]@{ Success = $LASTEXITCODE -eq 0 ExitCode = $LASTEXITCODE Output = $output } } function Resolve-LissIperfExecutable { [CmdletBinding()] param([string]$Path) if ($Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "iperf3 executable '$Path' was not found. The module does not install iperf3." } return (Get-Item -LiteralPath $Path).FullName } $bundledPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Bin\x64\iperf3.exe' if (Test-Path -LiteralPath $bundledPath -PathType Leaf) { return $bundledPath } $command = Get-Command iperf3.exe -CommandType Application -ErrorAction SilentlyContinue if (-not $command) { $command = Get-Command iperf3 -CommandType Application -ErrorAction SilentlyContinue } if (-not $command) { throw 'iperf3 is unavailable. Install it separately through an approved process and provide -IperfPath if it is not on PATH.' } $command.Source } function Get-LissPktmonCapability { [CmdletBinding()] param() $missing = New-Object 'Collections.Generic.List[string]' $checks = @( [pscustomobject]@{ Name = 'BaseCommands' Arguments = @('help') Pattern = '(?is)\bfilter\b.*\bstart\b.*\bstop\b.*\bstatus\b.*\betl2pcap\b' } [pscustomobject]@{ Name = 'FilterByIpProtocolAndPort' Arguments = @('filter', 'add', 'help') Pattern = '(?is)(-i|--ip).*?(-t|--transport).*?(-p|--port)' } [pscustomobject]@{ Name = 'BoundedCircularCapture' Arguments = @('start', 'help') Pattern = '(?is)--capture.*?--pkt-size.*?--file-name.*?--file-size.*?--log-mode.*?circular' } [pscustomobject]@{ Name = 'PcapngConversion' Arguments = @('etl2pcap', 'help') Pattern = '(?is)etl2pcap.*?--out' } ) $details = foreach ($check in $checks) { $result = Invoke-LissPktmonCommand -ArgumentList $check.Arguments $text = $result.Output -join "`n" $supported = $result.ExitCode -eq 0 -and $text -match $check.Pattern if (-not $supported) { $missing.Add($check.Name) } [pscustomobject]@{ Capability = $check.Name Supported = $supported ExitCode = $result.ExitCode } } [pscustomobject]@{ PSTypeName = 'LISSTech.WindowsNetworkDiagnostics.PktmonCapability' Supported = $missing.Count -eq 0 MissingCapabilities = $missing.ToArray() Details = @($details) } } function ConvertTo-LissProcessArgumentString { [CmdletBinding()] param([Parameter(Mandatory)][string[]]$ArgumentList) (($ArgumentList | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' } else { $_ } }) -join ' ') } function Start-LissRedirectedProcess { [CmdletBinding()] param( [Parameter(Mandatory)][string]$FilePath, [Parameter(Mandatory)][string[]]$ArgumentList ) $workingDirectory = Join-Path ([IO.Path]::GetTempPath()) ("LISSTech.WindowsNetworkDiagnostics\" + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($workingDirectory) | Out-Null $stdoutPath = Join-Path $workingDirectory 'stdout.txt' $stderrPath = Join-Path $workingDirectory 'stderr.txt' $startInfo = New-Object Diagnostics.ProcessStartInfo $startInfo.FileName = $FilePath $startInfo.Arguments = ConvertTo-LissProcessArgumentString -ArgumentList $ArgumentList $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $process = New-Object Diagnostics.Process $process.StartInfo = $startInfo if (-not $process.Start()) { [IO.Directory]::Delete($workingDirectory, $true) throw "Failed to start '$FilePath'." } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() [pscustomobject]@{ Process = $process StandardOutputTask = $stdoutTask StandardErrorTask = $stderrTask WorkingDirectory = $workingDirectory StandardOutputPath = $stdoutPath StandardErrorPath = $stderrPath } } function Stop-LissProcessContext { [CmdletBinding()] param([Parameter(Mandatory)]$Context) if ($Context.Process) { try { if (-not $Context.Process.HasExited) { $Context.Process.Kill() $Context.Process.WaitForExit(5000) | Out-Null } } catch { Write-Warning "Failed to stop the child process: $($_.Exception.Message)" } } } function Complete-LissRedirectedProcess { [CmdletBinding()] param( [Parameter(Mandatory)]$Context, [Parameter(Mandatory)][int]$TimeoutMilliseconds ) try { if (-not $Context.Process.WaitForExit($TimeoutMilliseconds)) { Stop-LissProcessContext -Context $Context throw "The child process exceeded the $TimeoutMilliseconds ms execution limit." } $stdout = $Context.StandardOutputTask.GetAwaiter().GetResult() $stderr = $Context.StandardErrorTask.GetAwaiter().GetResult() [pscustomobject]@{ ExitCode = $Context.Process.ExitCode StandardOutput = $stdout StandardError = $stderr } } finally { $Context.Process.Dispose() if ([IO.Directory]::Exists($Context.WorkingDirectory)) { [IO.Directory]::Delete($Context.WorkingDirectory, $true) } } } function New-LissIperfArgumentList { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][int]$DurationSeconds, [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction ) $arguments = @('-c', $Server, '-p', [string]$Port, '-t', [string]$DurationSeconds, '-J') if ($Direction -eq 'Download') { $arguments += '-R' } $arguments } function Invoke-LissIperfProcess { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Executable, [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][int]$DurationSeconds, [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction ) $arguments = New-LissIperfArgumentList -Server $Server -Port $Port -DurationSeconds $DurationSeconds -Direction $Direction $context = Start-LissRedirectedProcess -FilePath $Executable -ArgumentList $arguments $result = Complete-LissRedirectedProcess -Context $context -TimeoutMilliseconds (($DurationSeconds + 20) * 1000) if ($result.ExitCode -ne 0) { throw "iperf3 failed with exit code $($result.ExitCode): $($result.StandardError.Trim())" } try { $parsed = $result.StandardOutput | ConvertFrom-Json -ErrorAction Stop } catch { throw "iperf3 returned invalid JSON: $($_.Exception.Message)" } [pscustomobject]@{ Arguments = $arguments Result = $parsed ErrorText = $result.StandardError } } function Start-LissIperfLoad { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Executable, [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][int]$DurationSeconds, [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction ) $arguments = New-LissIperfArgumentList -Server $Server -Port $Port -DurationSeconds $DurationSeconds -Direction $Direction $context = Start-LissRedirectedProcess -FilePath $Executable -ArgumentList $arguments $context | Add-Member -NotePropertyName Arguments -NotePropertyValue $arguments $context } function Complete-LissIperfLoad { [CmdletBinding()] param( [Parameter(Mandatory)]$Context, [Parameter(Mandatory)][int]$DurationSeconds ) $result = Complete-LissRedirectedProcess -Context $Context -TimeoutMilliseconds (($DurationSeconds + 20) * 1000) if ($result.ExitCode -ne 0) { throw "iperf3 failed with exit code $($result.ExitCode): $($result.StandardError.Trim())" } try { $result.StandardOutput | ConvertFrom-Json -ErrorAction Stop } catch { throw "iperf3 returned invalid JSON: $($_.Exception.Message)" } } function Test-LissAdministrator { [CmdletBinding()] param() $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Invoke-LissPktmonCommand { [CmdletBinding()] param([Parameter(Mandatory)][string[]]$ArgumentList) $output = @(& pktmon.exe @ArgumentList 2>&1 | ForEach-Object { [string]$_ }) [pscustomobject]@{ ExitCode = $LASTEXITCODE Output = $output } } function Get-LissPktmonState { [CmdletBinding()] param() $status = Invoke-LissPktmonCommand -ArgumentList @('status') if ($status.ExitCode -ne 0) { throw "Unable to query pktmon status: $($status.Output -join ' ')" } $filters = Invoke-LissPktmonCommand -ArgumentList @('filter', 'list') if ($filters.ExitCode -ne 0) { throw "Unable to query pktmon filters: $($filters.Output -join ' ')" } $statusText = $status.Output -join "`n" $filterText = $filters.Output -join "`n" [pscustomobject]@{ IsCaptureActive = $statusText -match '(?im)^\s*(Running|Logger is running|Capture is running)\b' HasFilters = -not ([string]::IsNullOrWhiteSpace($filterText) -or $filterText -match '(?im)\b(no filters|filter list is empty|none)\b') StatusText = $statusText FilterText = $filterText } } |