Public Access
720 lines
32 KiB
C#
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);
|
|
}
|
|
}
|