PSDiscoveryProtocol.psm1

#region classes
class DiscoveryProtocolPacket
{
    [string]$MachineName
    [datetime]$TimeCreated
    [int]$FragmentSize
    [byte[]]$Fragment
}
#endregion

#region function Invoke-DiscoveryProtocolCapture
function Invoke-DiscoveryProtocolCapture {

<#
 
.SYNOPSIS
 
    Capture CDP or LLDP packets on local or remote computers
 
.DESCRIPTION
 
    Capture discovery protocol packets on local or remote computers. This function will start a packet capture and save the
    captured packets in a temporary ETL file. Only the first discovery protocol packet in the ETL file will be returned.
 
    Cisco devices will by default send CDP announcements every 60 seconds. Default interval for LLDP packets is 30 seconds.
 
    Requires elevation (Run as Administrator).
    WinRM and PowerShell remoting must be enabled on the target computer.
 
.PARAMETER ComputerName
 
    Specifies one or more computers on which to capture packets. Defaults to $env:COMPUTERNAME.
 
.PARAMETER Duration
 
    Specifies the duration for which the discovery protocol packets are captured, in seconds.
 
    If Type is LLDP, Duration defaults to 32. If Type is CDP or omitted, Duration defaults to 62.
 
.PARAMETER Type
 
    Specifies what type of packet to capture, CDP or LLDP. If omitted, both types will be captured,
    but only the first one will be returned.
 
    If Type is LLDP, Duration defaults to 32. If Type is CDP or omitted, Duration defaults to 62.
 
.OUTPUTS
 
    DiscoveryProtocolPacket
 
.EXAMPLE
 
    PS C:\> $Packet = Invoke-DiscoveryProtocolCapture -Type CDP -Duration 60
    PS C:\> Get-DiscoveryProtocolData -Packet $Packet
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
.EXAMPLE
 
    PS C:\> Invoke-DiscoveryProtocolCapture -Computer COMPUTER1 | Get-DiscoveryProtocolData
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
.EXAMPLE
 
    PS C:\> 'COMPUTER1', 'COMPUTER2' | Invoke-DiscoveryProtocolCapture | Get-DiscoveryProtocolData
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
    Port : FastEthernet0/2
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 20
    Computer : COMPUTER2
    Type : CDP
 
#>


    [CmdletBinding()]
    [OutputType('DiscoveryProtocolPacket')]
    [Alias('Capture-CDPPacket', 'Capture-LLDPPacket')]
    param(
        [Parameter(Position=0,
            ValueFromPipeline=$true,
            ValueFromPipelineByPropertyName=$true)]
        [Alias('CN', 'Computer')]
        [String[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Position=1)]
        [Int16]$Duration = $(if ($Type -eq 'LLDP') { 32 } else { 62 }),

        [Parameter(Position=2)]
        [ValidateSet('CDP', 'LLDP')]
        [String]$Type
    )

    begin {
        $Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
        $Principal = New-Object Security.Principal.WindowsPrincipal $Identity
        if (-not $Principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) {
            throw 'Invoke-DiscoveryProtocolCapture requires elevation. Please run PowerShell as administrator.'
        }

        if ($MyInvocation.InvocationName -ne $MyInvocation.MyCommand) {
            if ($MyInvocation.InvocationName -eq 'Capture-CDPPacket') { $Type = 'CDP' }
            if ($MyInvocation.InvocationName -eq 'Capture-LLDPPacket') { $Type = 'LLDP' }
            $Warning = '{0} has been deprecated, please use {1}' -f $MyInvocation.InvocationName, $MyInvocation.MyCommand
            Write-Warning $Warning
        }
    }

    process {

        foreach ($Computer in $ComputerName) {

            try {
                $CimSession = New-CimSession -ComputerName $Computer -ErrorAction Stop
            } catch {
                Write-Warning "Unable to create CimSession. Please make sure WinRM and PSRemoting is enabled on $Computer."
                continue
            }

            $ETLFile = Invoke-Command -ComputerName $Computer -ScriptBlock {
                $TempFile = New-TemporaryFile
                Rename-Item -Path $TempFile.FullName -NewName $TempFile.FullName.Replace('.tmp', '.etl') -PassThru
            }

            $Adapter = Get-NetAdapter -Physical -CimSession $CimSession |
                Where-Object {$_.Status -eq 'Up' -and $_.InterfaceType -eq 6} |
                Select-Object -First 1 Name, MacAddress

            $MACAddress = [PhysicalAddress]::Parse($Adapter.MacAddress).ToString()

            if ($Adapter) {
                $SessionName = 'Capture-{0}' -f (Get-Date).ToString('s')
                $Session = New-NetEventSession -Name $SessionName -LocalFilePath $ETLFile.FullName -CaptureMode SaveToFile -CimSession $CimSession

                $LinkLayerAddress = switch ($Type) {
                    'CDP'   { '01-00-0c-cc-cc-cc' }
                    'LLDP'  { '01-80-c2-00-00-0e', '01-80-c2-00-00-03', '01-80-c2-00-00-00' }
                    Default { '01-00-0c-cc-cc-cc', '01-80-c2-00-00-0e', '01-80-c2-00-00-03', '01-80-c2-00-00-00' }
                }

                $PacketCaptureParams = @{
                    SessionName      = $SessionName
                    TruncationLength = 0
                    CaptureType      = 'Physical'
                    CimSession       = $CimSession
                    LinkLayerAddress = $LinkLayerAddress
                }

                Add-NetEventPacketCaptureProvider @PacketCaptureParams | Out-Null
                Add-NetEventNetworkAdapter -Name $Adapter.Name -PromiscuousMode $True -CimSession $CimSession | Out-Null

                Start-NetEventSession -Name $SessionName -CimSession $CimSession

                $Seconds = $Duration
                $End = (Get-Date).AddSeconds($Seconds)
                while ($End -gt (Get-Date)) {
                    $SecondsLeft = $End.Subtract((Get-Date)).TotalSeconds
                    $Percent = ($Seconds - $SecondsLeft) / $Seconds * 100
                    Write-Progress -Activity "Discovery Protocol Packet Capture" -Status "Capturing on $Computer..." -SecondsRemaining $SecondsLeft -PercentComplete $Percent
                    [System.Threading.Thread]::Sleep(500)
                }

                Stop-NetEventSession -Name $SessionName -CimSession $CimSession

                $Events = Invoke-Command -ComputerName $Computer -ScriptBlock {
                    $Events = Get-WinEvent -Path $args[0] -Oldest -FilterXPath "*[System[EventID=1001]]"

                    [string[]]$XpathQueries = @(
                        "Event/EventData/Data[@Name='FragmentSize']"
                        "Event/EventData/Data[@Name='Fragment']"
                    )

                    $PropertySelector = [System.Diagnostics.Eventing.Reader.EventLogPropertySelector]::new($XpathQueries)

                    foreach ($Event in $Events) {
                        $EventData = $Event | Select-Object MachineName, TimeCreated
                        $EventData | Add-Member -NotePropertyName FragmentSize -NotePropertyValue $null
                        $EventData | Add-Member -NotePropertyName Fragment -NotePropertyValue $null
                        $EventData.FragmentSize, $EventData.Fragment = $Event.GetPropertyValues($PropertySelector)
                        $EventData
                    }
                } -ArgumentList $Session.LocalFilePath

                $FoundPacket = $null

                foreach ($Event in $Events) {
                    $Packet = [PSCustomObject] @{
                        PSTypeName   = 'DiscoveryProtocolPacket'
                        MachineName  = $Event.MachineName
                        TimeCreated  = $Event.TimeCreated
                        FragmentSize = $Event.FragmentSize
                        Fragment     = $Event.Fragment
                    }

                    if ($Packet.IsDiscoveryProtocolPacket -and $Packet.SourceAddress -ne $MACAddress) {
                        $FoundPacket = $Packet
                        break
                    }
                }

                Remove-NetEventSession -Name $SessionName -CimSession $CimSession

                Invoke-Command -ComputerName $Computer -ScriptBlock {
                    Remove-Item -Path $args[0] -Force
                } -ArgumentList $ETLFile.FullName

                if ($FoundPacket) {
                    $FoundPacket
                } else {
                    Write-Warning "No discovery protocol packets captured on $Computer in $Seconds seconds."
                    return
                }
            } else {
                Write-Warning "Unable to find a connected wired adapter on $Computer."
                return
            }
        }
    }

    end {}
}
#endregion

