Public/Ssh/New-VmSshTunnel.ps1

# ---------------------------------------------------------------------------
# New-VmSshTunnel
# Opens an SSH session to a jump host and configures a local TCP port
# forward so traffic to 127.0.0.1:<assigned-port> emerges at
# <TargetIp>:<TargetPort> on the far side of the jump. This is how the
# provisioner reaches workload VMs after feature 53 moved them onto a
# per-environment private switch the host has no route to.
#
# Why a local port forward (not native ProxyJump):
# - SSH.NET (the library every other SSH cmdlet in this stack is
# built on) has no native ProxyJump (`-J`) support. Local port
# forwarding is the closest equivalent SSH.NET ships out of the
# box: it gives a localhost endpoint that any TCP-aware caller
# (Test-VmSshPort, a fresh SshClient connect, etc.) can use
# without knowing about the jump.
# - Reusing a single jump session for the whole VM lifecycle (one
# wait-for-SSH probe loop + one post-provisioning session) saves
# the cost of re-handshaking the jump on every probe iteration.
#
# The returned object exposes:
# - LocalHost / LocalPort : the loopback endpoint callers connect to.
# - JumpClient / Forward : the underlying SSH.NET objects, kept for
# disposal and so callers that need to
# run commands on the jump itself can do
# so without re-opening a session.
# - Dispose() : tears down the forward and the jump
# session in the right order. ALWAYS call
# it in a finally block.
# ---------------------------------------------------------------------------

function New-VmSshTunnel {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingPlainTextForPassword', 'JumpPassword')]
    [CmdletBinding()]
    param(
        # IPv4 of the VM behind the jump (the workload VM).
        [Parameter(Mandatory)]
        [string] $TargetIp,

        # IPv4 of the jump host (the router VM). Reachable from the
        # provisioning host directly (it is on the same upstream LAN
        # the host's External vSwitch is bridged to).
        [Parameter(Mandatory)]
        [string] $JumpHostIp,

        [Parameter(Mandatory)]
        [string] $JumpUsername,

        # Plain string required by SSH.NET PasswordAuthenticationMethod.
        # Same caller responsibility as New-VmSshClient - never log it.
        [Parameter(Mandatory)]
        [string] $JumpPassword,

        # TCP port on the target. Defaults to 22 (SSH) because that is
        # the only port the provisioner actually proxies today; making
        # it a parameter keeps the helper reusable for a future probe
        # that wants 80 / 443 / arbitrary.
        [Parameter()]
        [uint32] $TargetPort = 22,

        # SSH.NET applies this to both the jump handshake and the
        # underlying TCP read. Default mirrors New-VmSshClient's 30 s.
        [Parameter()]
        [TimeSpan] $JumpConnectTimeout = [TimeSpan]::FromSeconds(30)
    )

    Assert-SshNetLoaded

    # Open the jump session. New-VmSshClient handles the connect +
    # password auth contract uniformly with every other SSH path in
    # this repo so the jump leg behaves identically to a direct
    # session (host key acceptance, KEX algorithm set, timeout
    # semantics).
    $jumpClient = New-VmSshClient `
                      -IpAddress $JumpHostIp `
                      -Username  $JumpUsername `
                      -Password  $JumpPassword `
                      -Timeout   $JumpConnectTimeout

    # Pick an ephemeral local port the kernel knows is free. Binding a
    # TcpListener to port 0 returns the kernel-assigned port; release
    # immediately so SSH.NET can claim it. The window between release
    # and SSH.NET's bind is small; if a collision did happen,
    # forward.Start() throws and the caller can retry. Cheaper than
    # iterating a fixed port range.
    $listener  = [System.Net.Sockets.TcpListener]::new(
        [System.Net.IPAddress]::Loopback, 0)
    $listener.Start()
    $localPort = $listener.LocalEndpoint.Port
    $listener.Stop()

    # Configure the forward: 127.0.0.1:<localPort> -> <TargetIp>:<TargetPort>
    # via $jumpClient. SSH.NET tunnels every byte in/out of the
    # localhost socket over the jump session's direct-tcpip channel.
    $forward = [Renci.SshNet.ForwardedPortLocal]::new(
        '127.0.0.1', [uint32]$localPort,
        $TargetIp, $TargetPort)

    # AddForwardedPort registers the forward with the jump client so
    # it shares the session's lifecycle; Start() actually opens the
    # local listener. Both must complete before any caller probe.
    try {
        $jumpClient.AddForwardedPort($forward)
        $forward.Start()
    }
    catch {
        # If forward setup fails (port collision, jump session dropped
        # between AddForwardedPort and Start, etc.) dispose the jump
        # session before propagating so we do not leak the SSH
        # connection.
        if ($jumpClient.IsConnected) { $jumpClient.Disconnect() }
        $jumpClient.Dispose()
        throw
    }

    $tunnel = [PSCustomObject]@{
        LocalHost  = '127.0.0.1'
        LocalPort  = $localPort
        JumpClient = $jumpClient
        Forward    = $forward
    }

    # Dispose tears down the forward FIRST (so existing localhost
    # connections drop) then the jump session. Wrapped in try/catch
    # blocks because the constituent objects throw on double-dispose
    # or on a session that already disconnected uncleanly; the
    # outer caller's finally must not be derailed by cleanup noise.
    Add-Member -InputObject $tunnel `
               -MemberType ScriptMethod `
               -Name Dispose `
               -Value {
        try { if ($this.Forward.IsStarted) { $this.Forward.Stop() } } catch {}
        try { $this.JumpClient.RemoveForwardedPort($this.Forward) } catch {}
        try { $this.Forward.Dispose() } catch {}
        try {
            if ($this.JumpClient.IsConnected) { $this.JumpClient.Disconnect() }
        } catch {}
        try { $this.JumpClient.Dispose() } catch {}
    }

    return $tunnel
}