Public/Ssh/New-VmSshClientWithJump.ps1

# ---------------------------------------------------------------------------
# New-VmSshClientWithJump
# Opens an SSH client to a VM, transparently routing through a jump
# host when one is required.
#
# Two paths:
# - Direct (router VMs, or any VM the host has L2 reach to):
# calls New-VmSshClient directly. No tunnel.
# - Jumped (workload VMs after feature 53 - host has no route to
# the per-environment private subnet): opens New-VmSshTunnel
# against the workload's `_RouterVm` neighbour, then connects
# a fresh SshClient to localhost:<LocalPort> using the
# workload's credentials. The SSH session piggybacks the
# tunnel's forwarded port; bytes flow workload <-> router <->
# host transparently.
#
# Returns a session object holding both the SshClient and (when
# jumped) the underlying tunnel. Callers MUST call .Dispose() in
# a finally block - it tears the client down first, then the
# tunnel, so the forward closes cleanly.
#
# Decision rule: a `_RouterVm` NoteProperty signals "jump required".
# provision.ps1 step 7 stamps it onto every workload VM def from
# Group-VmsByEnvironment, so callers do not need to know about
# environments or routing themselves.
# ---------------------------------------------------------------------------

function New-VmSshClientWithJump {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Vm,

        # Forwarded to the underlying New-VmSshClient. Workload SSH
        # paths typically pass a generous timeout (10 min) because the
        # workload's first SSH connect blocks behind cloud-config
        # finishing - same posture Invoke-VmPostProvisioning uses for
        # the direct case.
        [Parameter()]
        [TimeSpan] $Timeout = [TimeSpan]::FromSeconds(30)
    )

    Assert-SshNetLoaded

    # No jump configured (router VM, or pre-feature-53 topology). Direct
    # connect via the standard helper - same semantics as if this
    # wrapper did not exist.
    $hasRouter = $Vm.PSObject.Properties['_RouterVm'] -and $Vm._RouterVm
    if (-not $hasRouter) {
        $client = New-VmSshClient `
                      -IpAddress $Vm.ipAddress `
                      -Username  $Vm.username `
                      -Password  $Vm.password `
                      -Timeout   $Timeout

        $session = [PSCustomObject]@{ Client = $client; Tunnel = $null }
        Add-Member -InputObject $session `
                   -MemberType ScriptMethod -Name Dispose -Value {
            try {
                if ($this.Client.IsConnected) { $this.Client.Disconnect() }
            } catch {}
            try { $this.Client.Dispose() } catch {}
        }
        return $session
    }

    # Workload VM: open the jump tunnel through its router neighbour.
    $tunnel = New-VmSshTunnel `
                  -TargetIp     $Vm.ipAddress `
                  -JumpHostIp   $Vm._RouterVm.ipAddress `
                  -JumpUsername $Vm._RouterVm.username `
                  -JumpPassword $Vm._RouterVm.password `
                  -JumpConnectTimeout $Timeout

    # Connect a fresh SshClient to the tunnel's loopback endpoint with
    # the workload's credentials. Constructed directly (bypassing
    # New-VmSshClient) because New-VmSshClient does not expose a
    # -Port parameter; the workload listens on 22 inside its NIC, but
    # the local-forward endpoint is on an ephemeral host port.
    try {
        $auth     = [Renci.SshNet.PasswordAuthenticationMethod]::new(
            $Vm.username, $Vm.password)
        $connInfo = [Renci.SshNet.ConnectionInfo]::new(
            $tunnel.LocalHost, [int]$tunnel.LocalPort,
            $Vm.username, @($auth))
        $connInfo.Timeout = $Timeout
        $client = [Renci.SshNet.SshClient]::new($connInfo)
        $client.Connect()
    }
    catch {
        # Tunnel survives until we know the client connect succeeded;
        # tear it down on any failure so we do not leak the jump
        # session.
        $tunnel.Dispose()
        throw
    }

    $session = [PSCustomObject]@{ Client = $client; Tunnel = $tunnel }
    Add-Member -InputObject $session `
               -MemberType ScriptMethod -Name Dispose -Value {
        try {
            if ($this.Client.IsConnected) { $this.Client.Disconnect() }
        } catch {}
        try { $this.Client.Dispose() } catch {}
        # Dispose the tunnel AFTER the inner client so the
        # forwarded-port still services the workload session's
        # graceful shutdown traffic before the channel goes away.
        try { $this.Tunnel.Dispose() } catch {}
    }
    return $session
}