Public Access
Add port forwarding: local, remote and dynamic, per terminal session
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
This commit is contained in:
1 parent
c8848ec1e3
commit
f05deb4fbc
36 files changed
+2776
-42
No files matched your search
@@ -0,0 +1,223 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>A forward over an SSH.NET client.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One class for all three kinds, because SSH.NET's three share a base with the only members this needs —
|
||||
/// <c>Start</c>, <c>Stop</c>, <c>IsStarted</c> and the <c>Exception</c> event — and differ only in how they
|
||||
/// are built and where their bound port is read from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Started and stopped off the calling thread.</b> A remote forward's <c>Start</c> sends
|
||||
/// <c>tcpip-forward</c> and blocks until the server answers, and every kind's <c>Stop</c> waits for its open
|
||||
/// channels to drain. Either would otherwise hold the UI thread for a round trip or longer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SshNetPortForward : ISshPortForward
|
||||
{
|
||||
/// <summary>Where every listener on this machine binds. See <see cref="SshPortForwardSpec"/>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SshPortForwardSpec Spec { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BoundPort => port switch
|
||||
{
|
||||
ForwardedPortLocal local => (int)local.BoundPort,
|
||||
ForwardedPortDynamic dynamic => (int)dynamic.BoundPort,
|
||||
ForwardedPortRemote remote => (int)remote.BoundPort,
|
||||
_ => Spec.BindPort,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Read off the port rather than the client: SSH.NET stops every forward on a session that ends, and
|
||||
/// <c>SshClient.IsConnected</c> throws rather than answering once the connection has been disposed.
|
||||
/// </remarks>
|
||||
public bool IsListening => Volatile.Read(ref disposed) == 0 && port.IsStarted;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? LastError => Volatile.Read(ref lastError);
|
||||
|
||||
/// <summary>Starts a forward on a connected client.</summary>
|
||||
/// <exception cref="ArgumentException">The spec is not valid.</exception>
|
||||
/// <exception cref="SshPortForwardException">The forward could not be started.</exception>
|
||||
internal static async Task<ISshPortForward> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>localhost</c> could become <c>::1</c> — which a service listening on <c>127.0.0.1</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A local forward's target is not resolved at all. It travels to the remote as a name in the
|
||||
/// <c>direct-tcpip</c> request, which is what lets it name a machine only the remote can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ForwardedPort> 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<IPAddress> 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.");
|
||||
}
|
||||
|
||||
/// <summary>Says which port could not be had, and where, rather than repeating a socket error code.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <see cref="ISshPortForward.LastError"/>.
|
||||
/// </remarks>
|
||||
private void OnException(object? sender, ExceptionEventArgs e)
|
||||
{
|
||||
Volatile.Write(ref lastError, e.Exception.Message);
|
||||
ErrorOccurred?.Invoke(this, new SshPortForwardErrorEventArgs(e.Exception.Message));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user