Add port forwarding: local, remote and dynamic, per terminal session
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s

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:
jaap-jan committed 2026-09-28 20:47:02 +02:00
1 parent c8848ec1e3
commit f05deb4fbc
36 files changed
+2776 -42

No files matched your search

+9
View File
@@ -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()
{
+223
View File
@@ -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));
}
}
+164
View File
@@ -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);