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));
}
}