Public/network/Trace-NetworkRoute.ps1

#Requires -Version 5.1

function Trace-NetworkRoute {
    <#
        .SYNOPSIS
            Performs a traceroute to a target host and returns structured hop-by-hop results
 
        .DESCRIPTION
            Sends ICMP packets with incrementing TTL values to trace the network path
            to a destination. Each hop is returned as a structured object with hop number,
            IP address, hostname (via reverse DNS), and round-trip latency.
 
            Uses System.Net.NetworkInformation.Ping with controlled TTL values,
            which is more reliable and parseable than the native tracert.exe.
 
        .PARAMETER ComputerName
            One or more target hostnames or IP addresses to trace. Accepts pipeline input.
 
        .PARAMETER MaxHops
            Maximum number of hops (TTL). Default: 30. Valid range: 1-128.
 
        .PARAMETER TimeoutMs
            Timeout per hop in milliseconds. Default: 3000. Valid range: 500-30000.
 
        .PARAMETER PingsPerHop
            Number of pings per hop for latency averaging. Default: 3. Valid range: 1-10.
 
        .PARAMETER ResolveHostnames
            Attempt reverse DNS lookup for each hop IP. Default: true.
            Disable for faster traces when hostnames are not needed.
 
        .EXAMPLE
            Trace-NetworkRoute -ComputerName '8.8.8.8'
 
            Traces the route to Google DNS.
 
        .EXAMPLE
            Trace-NetworkRoute -ComputerName 'srv01.corp.local' -MaxHops 15
 
            Traces route to an internal server with max 15 hops.
 
        .EXAMPLE
            '8.8.8.8', '1.1.1.1' | Trace-NetworkRoute -ResolveHostnames:$false
 
            Traces routes to two targets without reverse DNS (faster).
 
        .OUTPUTS
            PSWinOps.TraceRouteHop
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-03-21
            Requires: PowerShell 5.1+ / Windows only
            Permissions: No admin required (ICMP may be blocked by firewall)
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
    #>

    [CmdletBinding()]
    [OutputType('PSWinOps.TraceRouteHop')]
    param (
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName', 'Destination')]
        [string[]]$ComputerName,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 128)]
        [int]$MaxHops = 30,

        [Parameter(Mandatory = $false)]
        [ValidateRange(500, 30000)]
        [int]$TimeoutMs = 3000,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 10)]
        [int]$PingsPerHop = 3,

        [Parameter(Mandatory = $false)]
        [bool]$ResolveHostnames = $true
    )

    begin {
        Write-Verbose "[$($MyInvocation.MyCommand)] Starting route trace (MaxHops: $MaxHops, Timeout: ${TimeoutMs}ms)"
    }

    process {
        foreach ($targetComputer in $ComputerName) {
            try {
                Write-Verbose "[$($MyInvocation.MyCommand)] Tracing route to '$targetComputer'"

                # Resolve target IP first
                try {
                    $targetIP = [System.Net.Dns]::GetHostAddresses($targetComputer) |
                        Where-Object { $_.AddressFamily -eq 'InterNetwork' } |
                        Select-Object -First 1
                    if (-not $targetIP) {
                        Write-Error "[$($MyInvocation.MyCommand)] Cannot resolve '$targetComputer' to an IPv4 address"
                        continue
                    }
                    $targetIPString = $targetIP.ToString()
                } catch {
                    Write-Error "[$($MyInvocation.MyCommand)] DNS resolution failed for '$targetComputer': $_"
                    continue
                }

                $pingSender = New-Object System.Net.NetworkInformation.Ping
                $buffer = [byte[]]::new(32)
                $timestamp = Get-Date -Format 'o'
                $reachedTarget = $false

                for ($ttl = 1; $ttl -le $MaxHops; $ttl++) {
                    $hopIP = $null
                    $latencies = [System.Collections.Generic.List[double]]::new()
                    $timedOut = $true

                    $pingOptions = New-Object System.Net.NetworkInformation.PingOptions($ttl, $true)

                    for ($p = 0; $p -lt $PingsPerHop; $p++) {
                        try {
                            $reply = $pingSender.Send($targetIPString, $TimeoutMs, $buffer, $pingOptions)

                            if ($reply.Status -eq [System.Net.NetworkInformation.IPStatus]::TtlExpired -or
                                $reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
                                $timedOut = $false
                                $hopIP = $reply.Address.ToString()
                                $latencies.Add($reply.RoundtripTime)
                            }
                        } catch {
                            Write-Verbose "[$($MyInvocation.MyCommand)] Ping to '$targetIPString' (TTL=$ttl, attempt $($p+1)) failed: $_"
                        }
                    }

                    # Resolve hostname if requested
                    $hostname = $null
                    if ($hopIP -and $ResolveHostnames) {
                        try {
                            $hostEntry = [System.Net.Dns]::GetHostEntry($hopIP)
                            $hostname = $hostEntry.HostName
                        } catch {
                            $hostname = $null
                        }
                    }

                    # Compute latency stats for this hop
                    $avgMs = $null
                    $minMs = $null
                    $maxMs = $null
                    if ($latencies.Count -gt 0) {
                        $avgMs = [math]::Round(($latencies | Measure-Object -Average).Average, 1)
                        $minMs = [math]::Round(($latencies | Measure-Object -Minimum).Minimum, 1)
                        $maxMs = [math]::Round(($latencies | Measure-Object -Maximum).Maximum, 1)
                    }

                    [PSCustomObject]@{
                        PSTypeName   = 'PSWinOps.TraceRouteHop'
                        Destination  = $targetComputer
                        Hop          = $ttl
                        IPAddress    = if ($hopIP) { $hopIP } else { '*' }
                        Hostname     = if ($hostname) { $hostname } else { '' }
                        AvgMs        = $avgMs
                        MinMs        = $minMs
                        MaxMs        = $maxMs
                        Status       = if ($timedOut) { 'TimedOut' } elseif ($hopIP -eq $targetIPString) { 'Reached' } else { 'Hop' }
                        Timestamp    = $timestamp
                    }

                    # Stop if we reached the destination
                    if ($hopIP -eq $targetIPString) {
                        $reachedTarget = $true
                        break
                    }
                }

                $pingSender.Dispose()

                if (-not $reachedTarget) {
                    Write-Warning "[$($MyInvocation.MyCommand)] Destination '$targetComputer' not reached within $MaxHops hops"
                }
            } catch {
                Write-Error "[$($MyInvocation.MyCommand)] Failed tracing '$targetComputer': $_"
            }
        }
    }

    end {
        Write-Verbose "[$($MyInvocation.MyCommand)] Completed route trace"
    }
}