using System.Text; using Renci.SshNet; using Renci.SshNet.Common; namespace DodoSSH.Client.Ssh; /// /// Opens SSH connections with SSH.NET, checking host key trust during the handshake. /// /// /// The pinned fingerprint is looked up before connecting, so the comparison inside SSH.NET's /// synchronous HostKeyReceived 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. /// public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory, ISftpSessionFactory { private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15); /// /// How often to put a byte on the wire for a connection that is otherwise idle. /// /// /// /// ◆ Without this a rebooted machine is never noticed, and the tab claims to be connected for ever. /// 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. /// /// /// It is the write that matters, not the reply. SSH.NET's keep-alive is SSH_MSG_IGNORE, /// 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. /// /// /// Thirty seconds, which is what OpenSSH's own ServerAliveInterval 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. /// /// private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30); /// /// How much of a file SSH.NET reads or writes per SFTP request. /// /// /// 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. /// private const uint SftpBufferSize = 64 * 1024; /// Where a is, and the only address one is ever dialled at. /// /// The literal rather than IPAddress.Loopback.ToString(), 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 localhost 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. /// private const string LoopbackAddress = "127.0.0.1"; /// public async Task ConnectAsync( SshConnectionRequest request, IProgress? 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; } } /// /// /// A second connection to the host rather than a second channel on one that may already be open — see /// 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. /// public async Task 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; } } /// /// Disposes a proxy the connection was given, when the connection it was given for did not happen. /// /// /// Every failure path, including the ones before a socket exists — an unreadable key throws from /// — 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. /// private static async ValueTask DisposeAsync(IAsyncDisposable? lifetime) { if (lifetime is not null) { await lifetime.DisposeAsync().ConfigureAwait(false); } } private async Task OpenSftpCoreAsync( SshConnectionRequest request, IAsyncDisposable? lifetime, CancellationToken cancellationToken) { var info = BuildConnectionInfo(request); // 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. // // ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's // default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange // and the home directory lookup — synchronously after the login, where the cancellation token is not // read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends // text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said // "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off. var client = new SftpClient(info) { BufferSize = SftpBufferSize, KeepAliveInterval = KeepAliveInterval, OperationTimeout = info.Timeout, }; // 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. HostKeyGate gate; try { gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken) .ConfigureAwait(false); } catch (SshOperationTimeoutException exception) when (info.IsAuthenticated) { throw SftpHandshakeTimedOut(request, info, exception); } client.OperationTimeout = Timeout.InfiniteTimeSpan; // 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); } /// Says what a timeout after the login means for a file-transfer connection. /// /// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words /// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then /// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking /// at the network for what is nearly always a line of text in a login script on the host. /// private static TimeoutException SftpHandshakeTimedOut( SshConnectionRequest request, ConnectionInfo info, SshOperationTimeoutException exception) => new( $"{request.Host} accepted the login, but its SFTP service did not answer within " + $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that " + "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when " + "it is not interactive.", exception); /// /// Runs the handshake with host key trust attached, and translates a refusal this factory caused. /// /// /// Shared by the shell and the file-transfer paths over BaseClient, which is where SSH.NET puts /// both ConnectAsync and HostKeyReceived. 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. /// private async Task ConnectThroughHostKeyGateAsync( BaseClient client, SshConnectionRequest request, IProgress? 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; } /// /// Offers the host key algorithms this endpoint has pins for ahead of every other. /// /// /// /// 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 /// ed25519 key alongside the rsa 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. /// /// /// 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. /// /// private static void PreferPinnedAlgorithms( ConnectionInfo info, IReadOnlyList 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); } } /// /// Decides host key trust during the handshake, and remembers enough to explain a refusal. /// /// /// /// 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. /// /// /// ◆ "Pinned" means pinned for this endpoint, not for this algorithm. 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 /// rsa-sha2-256 and rsa-sha2-512, and its fingerprint says so), and is otherwise a /// mismatch against the endpoint's pin. is what keeps that from /// refusing a server that still has its pinned key. /// /// private sealed class HostKeyGate( IKnownHostStore knownHosts, SshConnectionRequest request, IReadOnlyList endpointPins, IProgress? progress, CancellationToken cancellationToken) { /// What the server offered, once the handshake has reached that point. 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); } } /// The specific exception for a refusal this gate caused, or null if it did not. 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; } } /// /// /// 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. /// /// /// ◆ A proxied connection differs here and nowhere else. 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 /// 127.0.0.1. The proxy is a route, not a destination, and this is the one method that needs to /// know the difference. See . /// /// /// Timeout 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. /// /// 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); } } /// An SSH.NET-backed connection. /// /// 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. /// internal sealed class SshNetConnection( SshClient client, HostKeyPresentation hostKey, IAsyncDisposable? proxyLifetime = null) : ISshConnection { /// public bool IsConnected => client.IsConnected; /// public HostKeyPresentation HostKey { get; } = hostKey; /// /// /// Read at construction rather than lazily: by the time an exists, /// has already awaited client.ConnectAsync, so /// ConnectionInfo 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 MainWindowViewModel.NullIfEmpty. /// public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty; /// /// /// /// On the thread pool, because CreateShellStream 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. /// /// /// 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. /// /// public async Task 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; } } /// Closes a shell nobody is waiting for any more, or lets its failure go unremarked. /// /// 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. /// private static async Task CloseWhenItLandsAsync(Task 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); } /// public Task StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(spec); return SshNetPortForward.StartAsync(client, spec, cancellationToken); } /// public async ValueTask DisposeAsync() { client.Dispose(); if (proxyLifetime is not null) { await proxyLifetime.DisposeAsync().ConfigureAwait(false); } } } /// An SSH.NET-backed shell session. internal sealed class SshNetShellSession : ISshShellSession { private readonly ShellStream shell; private readonly SshClient client; private string? transportFailure; /// The channel this shell reads and writes. /// /// The connection underneath it, subscribed to for the one thing the stream cannot report on its own — /// see . /// internal SshNetShellSession(ShellStream shell, SshClient client) { this.shell = shell; this.client = client; client.ErrorOccurred += OnTransportError; } /// /// /// CanWrite rather than CanRead, which is what this used to read and which is a constant: /// ShellStream.CanRead 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. CanWrite is the one that goes false when the stream is disposed, which is what closing /// the channel and losing the session both do. /// public bool IsOpen => shell.CanWrite; /// /// /// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a /// 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. /// public string? TransportFailure => Volatile.Read(ref transportFailure); /// /// Latches the first transport failure and ends the read that is waiting on a connection which is /// no longer there. /// /// /// /// ◆ 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. ShellStream ends a blocked read from exactly two /// places: its channel being closed, and the session raising Disconnected. A shell that exits /// gives the first. But Session raises Disconnected from one method only — /// OnDisconnectReceived, 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 Disconnected. So the stream was never disposed, its /// Monitor.Wait 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. /// /// /// Keep-alive is what makes the failure happen on an idle connection; this is what makes it /// arrive. Neither is sufficient alone, which is why both are in this file. /// /// /// Queued rather than disposed inline, and that is not tidiness. 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. /// /// /// The latch is set before the queue, so 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. /// /// 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); } /// /// /// ShellStream does not override ReadAsync, so the base /// implementation runs the blocking read on a thread-pool thread. Every idle session therefore /// parks one thread; see docs/platform-flags.md. /// public ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) => shell.ReadAsync(buffer, cancellationToken); /// /// /// The flush is mandatory, not an optimisation. ShellStream.Write 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 WriteLine flushes for this /// reason, which is why the spike tests never hit it. /// /// 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. /// /// public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { await shell.WriteAsync(data, cancellationToken).ConfigureAwait(false); await shell.FlushAsync(cancellationToken).ConfigureAwait(false); } /// 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); } /// 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); } } /// Convenience helpers over a shell session. public static class SshShellSessionExtensions { /// Writes UTF-8 text to the remote. public static ValueTask WriteTextAsync( this ISshShellSession session, string text, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken); } }