using System.Globalization; using System.Net; using System.Net.Sockets; using Renci.SshNet; using Renci.SshNet.Common; namespace DodoSSH.Client.Ssh; /// A forward over an SSH.NET client. /// /// /// One class for all three kinds, because SSH.NET's three share a base with the only members this needs — /// Start, Stop, IsStarted and the Exception event — and differ only in how they /// are built and where their bound port is read from. /// /// /// ◆ Started and stopped off the calling thread. A remote forward's Start sends /// tcpip-forward and blocks until the server answers, and every kind's Stop waits for its open /// channels to drain. Either would otherwise hold the UI thread for a round trip or longer. /// /// internal sealed class SshNetPortForward : ISshPortForward { /// Where every listener on this machine binds. See . private const string LoopbackAddress = "127.0.0.1"; private readonly SshClient client; private readonly ForwardedPort port; private string? lastError; private int disposed; private SshNetPortForward(SshClient client, ForwardedPort port, SshPortForwardSpec spec) { this.client = client; this.port = port; Spec = spec; port.Exception += OnException; } /// public event EventHandler? ErrorOccurred; /// public SshPortForwardSpec Spec { get; } /// public int BoundPort => port switch { ForwardedPortLocal local => (int)local.BoundPort, ForwardedPortDynamic dynamic => (int)dynamic.BoundPort, ForwardedPortRemote remote => (int)remote.BoundPort, _ => Spec.BindPort, }; /// /// /// Read off the port rather than the client: SSH.NET stops every forward on a session that ends, and /// SshClient.IsConnected throws rather than answering once the connection has been disposed. /// public bool IsListening => Volatile.Read(ref disposed) == 0 && port.IsStarted; /// public string? LastError => Volatile.Read(ref lastError); /// Starts a forward on a connected client. /// The spec is not valid. /// The forward could not be started. internal static async Task StartAsync( SshClient client, SshPortForwardSpec spec, CancellationToken cancellationToken) { if (!spec.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(spec)); } var listener = await CreateAsync(spec, cancellationToken).ConfigureAwait(false); var forward = new SshNetPortForward(client, listener, spec); try { client.AddForwardedPort(listener); await Task.Run(listener.Start, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { await forward.DisposeAsync().ConfigureAwait(false); throw new SshPortForwardException(Explain(spec, exception), exception); } catch { await forward.DisposeAsync().ConfigureAwait(false); throw; } return forward; } /// /// /// Safe on a client that has already gone: SSH.NET stops its forwards when the session ends, and what /// is left to do then is let go of the port object, which cannot fail in a way anybody could act on. /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 1) { return; } port.Exception -= OnException; await Task.Run(() => { try { client.RemoveForwardedPort(port); } catch (Exception exception) when (exception is ObjectDisposedException or SshException or SocketException or InvalidOperationException) { // The client or its session is already gone, and took the listener with it. } port.Dispose(); }).ConfigureAwait(false); } /// /// /// A remote forward's target is resolved here, asynchronously, rather than by SSH.NET. Its string /// constructor resolves both addresses synchronously and takes whichever comes first, so /// localhost could become ::1 — which a service listening on 127.0.0.1 alone /// refuses — and the bound address could be sent to the remote as an IPv6 literal it has no reason to /// expect. IPv4 is preferred for the target for the first reason, and the bound address is the IPv4 /// loopback literal for the second. /// /// /// A local forward's target is not resolved at all. It travels to the remote as a name in the /// direct-tcpip request, which is what lets it name a machine only the remote can see. /// /// private static async Task CreateAsync(SshPortForwardSpec spec, CancellationToken cancellationToken) { switch (spec.Kind) { case SshPortForwardKind.Local: return new ForwardedPortLocal( LoopbackAddress, (uint)spec.BindPort, spec.TargetHost!, (uint)spec.TargetPort); case SshPortForwardKind.Dynamic: return new ForwardedPortDynamic(LoopbackAddress, (uint)spec.BindPort); default: var target = await ResolveAsync(spec.TargetHost!, cancellationToken).ConfigureAwait(false); return new ForwardedPortRemote(IPAddress.Loopback, (uint)spec.BindPort, target, (uint)spec.TargetPort); } } private static async Task ResolveAsync(string host, CancellationToken cancellationToken) { if (IPAddress.TryParse(host, out var literal)) { return literal; } IPAddress[] addresses; try { addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); } catch (SocketException exception) { throw new SshPortForwardException($"'{host}' could not be resolved on this machine.", exception); } return addresses.FirstOrDefault(address => address.AddressFamily is AddressFamily.InterNetwork) ?? addresses.FirstOrDefault() ?? throw new SshPortForwardException($"'{host}' could not be resolved on this machine."); } /// Says which port could not be had, and where, rather than repeating a socket error code. private static string Explain(SshPortForwardSpec spec, Exception exception) { var bind = spec.BindPort.ToString(CultureInfo.InvariantCulture); return exception switch { SshPortForwardException own => own.Message, SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse } => $"Port {bind} on this machine is already in use.", SocketException { SocketErrorCode: SocketError.AccessDenied } => $"This machine would not let DodoSSH listen on port {bind}.", // What SSH.NET throws when the server answers tcpip-forward with a refusal. The server gives no // reason, so neither can this — but the likely ones are worth naming. SshException when spec.Kind is SshPortForwardKind.Remote => $"The remote would not listen on port {bind}. It may be in use there, below 1024, or " + "forbidden by the server's AllowTcpForwarding setting.", _ => exception.Message, }; } /// /// Raised by SSH.NET on its own threads for a connection through the forward that failed — most often a /// target that refused it. The listener stays up; see . /// private void OnException(object? sender, ExceptionEventArgs e) { Volatile.Write(ref lastError, e.Exception.Message); ErrorOccurred?.Invoke(this, new SshPortForwardErrorEventArgs(e.Exception.Message)); } }