Public/Ssh/New-VmSshClient.ps1
|
# --------------------------------------------------------------------------- # New-VmSshClient # Creates and connects a Renci.SshNet.SshClient using password # authentication. Returns the connected client; the caller is responsible # for calling Disconnect() and Dispose() in a finally block. # # SSH.NET is used directly rather than Posh-SSH cmdlets because # ConnectionInfoGenerator in Posh-SSH 3.x drops algorithm entries from # the SSH.NET ConnectionInfo, breaking key exchange against OpenSSH 9.x # (Ubuntu 24.04). Posh-SSH must still be installed - it ships the # Renci.SshNet.dll the function depends on. # # Renci types are referenced inside the function body (not as parameter # types) so the module imports cleanly on hosts without Posh-SSH. The # Assert-SshNetLoaded guard turns the otherwise opaque "type not found" # error into an actionable message naming the missing prerequisite. # # Connect timing: # -Timeout caps the total Connect() wall-clock. SSH.NET applies the # same value to both socket-read and KEX, so a long Timeout is the # right knob when a slow server-side responder (e.g. sshd held off # by cloud-config until users are created) needs more than SSH.NET's # 30s default. The connect runs on the thread pool so the calling # thread can emit a progress dot every -ProgressInterval while the # Task is still running. Without that, a multi-minute wait looks # identical to a hang to an operator watching the console. # # Security: # - SSH.NET accepts any host key by default (no HostKeyReceived handler). # Equivalent to Posh-SSH's -AcceptKey. Acceptable on a private Hyper-V # network with statically provisioned IPs; do NOT use on untrusted # networks without supplying a fingerprint check. # - Password is required as a plain string by SSH.NET's # PasswordAuthenticationMethod constructor. Callers should source the # value from SecretManagement and avoid logging it. # --------------------------------------------------------------------------- function New-VmSshClient { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSAvoidUsingPlainTextForPassword', 'Password')] [CmdletBinding()] param( [Parameter(Mandatory)] [string] $IpAddress, [Parameter(Mandatory)] [string] $Username, # Plain string required by SSH.NET PasswordAuthenticationMethod. [Parameter(Mandatory)] [string] $Password, # Total Connect() wall-clock budget. Default 30s matches SSH.NET's # built-in default so existing callers are unaffected. Callers # waiting on a slow server-side responder (e.g. provisioning, # where sshd is ordered after cloud-config) pass a generous value. [TimeSpan] $Timeout = [TimeSpan]::FromSeconds(30), # How often a progress dot is emitted while Connect is still # running. Smaller values are smoother visually but produce more # output; 5s is a reasonable default for waits measured in # minutes. [TimeSpan] $ProgressInterval = [TimeSpan]::FromSeconds(5) ) Assert-SshNetLoaded $auth = [Renci.SshNet.PasswordAuthenticationMethod]::new($Username, $Password) $connInfo = [Renci.SshNet.ConnectionInfo]::new($IpAddress, $Username, @($auth)) # SSH.NET applies ConnectionInfo.Timeout to both the TCP socket read # and the KEX exchange. Setting it here governs the upper bound that # the Task below observes. $connInfo.Timeout = $Timeout $client = [Renci.SshNet.SshClient]::new($connInfo) # Run Connect on the thread pool so the foreground can poll and emit # progress. Task.Run wraps the .Connect() call and surfaces any # exception via Task.Exception (an AggregateException whose # InnerException is the real SSH.NET error). Write-Host " Connecting to $IpAddress (timeout $([int]$Timeout.TotalSeconds)s) ..." ` -NoNewline $connectTask = [System.Threading.Tasks.Task]::Run([System.Action] { $client.Connect() }) # Wait in ProgressInterval slices, emitting a dot per slice. Wait() # returns $true on completion, $false on slice-timeout. SSH.NET's # own timeout will surface on the Task as an exception once the # configured ConnectionInfo.Timeout elapses; we do not impose a # second timeout in this loop. while (-not $connectTask.Wait($ProgressInterval)) { Write-Host '.' -NoNewline } Write-Host '' if ($connectTask.IsFaulted) { # AggregateException wraps the original; rethrow the inner so # the caller's catch sees the underlying SSH.NET exception type # (e.g. SocketException, SshOperationTimeoutException) instead # of a generic AggregateException. throw $connectTask.Exception.InnerException } $client } |