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 } |