Public Access
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.
165 lines
6.5 KiB
C#
165 lines
6.5 KiB
C#
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);
|