Public/Get-VBRTSPBanner.ps1

function Get-VBRTSPBanner {
<#
.SYNOPSIS
    Grab the RTSP banner from a device by sending an OPTIONS request to TCP 554 (Layer 8).
 
.DESCRIPTION
    Opens a raw TCP connection to port 554, sends a minimal RTSP OPTIONS request,
    reads up to 1 KB of response (or until 500 ms idle), and returns the first 512
    characters of the banner. The presence of an RTSP banner is a high-confidence
    signal for IP cameras and NVRs.
 
    The orchestrator gates this layer: it is only called when TCP layer 5 found
    port 554 open AND $Context.RTSPProbeEnabled is $true.
 
    All stream and TCP client resources are released in a finally block on every
    code path.
 
    Prerequisites: $Context.NetworkProbeEnabled and $Context.RTSPProbeEnabled.
 
.PARAMETER IPAddress
    The RFC1918 / CGNAT / link-local IP address to probe.
 
.PARAMETER Context
    Environment context from Get-VBEnrichmentContext. Provides
    NetworkProbeEnabled, RTSPProbeEnabled, and DefaultTimeoutMs.RTSP.
 
.PARAMETER TimeoutMs
    Read idle timeout in milliseconds. Defaults to $Context.DefaultTimeoutMs.RTSP (2000).
 
.OUTPUTS
    [PSCustomObject] -- base layer result fields plus:
        RTSPBanner [string] first 512 chars of the RTSP response
 
.EXAMPLE
    $ctx = Get-VBEnrichmentContext
    Get-VBRTSPBanner -IPAddress '192.168.1.200' -Context $ctx
 
.EXAMPLE
    '192.168.1.200' | Get-VBRTSPBanner -Context $ctx
 
.NOTES
    Version: 1.0.0
    MinPSVersion: 5.1
    Author: VB
    ChangeLog:
        1.0.0 -- 2026-05-11 -- Initial release
#>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$IPAddress,

        [Parameter()]
        [PSCustomObject]$Context,

        [Parameter()]
        [int]$TimeoutMs = 2000
    )

    begin {
        $LAYER_NUM  = 8
        $LAYER_NAME = 'RTSP'

        if (-not $Context) {
            Write-Warning "[$LAYER_NAME] No context provided -- running without prerequisite validation."
        }

        if ($PSBoundParameters.ContainsKey('TimeoutMs') -eq $false -and $Context -and $Context.DefaultTimeoutMs) {
            $TimeoutMs = $Context.DefaultTimeoutMs.RTSP
        }
    }

    process {
        $sw = [System.Diagnostics.Stopwatch]::StartNew()

        if ($Context -and (-not $Context.NetworkProbeEnabled -or -not $Context.RTSPProbeEnabled)) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'RTSPProbeDisabled' `
                -Impact 'No RTSP banner -- cameras may be classified by OUI only'
        }

        $client = $null
        $stream = $null

        try {
            $client = New-Object System.Net.Sockets.TcpClient
            $connectResult = $client.BeginConnect($IPAddress, 554, $null, $null)
            $connected = $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)

            if (-not $connected -or -not $client.Connected) {
                $sw.Stop()
                return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                    -Status 'NoResult' -ExecutionMs $sw.ElapsedMilliseconds
            }

            $client.EndConnect($connectResult)
            $stream = $client.GetStream()
            $stream.ReadTimeout  = $TimeoutMs
            $stream.WriteTimeout = $TimeoutMs

            # Send minimal RTSP OPTIONS request
            $request = "OPTIONS rtsp://${IPAddress}:554/ RTSP/1.0`r`nCSeq: 1`r`n`r`n"
            $requestBytes = [System.Text.Encoding]::ASCII.GetBytes($request)
            $stream.Write($requestBytes, 0, $requestBytes.Length)

            # Read response -- up to 1 KB, stop on idle or timeout
            $buffer    = New-Object byte[] 1024
            $totalRead = 0
            try {
                $totalRead = $stream.Read($buffer, 0, $buffer.Length)
            }
            catch [System.IO.IOException] {
                # Read timeout is expected -- we got partial data or nothing
            }

            if ($totalRead -eq 0) {
                $sw.Stop()
                return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                    -Status 'NoResult' -ExecutionMs $sw.ElapsedMilliseconds
            }

            $banner = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $totalRead)
            # Trim to 512 chars and strip control characters
            $banner = ($banner -replace '[\x00-\x08\x0B\x0C\x0E-\x1F]', '').Substring(
                0, [math]::Min(512, $banner.Length)
            ).Trim()

            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Success' -ExecutionMs $sw.ElapsedMilliseconds `
                -ExtraFields @{ RTSPBanner = $banner }
        }
        catch {
            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Failed' -ExecutionMs $sw.ElapsedMilliseconds `
                -ErrorDetail $_.Exception.Message
        }
        finally {
            if ($stream) { try { $stream.Close() } catch { } }
            if ($client) { try { $client.Close() } catch { } ; try { $client.Dispose() } catch { } }
        }
    }
}