#region function Get-DiscoveryProtocolData
function Get-DiscoveryProtocolData {

<#
 
.SYNOPSIS
 
    Parse CDP or LLDP packets captured by Invoke-DiscoveryProtocolCapture
 
.DESCRIPTION
 
    Gets computername, type and packet details from a DiscoveryProtocolPacket.
 
    Calls ConvertFrom-CDPPacket or ConvertFrom-LLDPPacket to extract packet details
    from a byte array.
 
.PARAMETER Packet
 
    Specifies an object of type DiscoveryProtocolPacket.
 
.EXAMPLE
 
    PS C:\> $Packet = Invoke-DiscoveryProtocolCapture
    PS C:\> Get-DiscoveryProtocolData -Packet $Packet
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
.EXAMPLE
 
    PS C:\> Invoke-DiscoveryProtocolCapture -Computer COMPUTER1 | Get-DiscoveryProtocolData
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
.EXAMPLE
 
    PS C:\> 'COMPUTER1', 'COMPUTER2' | Invoke-DiscoveryProtocolCapture | Get-DiscoveryProtocolData
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
    Computer : COMPUTER1
    Type : CDP
 
    Port : FastEthernet0/2
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 20
    Computer : COMPUTER2
    Type : CDP
 
#>


    [CmdletBinding()]
    [Alias('Parse-CDPPacket', 'Parse-LLDPPacket')]
    param(
        [Parameter(Position=0,
            Mandatory=$true,
            ValueFromPipeline=$true,
            ValueFromPipelineByPropertyName=$true)]
        [DiscoveryProtocolPacket[]]
        $Packet
    )

    begin {
        if ($MyInvocation.InvocationName -ne $MyInvocation.MyCommand) {
            $Warning = '{0} has been deprecated, please use {1}' -f $MyInvocation.InvocationName, $MyInvocation.MyCommand
            Write-Warning $Warning
        }
    }

    process {
        foreach ($item in $Packet) {
            switch ($item.DiscoveryProtocolType) {
                'CDP'   { $PacketData = ConvertFrom-CDPPacket -Packet $item.Fragment }
                'LLDP'  { $PacketData = ConvertFrom-LLDPPacket -Packet $item.Fragment }
                Default { throw 'No valid CDP or LLDP found in $Packet' }
            }

            $PacketData | Add-Member -NotePropertyName Computer -NotePropertyValue $item.MachineName
            $PacketData | Add-Member -NotePropertyName Type -NotePropertyValue $item.DiscoveryProtocolType
            $PacketData
        }
    }

    end {}
}
#endregion

