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.
#
# -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 call is synchronous - the caller pays a
# single block of up to -Timeout with no console output in between.
# Consumers waiting on multi-minute connects should print a leading
# "this may take a few minutes" line so the silence does not read
# like a hang.
#
# 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)
    )

    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.
    $connInfo.Timeout = $Timeout
    $client   = [Renci.SshNet.SshClient]::new($connInfo)
    $client.Connect()
    $client
}