Public/Relay/Test-RouterSshRelay.ps1

<#
.NOTES
    Do not run this file directly. Dot-sourced by the module psm1.
#>


# ---------------------------------------------------------------------------
# Test-RouterSshRelay
# Probes the controller -> router SSH relay end to end and reports which
# half of it is broken. The read counterpart to Set-RouterSshRelay /
# Remove-RouterSshRelay, which until now had no verifier: the relay could
# be laid and torn down, never checked.
#
# WHY AN ACTIVE PROBE, not an inspection of the netsh table. The failure
# this exists to catch is that an ICS toggle regenerates the Internal
# vSwitch network and iphlpsvc keeps forwarding bound to the PRE-toggle
# generation. The portproxy entry still reads back perfectly from
# `netsh interface portproxy show v4tov4` - it is the forwarding behind
# it that is dead. So comparing configuration against expectation cannot
# see this; only moving bytes can.
#
# WHY THE LISTENER, not the router's own IP. Connecting straight to
# <router>:22 bypasses the relay entirely and would report healthy while
# every WSL-side consumer is broken. The probe therefore targets the
# host-side listen endpoint, so the packets traverse exactly the hop that
# goes stale.
#
# The two stages are the diagnosis, and are reported separately:
# Connect - the TCP connect to the listener failed. The portproxy is
# absent (or the listen port moved). Nothing is forwarding.
# Banner - the connect SUCCEEDED but no SSH banner arrived before the
# timeout. This is the stale-forwarding signature: iphlpsvc
# accepted on the host and dropped the onward hop. It is also
# what a powered-off router looks like, which the message
# says rather than guessing between them.
#
# SCOPE. This runs host-side against the loopback listener, so it proves
# the portproxy and its forwarding. It does NOT traverse the Windows
# Firewall (loopback bypasses it), so it cannot prove a WSL client is
# allowed inbound. That is a deliberate and narrow gap: the firewall
# companion is scoped by REMOTE ADDRESS (the WSL NAT range) rather than
# by interface precisely so it has nothing volatile to go stale against -
# see Set-RouterSshPortProxyFirewall. Forwarding staleness is the live
# failure mode; the firewall's was closed by that scoping change.
#
# Returns a result object rather than throwing, matching the Test-* verb
# contract in this module (Assert-* throws; Test-* reports). Callers that
# want a gate assert on .Ok and surface .Reason, which is written to be
# shown to an operator verbatim.
# ---------------------------------------------------------------------------

function Test-RouterSshRelay {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        # Host-side listen endpoint to probe. Defaults to loopback: the
        # portproxy listens on 0.0.0.0 so WSL can reach it via the host's
        # vEthernet IP, and loopback hits the same listener without needing
        # to know which of the host's addresses is the WSL-facing one.
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ListenAddress = '127.0.0.1',

        # Must match the port Set-RouterSshRelay laid. Both default to 2222;
        # a caller that overrode one has to override the other.
        [Parameter()]
        [ValidateRange(1, 65535)]
        [int] $ListenPort = 2222,

        # Budget for BOTH the TCP connect and the banner read. Five seconds
        # is well past a healthy local relay (the connect is loopback and
        # the banner is one hop away) while still failing a wedged relay
        # fast enough to gate a provisioning run.
        [Parameter()]
        [ValidateRange(1, 120)]
        [int] $TimeoutSeconds = 5
    )

    $timeoutMs = $TimeoutSeconds * 1000
    $endpoint  = "${ListenAddress}:${ListenPort}"

    # Result shape is built up front and mutated on the way through so every
    # exit path returns the same fields - a caller can read .Ok and .Reason
    # without checking whether the others exist.
    $result = [pscustomobject]@{
        Ok            = $false
        ListenAddress = $ListenAddress
        ListenPort    = $ListenPort
        Stage         = 'Connect'
        Banner        = ''
        Reason        = ''
    }

    $client = [System.Net.Sockets.TcpClient]::new()
    try {
        # ConnectAsync + Wait rather than the synchronous Connect: the sync
        # overload ignores any timeout and can hang on SYN retries for the
        # OS default (~20s+), which is far too long for a gate.
        $connectTask = $client.ConnectAsync($ListenAddress, $ListenPort)
        if (-not $connectTask.Wait($timeoutMs) -or -not $client.Connected) {
            $result.Reason = (
                "No SSH relay listening on $endpoint. The host-side portproxy " +
                "is absent or on a different port. Re-lay it with " +
                "Set-RouterSshRelay -ConnectAddress <router-ip>, or run the " +
                "provisioner's network preflight with -AutoRepair."
            )
            return $result
        }

        # Connected: the listener is live, so anything that fails from here
        # is the onward hop rather than the portproxy's existence.
        $result.Stage = 'Banner'

        $stream = $client.GetStream()
        $stream.ReadTimeout = $timeoutMs
        $buffer = [byte[]]::new(255)

        try {
            $read = $stream.Read($buffer, 0, $buffer.Length)
        }
        catch [System.IO.IOException] {
            # A read timeout surfaces as IOException. This is THE signature
            # of the stale-forwarding bug, so the message leads with it
            # while naming the other cause it is indistinguishable from.
            $result.Reason = (
                "Connected to $endpoint but no SSH banner arrived within " +
                "${TimeoutSeconds}s. The portproxy is listening but its " +
                "onward hop to the router is not delivering - either the " +
                "forwarding went stale (an ICS toggle regenerates the " +
                "Internal vSwitch and strands iphlpsvc on the old network " +
                "generation) or the router VM is down. Re-lay the relay " +
                "with Set-RouterSshRelay -ConnectAddress <router-ip>; if " +
                "that does not fix it, check the router VM is running."
            )
            return $result
        }

        if ($read -le 0) {
            $result.Reason = (
                "Connected to $endpoint but the connection closed without " +
                "sending an SSH banner. The onward hop to the router is not " +
                "delivering; re-lay the relay and confirm the router VM is up."
            )
            return $result
        }

        # SSH servers announce themselves with an "SSH-<protoversion>-..."
        # identification string before anything else (RFC 4253 s4.2), so the
        # prefix is a sufficient and cheap proof that we reached sshd rather
        # than some other listener that happens to hold the port.
        $banner        = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim()
        $result.Banner = $banner

        if (-not $banner.StartsWith('SSH-')) {
            $result.Reason = (
                "Something is answering on $endpoint but it is not an SSH " +
                "server (banner: '$banner'). Another service may have taken " +
                "the listen port."
            )
            return $result
        }

        $result.Ok     = $true
        $result.Reason = "SSH relay healthy on $endpoint ($banner)."
        return $result
    }
    catch {
        # Anything unanticipated (a socket layer error, a DNS failure on a
        # non-loopback ListenAddress) is reported as a probe failure rather
        # than thrown, so a caller's gate still gets a Reason to display
        # instead of a stack trace.
        $result.Reason = (
            "Probing the SSH relay at $endpoint failed: $($_.Exception.Message)"
        )
        return $result
    }
    finally {
        # Dispose closes the underlying socket; safe on a client that never
        # connected.
        $client.Dispose()
    }
}