#region function ConvertFrom-CDPPacket
function ConvertFrom-CDPPacket {

<#
 
.SYNOPSIS
 
    Parse CDP packet.
 
.DESCRIPTION
 
    Parse CDP packet to get port, device, model, ipaddress and vlan.
 
    This function is used by Get-DiscoveryProtocolData to parse the
    Fragment property of a DiscoveryProtocolPacket object.
 
.PARAMETER Packet
 
    Raw CDP packet as byte array.
 
    This function is used by Get-DiscoveryProtocolData to parse the
    Fragment property of a DiscoveryProtocolPacket object.
 
.EXAMPLE
 
    PS C:\> $Packet = Invoke-DiscoveryProtocolCapture -Type CDP
    PS C:\> ConvertFrom-CDPPacket -Packet $Packet.Fragment
 
    Port : FastEthernet0/1
    Device : SWITCH1.domain.example
    Model : cisco WS-C2960-48TT-L
    IPAddress : 192.0.2.10
    VLAN : 10
 
#>


    [CmdletBinding()]
    param(
        [Parameter(Position=0,
            Mandatory=$true)]
        [byte[]]$Packet
    )

    begin {}

    process {

        $Offset = 26
        $Hash = @{}

        while ($Offset -lt ($Packet.Length - 4)) {

            $Type   = [BitConverter]::ToUInt16($Packet[($Offset + 1)..$Offset], 0)
            $Length = [BitConverter]::ToUInt16($Packet[($Offset + 3)..($Offset + 2)], 0)

            switch ($Type)
            {
                1  { $Hash.Add('Device',    [System.Text.Encoding]::ASCII.GetString($Packet[($Offset + 4)..($Offset + $Length)])) }
                3  { $Hash.Add('Port',      [System.Text.Encoding]::ASCII.GetString($Packet[($Offset + 4)..($Offset + $Length)])) }
                6  { $Hash.Add('Model',     [System.Text.Encoding]::ASCII.GetString($Packet[($Offset + 4)..($Offset + $Length)])) }
                10 { $Hash.Add('VLAN',      [BitConverter]::ToUInt16($Packet[($Offset + 5)..($Offset + 4)], 0)) }
                22 { $Hash.Add('IPAddress', ([System.Net.IPAddress][byte[]]$Packet[($Offset + 13)..($Offset + 16)]).IPAddressToString) }
            }

            if ($Length -eq 0 ) {
                $Offset = $Packet.Length
            }

            $Offset = $Offset + $Length

        }

        return [PSCustomObject]$Hash

    }

    end {}

}
#endregion

