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 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 client = new SshClient(BuildConnectionInfo(request)); var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken) .ConfigureAwait(false); return new SshNetConnection(client, gate.Presented!); } /// /// /// 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 client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize }; // 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); } /// /// 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 gate = new HostKeyGate(knownHosts, request, 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; } /// /// 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. /// private sealed class HostKeyGate( IKnownHostStore knownHosts, SshConnectionRequest request, 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) { // Refused, not prompted. The caller decides, off the handshake thread. e.CanTrust = false; return; } 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. internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : 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 a non-nullable string, so this reads straight through rather than coalescing /// a null that the library's own contract says cannot occur. /// public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption; /// public 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 shell = client.CreateShellStream( "xterm-256color", effective.Columns, effective.Rows, effective.PixelWidth, effective.PixelHeight, 4096); return Task.FromResult(new SshNetShellSession(shell)); } /// public ValueTask DisposeAsync() { client.Dispose(); return ValueTask.CompletedTask; } } /// An SSH.NET-backed shell session. internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession { /// public bool IsOpen => shell.CanRead; /// /// /// 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() { 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); } }