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

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