#region function ConvertFrom-LLDPPacket
function ConvertFrom-LLDPPacket {

<#
 
.SYNOPSIS
 
    Parse LLDP packet.
 
.DESCRIPTION
 
    Parse LLDP packet to get port, description, device, model, ipaddress and vlan.
 
.PARAMETER Packet
 
    Raw LLDP packet as byte array.
 
    This function is used by Get-DiscoveryProtocolData to parse the
    Fragment property of a DiscoveryProtocolPacket object.
 
.EXAMPLE
 
    PS C:\> $Packet = Invoke-DiscoveryProtocolCapture -Type LLDP
    PS C:\> ConvertFrom-LLDPPacket -Packet $Packet.Fragment
 
    Model : WS-C2960-48TT-L
    Description : HR Workstation
    VLAN : 10
    Port : Fa0/1
    Device : SWITCH1.domain.example
    IPAddress : 192.0.2.10
 
#>


    [CmdletBinding()]
    param(
        [Parameter(Position=0,
            Mandatory=$true)]
        [byte[]]$Packet
    )

    begin {
        $TlvType = @{
            PortId               = 2
            PortDescription      = 4
            SystemName           = 5
            ManagementAddress    = 8
            OrganizationSpecific = 127
        }
    }

    process {

        $Destination = [PhysicalAddress]::new($Packet[0..5])
        $Source      = [PhysicalAddress]::new($Packet[6..11])
        $LLDP        = [BitConverter]::ToUInt16($Packet[13..12], 0)

        Write-Verbose "Destination: $Destination"
        Write-Verbose "Source: $Source"
        Write-Verbose "LLDP: $LLDP"

        $Offset = 14
        $Mask = 0x01FF
        $Hash = @{}

        while ($Offset -lt $Packet.Length)
        {
            $Type = $Packet[$Offset] -shr 1
            $Length = [BitConverter]::ToUInt16($Packet[($Offset + 1)..$Offset], 0) -band $Mask
            $Offset += 2

            switch ($Type)
            {
                $TlvType.PortId {
                    $Subtype = $Packet[($Offset)]

                    if ($SubType -in (1, 2, 5, 6, 7)) {
                        $Hash.Add('Port', [System.Text.Encoding]::ASCII.GetString($Packet[($Offset + 1)..($Offset + $Length - 1)]))
                    }

                    if ($Subtype -eq 3) {
                        $Hash.Add('Port', [PhysicalAddress]::new($Packet[($Offset + 1)..($Offset + $Length - 1)]))
                    }

                    $Offset += $Length
                    break
                }

                $TlvType.PortDescription {
                    $Hash.Add('Description', [System.Text.Encoding]::ASCII.GetString($Packet[$Offset..($Offset + $Length - 1)]))
                    $Offset += $Length
                    break
                }

                $TlvType.SystemName {
                    $Hash.Add('Device', [System.Text.Encoding]::ASCII.GetString($Packet[$Offset..($Offset + $Length - 1)]))
                    $Offset += $Length
                    break
                }

                $TlvType.ManagementAddress {
                    $AddrLen = $Packet[($Offset)]
                    $Subtype = $Packet[($Offset + 1)]

                    if ($Subtype -eq 1)
                    {
                        $Hash.Add('IPAddress', ([System.Net.IPAddress][byte[]]$Packet[($Offset + 2)..($Offset + $AddrLen)]).IPAddressToString)
                    }

                    $Offset += $Length
                    break
                }

                $TlvType.OrganizationSpecific {
                    $OUI = [System.BitConverter]::ToString($Packet[($Offset)..($Offset + 2)])

                    if ($OUI -eq '00-12-BB') {
                        $Subtype = $Packet[($Offset + 3)]
                        if ($Subtype -eq 10) {
                            $Hash.Add('Model', [System.Text.Encoding]::ASCII.GetString($Packet[($Offset + 4)..($Offset + $Length - 1)]))
                            $Offset += $Length
                            break
                        }
                    }

                    if ($OUI -eq '00-80-C2') {
                        $Subtype = $Packet[($Offset + 3)]
                        if ($Subtype -eq 1) {
                            $Hash.Add('VLAN', [BitConverter]::ToUInt16($Packet[($Offset + 5)..($Offset + 4)], 0))
                            $Offset += $Length
                            break
                        }
                    }

                    $Tlv = [PSCustomObject] @{
                        Type = $Type
                        Value = [System.Text.Encoding]::ASCII.GetString($Packet[$Offset..($Offset + $Length)])
                    }
                    Write-Verbose $Tlv
                    $Offset += $Length
                    break
                }

                default {
                    $Tlv = [PSCustomObject] @{
                        Type = $Type
                        Value = [System.Text.Encoding]::ASCII.GetString($Packet[$Offset..($Offset + $Length)])
                    }
                    Write-Verbose $Tlv
                    $Offset += $Length
                    break
                }
            }
        }
        [PSCustomObject]$Hash
    }

    end {}
}
#endregion

