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
@@ -190,6 +190,15 @@ public interface ISshConnection : IAsyncDisposable
|
||||
|
||||
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
|
||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Starts a port forward over this connection.</summary>
|
||||
/// <remarks>
|
||||
/// Runs beside the shell on the same transport, as <c>ssh -L</c> does, and stops when the connection
|
||||
/// does. Any number may be started; each is its own listener and fails on its own.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">The spec is not valid; see <see cref="SshPortForwardSpec.TryValidate"/>.</exception>
|
||||
/// <exception cref="SshPortForwardException">The forward could not be started, with a message saying why.</exception>
|
||||
Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -417,6 +417,14 @@ internal sealed class SshNetConnection(
|
||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(spec);
|
||||
|
||||
return SshNetPortForward.StartAsync(client, spec, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>Which way a forward carries connections.</summary>
|
||||
/// <remarks>The three OpenSSH has, named for the flag each one is: <c>-L</c>, <c>-R</c> and <c>-D</c>.</remarks>
|
||||
public enum SshPortForwardKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A port on this machine, carried to a host and port as the remote sees them. <c>ssh -L</c>.
|
||||
/// </summary>
|
||||
Local = 0,
|
||||
|
||||
/// <summary>
|
||||
/// A port on the remote, carried back to a host and port as this machine sees them. <c>ssh -R</c>.
|
||||
/// </summary>
|
||||
Remote = 1,
|
||||
|
||||
/// <summary>
|
||||
/// A SOCKS proxy on this machine, whose every connection leaves from the remote. <c>ssh -D</c>.
|
||||
/// </summary>
|
||||
Dynamic = 2,
|
||||
}
|
||||
|
||||
/// <summary>One forward to run over a connection.</summary>
|
||||
/// <param name="Kind">Which way it carries connections.</param>
|
||||
/// <param name="BindPort">
|
||||
/// The port to listen on: on this machine for <see cref="SshPortForwardKind.Local"/> and
|
||||
/// <see cref="SshPortForwardKind.Dynamic"/>, on the remote for <see cref="SshPortForwardKind.Remote"/>. Zero
|
||||
/// asks for an ephemeral port, which only a listener on this machine can report back — see
|
||||
/// <see cref="TryValidate"/>.
|
||||
/// </param>
|
||||
/// <param name="TargetHost">
|
||||
/// Where each connection goes: resolved by the remote for a local forward and by this machine for a remote
|
||||
/// one. Null for a dynamic forward, whose target is whatever each SOCKS client asks for.
|
||||
/// </param>
|
||||
/// <param name="TargetPort">The port on <paramref name="TargetHost"/>; zero for a dynamic forward.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>There is no bind address, and that is the point of the type.</b> Every listener this client opens
|
||||
/// is on loopback, for the reason <see cref="SshLoopbackProxy"/> gives: a forward bound to any other
|
||||
/// interface hands the remote's network to whoever else can reach this machine, for as long as the session
|
||||
/// is up. <c>ssh -L 0.0.0.0:…</c> is a flag somebody types knowing what it does; a field in a synced host
|
||||
/// that another member of the keychain filled in is not. Leaving the address out makes that
|
||||
/// unrepresentable rather than merely discouraged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A remote forward asks for the remote's loopback too. Whether the remote honours that is its
|
||||
/// <c>GatewayPorts</c> setting and not this client's to decide, which is the same arrangement OpenSSH has.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SshPortForwardSpec(
|
||||
SshPortForwardKind Kind,
|
||||
int BindPort,
|
||||
string? TargetHost = null,
|
||||
int TargetPort = 0)
|
||||
{
|
||||
/// <summary>Checks the spec can be started as it stands.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (!Enum.IsDefined(Kind))
|
||||
{
|
||||
reason = $"'{Kind}' is not a kind of forward.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// A remote listener's port is chosen by the remote, and SSH.NET never reads the one it chose back,
|
||||
// so an ephemeral remote forward would be listening somewhere nobody could name.
|
||||
var lowest = Kind is SshPortForwardKind.Remote ? 1 : 0;
|
||||
|
||||
if (BindPort < lowest || BindPort > 65535)
|
||||
{
|
||||
reason = $"The listening port must be between {lowest} and 65535, not {BindPort}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Kind is SshPortForwardKind.Dynamic)
|
||||
{
|
||||
if (TargetHost is not null || TargetPort != 0)
|
||||
{
|
||||
reason = "A dynamic forward has no target: each connection through it names its own.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TargetHost))
|
||||
{
|
||||
reason = "A forward needs a host to forward to.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TargetPort is < 1 or > 65535)
|
||||
{
|
||||
reason = $"The target port must be between 1 and 65535, not {TargetPort}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Why a forward is no longer carrying a connection it accepted.</summary>
|
||||
/// <param name="message">What went wrong, as SSH.NET put it.</param>
|
||||
public sealed class SshPortForwardErrorEventArgs(string message) : EventArgs
|
||||
{
|
||||
/// <summary>What went wrong.</summary>
|
||||
public string Message { get; } = message;
|
||||
}
|
||||
|
||||
/// <summary>A forward that is listening, and stops when disposed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Owned by whoever started it, and also stopped by the connection it runs over: a connection that closes
|
||||
/// takes every forward on it down with it, so disposing one afterwards is safe and does nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>No byte counts.</b> SSH.NET copies a forwarded channel's bytes inside its own channel classes and
|
||||
/// exposes no count of them, so a figure here would be an estimate presented as a measurement.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISshPortForward : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What was asked for.</summary>
|
||||
SshPortForwardSpec Spec { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The port actually listened on, which differs from <see cref="SshPortForwardSpec.BindPort"/> only when
|
||||
/// that asked for an ephemeral one.
|
||||
/// </summary>
|
||||
int BoundPort { get; }
|
||||
|
||||
/// <summary>Whether the listener is still up.</summary>
|
||||
/// <remarks>
|
||||
/// False once disposed, and once the connection under it has gone — SSH.NET stops every forward on a
|
||||
/// session that ends.
|
||||
/// </remarks>
|
||||
bool IsListening { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The most recent failure to carry a connection, or null if there has not been one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A failure here does not stop the listener. The usual cause is the target refusing one connection —
|
||||
/// a database that is down, a port nothing is on — and the next connection may well succeed.
|
||||
/// </remarks>
|
||||
string? LastError { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a connection through this forward fails, on whichever thread SSH.NET noticed it.
|
||||
/// </summary>
|
||||
event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred;
|
||||
}
|
||||
|
||||
/// <summary>A forward that could not be started, with a sentence saying why.</summary>
|
||||
/// <remarks>
|
||||
/// What SSH.NET throws for the two commonest refusals is a socket error code and "failed to start", neither
|
||||
/// of which names the port. The message here does, and the original is kept as the inner exception.
|
||||
/// </remarks>
|
||||
public sealed class SshPortForwardException(string message, Exception? innerException = null)
|
||||
: Exception(message, innerException);
|
||||
Reference in new issue
Block a user