Files
DodoSSH/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
T

720 lines
32 KiB
C#

using System.Text;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace DodoSSH.Client.Ssh;
/// <summary>
/// Opens SSH connections with SSH.NET, checking host key trust during the handshake.
/// </summary>
/// <remarks>
/// The pinned fingerprint is looked up <em>before</em> connecting, so the comparison inside SSH.NET's
/// synchronous <c>HostKeyReceived</c> event is a pure equality check with no I/O and no chance of
/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
/// exception the caller resolves asynchronously.
/// </remarks>
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
: ISshConnectionFactory, ISftpSessionFactory
{
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// How often to put a byte on the wire for a connection that is otherwise idle.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
/// fail. This is what makes the question get asked.
/// </para>
/// <para>
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
/// about a slow link.
/// </para>
/// <para>
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
/// probe after the machine came back.
/// </para>
/// </remarks>
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
/// <summary>
/// How much of a file SSH.NET reads or writes per SFTP request.
/// </summary>
/// <remarks>
/// SSH.NET's default is 32 KiB, which is a request per 32 KiB and a round trip's latency between each on
/// a link the window would happily keep full. 64 KiB is the largest an OpenSSH server accepts without
/// negotiation, so it is the ceiling rather than a guess — anything above it is answered with a shorter
/// read, which SSH.NET handles but which buys nothing.
/// </remarks>
private const uint SftpBufferSize = 64 * 1024;
/// <summary>Where a <see cref="SshLoopbackProxy"/> is, and the only address one is ever dialled at.</summary>
/// <remarks>
/// The literal rather than <c>IPAddress.Loopback.ToString()</c>, and rather than "localhost": SSH.NET
/// takes the proxy host as a string and resolves it, so a name would put a DNS lookup — and whatever
/// the machine's hosts file says <c>localhost</c> means — inside the connect path of every proxied
/// connection. It is also the half of the loopback promise this assembly can keep on its own; the other
/// half is that the proxy bound there, which is the caller's to get right.
/// </remarks>
private const string LoopbackAddress = "127.0.0.1";
/// <inheritdoc />
public async Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var lifetime = request.Proxy?.Lifetime;
try
{
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
.ConfigureAwait(false);
return new SshNetConnection(client, gate.Presented!, lifetime);
}
catch
{
await DisposeAsync(lifetime).ConfigureAwait(false);
throw;
}
}
/// <inheritdoc />
/// <remarks>
/// A second connection to the host rather than a second channel on one that may already be open — see
/// <see cref="ISftpSession"/> for why SSH.NET leaves no choice. Everything that guards a shell guards this
/// too, because it is the same handshake: the same host key gate, the same pin, the same two refusals.
/// </remarks>
public async Task<ISftpSession> OpenSftpAsync(
SshConnectionRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var lifetime = request.Proxy?.Lifetime;
try
{
return await OpenSftpCoreAsync(request, lifetime, cancellationToken).ConfigureAwait(false);
}
catch
{
await DisposeAsync(lifetime).ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Disposes a proxy the connection was given, when the connection it was given for did not happen.
/// </summary>
/// <remarks>
/// Every failure path, including the ones before a socket exists — an unreadable key throws from
/// <see cref="BuildConnectionInfo"/> — because a relay bridge left listening after a failed connect is a
/// loopback SOCKS proxy with an open relay session behind it, and nothing would ever close it.
/// </remarks>
private static async ValueTask DisposeAsync(IAsyncDisposable? lifetime)
{
if (lifetime is not null)
{
await lifetime.DisposeAsync().ConfigureAwait(false);
}
}
private async Task<ISftpSession> OpenSftpCoreAsync(
SshConnectionRequest request,
IAsyncDisposable? lifetime,
CancellationToken cancellationToken)
{
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
// since rebooted would otherwise wait on a socket nothing will ever answer.
var client = new SftpClient(BuildConnectionInfo(request))
{
BufferSize = SftpBufferSize,
KeepAliveInterval = KeepAliveInterval,
};
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
// reports itself, and a second connection opened behind an already-open shell has nothing the user
// is watching a step list for.
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
.ConfigureAwait(false);
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
// on first read, so leaving it to the property would put a round trip behind something that reads
// like a field — and the session's own remark promises this is the one path known without asking.
string home;
try
{
home = client.WorkingDirectory;
}
catch
{
client.Dispose();
throw;
}
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
}
/// <summary>
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
/// </summary>
/// <remarks>
/// Shared by the shell and the file-transfer paths over <c>BaseClient</c>, which is where SSH.NET puts
/// both <c>ConnectAsync</c> and <c>HostKeyReceived</c>. The alternative was the same twelve lines twice,
/// and the half worth getting wrong is the translation: without it a user who has never seen a host is
/// told the connection was lost.
/// </remarks>
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
BaseClient client,
SshConnectionRequest request,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
var endpointPins = await knownHosts
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
.ConfigureAwait(false);
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
client.HostKeyReceived += gate.OnHostKeyReceived;
// Before the await rather than inside the gate, because this phase is the part of the handshake
// that happens before there is anything to raise an event about: the lookup, the socket and the key
// exchange. Nothing else can report the start of it.
progress?.Report(SshConnectionPhase.Reaching);
try
{
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshConnectionException or SshAuthenticationException)
{
client.Dispose();
// Translate a refusal we caused ourselves into something the caller can act on. Without
// this the user sees "connection lost" for what is really "do you trust this key?".
throw gate.TranslateFailure() ?? exception;
}
catch
{
client.Dispose();
throw;
}
return gate;
}
/// <summary>
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
/// </summary>
/// <remarks>
/// <para>
/// The server takes the first algorithm in the client's list that it also supports, so this is what
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
/// may not be, which is exactly the choice it must not be left with.
/// </para>
/// <para>
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
/// </para>
/// </remarks>
private static void PreferPinnedAlgorithms(
ConnectionInfo info,
IReadOnlyList<HostKeyPresentation> pins)
{
if (pins.Count == 0)
{
return;
}
var pinned = pins
.Select(pin => pin.Algorithm)
.ToHashSet(StringComparer.Ordinal);
var ordered = info.HostKeyAlgorithms
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
.ToList();
info.HostKeyAlgorithms.Clear();
foreach (var algorithm in ordered)
{
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
}
}
/// <summary>
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
/// </summary>
/// <remarks>
/// <para>
/// Separate from the connect method because the decision is the security-relevant part and reads
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
/// </para>
/// <para>
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
/// — so somebody in the middle who simply offered a different key type got the first-use question
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
/// refusing a server that still has its pinned key.
/// </para>
/// </remarks>
private sealed class HostKeyGate(
IKnownHostStore knownHosts,
SshConnectionRequest request,
IReadOnlyList<HostKeyPresentation> endpointPins,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
/// <summary>What the server offered, once the handshake has reached that point.</summary>
public HostKeyPresentation? Presented { get; private set; }
private string? pinned;
private bool mismatch;
public void OnHostKeyReceived(object? sender, HostKeyEventArgs e)
{
var presentation = new HostKeyPresentation(
request.Host,
request.Port,
e.HostKeyName,
SshHostKeyFingerprint.Format(e.HostKey));
Presented = presentation;
// Reported before the lookup rather than after it, because the lookup is the wait: this is a
// vault-backed store on the handshake thread, and on a locked or cold vault it is the part of
// "checking the host key" long enough to be worth naming.
progress?.Report(SshConnectionPhase.CheckingHostKey);
// Looked up here rather than before connecting, because the negotiated algorithm is only
// known now and a server may choose a different one than it did last time.
//
// This is the one place the design cannot stay asynchronous: SSH.NET raises host key
// verification synchronously. It is a local store read rather than a UI round trip, and
// making the store synchronous instead would rule out a vault-backed implementation.
pinned = knownHosts
.FindAsync(request.Host, request.Port, e.HostKeyName, cancellationToken)
.AsTask()
.GetAwaiter()
.GetResult();
if (pinned is null && endpointPins.Count == 0)
{
// Refused, not prompted. The caller decides, off the handshake thread.
e.CanTrust = false;
return;
}
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
// against the endpoint's first pin. See the class remarks.
pinned ??= endpointPins
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
?.Fingerprint
?? endpointPins[0].Fingerprint;
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
mismatch = !matches;
e.CanTrust = matches;
// Only on acceptance, and here rather than after the await above, because this is the last
// moment SSH.NET gives anyone: returning true from this handler is what lets the handshake go on
// to offer the credential, and it does not come back until it has an answer either way. A
// refusal reports nothing — there is no authentication about to happen for it to be true of.
if (matches)
{
progress?.Report(SshConnectionPhase.Authenticating);
}
}
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
public Exception? TranslateFailure()
{
if (Presented is not { } presentation)
{
return null;
}
if (mismatch && pinned is { } pin)
{
return new SshHostKeyMismatchException(presentation, pin);
}
return pinned is null ? new SshHostKeyUnknownException(presentation) : null;
}
}
/// <remarks>
/// <para>
/// The known-host lookup inside the synchronous event is the one place this design cannot avoid
/// blocking. It is a local store read rather than a UI round trip, and the alternative — making
/// the store synchronous — would rule out the encrypted vault-backed implementation entirely.
/// </para>
/// <para>
/// ◆ <b>A proxied connection differs here and nowhere else.</b> The host, the port, the account and the
/// credential are the target's either way, and so is everything the gate above reads — which is what
/// makes a host reached through a bastion or a relay get pinned under its own name rather than under
/// <c>127.0.0.1</c>. The proxy is a route, not a destination, and this is the one method that needs to
/// know the difference. See <see cref="SshLoopbackProxy"/>.
/// </para>
/// <para>
/// <c>Timeout</c> is assigned after the branch rather than in two initialisers, because it covers the
/// whole of getting there — the proxy handshake included — and having it stated once is what stops the
/// two paths quietly drifting to different waits.
/// </para>
/// </remarks>
private static ConnectionInfo BuildConnectionInfo(SshConnectionRequest request)
{
AuthenticationMethod method = request.Credential switch
{
SshPasswordCredential password =>
new PasswordAuthenticationMethod(request.Username, password.Password),
SshPrivateKeyCredential key => new PrivateKeyAuthenticationMethod(
request.Username,
CreatePrivateKeyFile(key)),
_ => throw new NotSupportedException(
$"Credential type {request.Credential.GetType().Name} is not supported."),
};
var info = request.Proxy is { } proxy
? new ConnectionInfo(
request.Host,
request.Port,
request.Username,
ProxyTypes.Socks5,
LoopbackAddress,
proxy.Port,
// No proxy credentials, and empty rather than null: SSH.NET offers username/password
// authentication to a SOCKS5 server only when it has been given one, and both proxies this
// client will ever use are on its own loopback interface, where a password would be a
// secret shared between two halves of the same process.
string.Empty,
string.Empty,
method)
: new ConnectionInfo(request.Host, request.Port, request.Username, method);
info.Timeout = request.ConnectTimeout ?? DefaultConnectTimeout;
return info;
}
private static PrivateKeyFile CreatePrivateKeyFile(SshPrivateKeyCredential credential)
{
using var stream = new MemoryStream(credential.PrivateKeyPem, writable: false);
return credential.Passphrase is null
? new PrivateKeyFile(stream)
: new PrivateKeyFile(stream, credential.Passphrase);
}
}
/// <summary>An SSH.NET-backed connection.</summary>
/// <remarks>
/// Owns the proxy it was reached through when it was given one, and disposes it after the client — the
/// order that lets SSH.NET close its end of the loopback socket before the thing on the other end goes.
/// </remarks>
internal sealed class SshNetConnection(
SshClient client,
HostKeyPresentation hostKey,
IAsyncDisposable? proxyLifetime = null)
: ISshConnection
{
/// <inheritdoc />
public bool IsConnected => client.IsConnected;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } = hostKey;
/// <inheritdoc />
/// <remarks>
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
/// types the property as nullable all the same — it is null before the key exchange completes — so the
/// coalesce is here to satisfy that annotation, not because this call site expects to see it. Empty is the
/// value this codebase already uses for a cipher that could not be read; see <c>MainWindowViewModel.NullIfEmpty</c>.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
/// <remarks>
/// <para>
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
/// painting for as long as they took, and the cancellation token it was given was read once, before any
/// of them.
/// </para>
/// <para>
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
/// channel on the server.
/// </para>
/// </remarks>
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var effective = size.IsUsable ? size : TerminalSize.Default;
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
// only delays the first byte reaching the screen.
var opening = Task.Run(
() => client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096),
CancellationToken.None);
try
{
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
return new SshNetShellSession(shell, client);
}
catch (OperationCanceledException)
{
_ = CloseWhenItLandsAsync(opening);
throw;
}
}
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
/// <remarks>
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
/// for a channel or a connection that went away, named so that anything else still surfaces.
/// </remarks>
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
{
ShellStream shell;
try
{
shell = await opening.ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshException
or System.Net.Sockets.SocketException
or ObjectDisposedException
or InvalidOperationException)
{
return;
}
await shell.DisposeAsync().ConfigureAwait(false);
}
/// <inheritdoc />
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(spec);
return SshNetPortForward.StartAsync(client, spec, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
client.Dispose();
if (proxyLifetime is not null)
{
await proxyLifetime.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>An SSH.NET-backed shell session.</summary>
internal sealed class SshNetShellSession : ISshShellSession
{
private readonly ShellStream shell;
private readonly SshClient client;
private string? transportFailure;
/// <param name="shell">The channel this shell reads and writes.</param>
/// <param name="client">
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
/// see <see cref="TransportFailure"/>.
/// </param>
internal SshNetShellSession(ShellStream shell, SshClient client)
{
this.shell = shell;
this.client = client;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
/// <remarks>
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
/// the channel and losing the session both do.
/// </remarks>
public bool IsOpen => shell.CanWrite;
/// <inheritdoc />
/// <remarks>
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
/// would report a clean exit for a connection that had just died.
/// </remarks>
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <summary>
/// Latches the first transport failure and ends the read that is waiting on a connection which is
/// no longer there.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
/// </para>
/// <para>
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
/// </para>
/// <para>
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
/// the best case is that it unwinds when the wait times out.
/// </para>
/// <para>
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
try
{
state.Dispose();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort. The connection is already gone; what matters is that the read was
// released, and a fault tearing down a dead channel has no caller to report it to.
}
},
shell,
preferLocal: false);
}
/// <inheritdoc />
/// <remarks>
/// <c>ShellStream</c> does not override <c>ReadAsync</c>, so the base <see cref="Stream"/>
/// implementation runs the blocking read on a thread-pool thread. Every idle session therefore
/// parks one thread; see docs/platform-flags.md.
/// </remarks>
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) =>
shell.ReadAsync(buffer, cancellationToken);
/// <inheritdoc />
/// <remarks>
/// The flush is mandatory, not an optimisation. <c>ShellStream.Write</c> accumulates into an
/// internal buffer and sends nothing until flushed, so without this a keystroke is accepted,
/// reported as written, and never reaches the remote — the terminal simply stops responding to
/// input while still displaying output perfectly. SSH.NET's own <c>WriteLine</c> flushes for this
/// reason, which is why the spike tests never hit it.
/// <para>
/// Flushed per write rather than batched: a terminal has to put a keystroke on the wire
/// immediately, and there is nothing to coalesce — a human types far below any rate at which
/// batching would matter.
/// </para>
/// </remarks>
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
await shell.WriteAsync(data, cancellationToken).ConfigureAwait(false);
await shell.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Resize(TerminalSize size)
{
if (!size.IsUsable)
{
// A collapsed pane or a minimised window produces these. Forwarding one leaves the
// remote's idea of the terminal nonsensical until the next resize arrives.
return;
}
shell.ChangeWindowSize(size.Columns, size.Rows, size.PixelWidth, size.PixelHeight);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
// disposes the two in that order — is not still holding a reference to it through its own event.
client.ErrorOccurred -= OnTransportError;
await shell.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>Convenience helpers over a shell session.</summary>
public static class SshShellSessionExtensions
{
/// <summary>Writes UTF-8 text to the remote.</summary>
public static ValueTask WriteTextAsync(
this ISshShellSession session,
string text,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken);
}
}