#region function Export-Pcap
function Export-Pcap {

<#
 
.SYNOPSIS
 
    Export packets to pcap
 
.DESCRIPTION
 
    Export packets, captured using Invoke-DiscoveryProtocolCapture, to pcap format.
 
.PARAMETER Packet
 
    Specifies one or more objects of type DiscoveryProtocolPacket.
 
.PARAMETER Path
 
    Relative or absolute path to pcap file.
 
.PARAMETER Invoke
 
    If Invoke is set, exported file is opened in the program associated with pcap files.
 
.EXAMPLE
 
    PS C:\> $Packet = Invoke-DiscoveryProtocolCapture
    PS C:\> Export-Pcap -Packet $Packet -Path C:\Windows\Temp\captures.pcap -Invoke
 
    Export captured packet to C:\Windows\Temp\captures.pcap and open file in
    the program associated with pcap files.
 
.EXAMPLE
 
    PS C:\> 'COMPUTER1', 'COMPUTER2' | Invoke-DiscoveryProtocolCapture | Export-Pcap -Path captures.pcap
 
    Export captured packets to captures.pcap in current directory. Export-Pcap supports input from pipeline.
 
#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,
            ValueFromPipeline=$true,
            ValueFromPipelineByPropertyName=$true)]
        [DiscoveryProtocolPacket[]]$Packet,

        [Parameter(Mandatory=$true)]
        [ValidateScript({
            if ([System.IO.Path]::IsPathRooted($_)) {
                $AbsolutePath = $_
            } else {
                $AbsolutePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($_)
            }
            if (-not(Test-Path (Split-Path $AbsolutePath -Parent))) {
                throw "Folder does not exist"
            }
            if ($_ -notmatch '\.pcap$') {
                throw "Extension must be pcap"
            }
            return $true
        })]
        [System.IO.FileInfo]$Path,

        [Parameter(Mandatory=$false)]
        [switch]$Invoke
    )

    begin {
        [uint32]$magicNumber = '0xa1b2c3d4'
        [uint16]$versionMajor = 2
        [uint16]$versionMinor = 4
        [int32] $thisZone = 0
        [uint32]$sigFigs = 0
        [uint32]$snapLen = 65536
        [uint32]$network = 1

        $stream = New-Object System.IO.MemoryStream
        $writer = New-Object System.IO.BinaryWriter $stream

        $writer.Write($magicNumber)
        $writer.Write($versionMajor)
        $writer.Write($versionMinor)
        $writer.Write($thisZone)
        $writer.Write($sigFigs)
        $writer.Write($snapLen)
        $writer.Write($network)
    }

    process {
        foreach ($item in $Packet) {
            [uint32]$tsSec = ([DateTimeOffset]$item.TimeCreated).ToUnixTimeSeconds()
            [uint32]$tsUsec = $item.TimeCreated.Millisecond
            [uint32]$inclLen = $item.FragmentSize
            [uint32]$origLen = $inclLen

            $writer.Write($tsSec)
            $writer.Write($tsUsec)
            $writer.Write($inclLen)
            $writer.Write($origLen)
            $writer.Write($item.Fragment)
        }
    }

    end {
        $bytes = $stream.ToArray()

        $stream.Dispose()
        $writer.Dispose()

        if (-not([System.IO.Path]::IsPathRooted($Path))) {
            $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        }

        [System.IO.File]::WriteAllBytes($Path, $bytes)

        if ($Invoke) {
            Invoke-Item -Path $Path
        }
    }
}
#endregion