Public/Ssh/Test-SshBanner.ps1

# ---------------------------------------------------------------------------
# Test-SshBanner
# Connects to <IpAddress>:<Port>, reads up to a few bytes, and returns
# $true iff the bytes start with the SSH protocol banner prefix ("SSH-").
# Returns $false on TCP connect failure, timeout, or a non-SSH response.
#
# Why this beats a bare TCP probe through a tunnel:
# - SSH.NET's ForwardedPortLocal listener accepts the TCP socket the
# moment ForwardedPortLocal.Start() returns. A TCP-only probe
# therefore succeeds INSTANTLY against a tunnel whose far end may
# not have sshd running yet - the listener accepts, SSH.NET tries
# to open the direct-tcpip channel, the channel-open fails because
# the workload's port 22 is silent, SSH.NET tears the local socket
# down a few hundred ms later - by which time the caller already
# believed the probe succeeded and moved on.
# - The SSH banner is the first thing the SERVER sends after the TCP
# 3-way handshake. If we receive it, SSH.NET successfully opened
# the channel AND the workload's sshd is actually serving on the
# far side. If the connection drops without sending the banner,
# the workload is not yet ready.
#
# The 16-byte read is a conservative ceiling - the OpenSSH banner is
# typically ~24 bytes ("SSH-2.0-OpenSSH_9.6p1\r\n"), and we only need
# the first 4 bytes ("SSH-") to confirm protocol speaker. Reading more
# would only delay the success path.
# ---------------------------------------------------------------------------
function Test-SshBanner {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string] $IpAddress,

        [Parameter()]
        [int] $Port = 22,

        # Total banner-read budget (connect + read). 3 s comfortably
        # covers a local-LAN SSH banner; through a tunnel with a slow
        # far end, the connection drops fast on channel-open failure
        # so this timeout primarily caps the "server is up but quiet"
        # pathology, not normal operation.
        [Parameter()]
        [int] $TimeoutMilliseconds = 3000
    )

    $tcpClient = $null
    try {
        $tcpClient = [System.Net.Sockets.TcpClient]::new()
        $connectTask = $tcpClient.ConnectAsync($IpAddress, $Port)
        if (-not $connectTask.Wait($TimeoutMilliseconds)) {
            return $false
        }

        $stream = $tcpClient.GetStream()
        # ReadTimeout applies per-read, not per-byte; setting it after
        # connect rather than before is required because the stream
        # object does not exist until then.
        $stream.ReadTimeout = $TimeoutMilliseconds

        $buffer = [byte[]]::new(16)
        $read   = $stream.Read($buffer, 0, $buffer.Length)
        if ($read -lt 4) {
            return $false
        }

        # ASCII compare against the protocol-version prefix.
        $prefix = [System.Text.Encoding]::ASCII.GetString($buffer, 0, 4)
        return $prefix -eq 'SSH-'
    }
    catch {
        return $false
    }
    finally {
        if ($null -ne $tcpClient) { $tcpClient.Dispose() }
    }
}