using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Ssh;
/// Which way a forward carries connections.
/// The three OpenSSH has, named for the flag each one is: -L, -R and -D.
public enum SshPortForwardKind
{
///
/// A port on this machine, carried to a host and port as the remote sees them. ssh -L.
///
Local = 0,
///
/// A port on the remote, carried back to a host and port as this machine sees them. ssh -R.
///
Remote = 1,
///
/// A SOCKS proxy on this machine, whose every connection leaves from the remote. ssh -D.
///
Dynamic = 2,
}
/// One forward to run over a connection.
/// Which way it carries connections.
///
/// The port to listen on: on this machine for and
/// , on the remote for . Zero
/// asks for an ephemeral port, which only a listener on this machine can report back — see
/// .
///
///
/// 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.
///
/// The port on ; zero for a dynamic forward.
///
///
/// ◆ There is no bind address, and that is the point of the type. Every listener this client opens
/// is on loopback, for the reason 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. ssh -L 0.0.0.0:… 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.
///
///
/// A remote forward asks for the remote's loopback too. Whether the remote honours that is its
/// GatewayPorts setting and not this client's to decide, which is the same arrangement OpenSSH has.
///
///
public sealed record SshPortForwardSpec(
SshPortForwardKind Kind,
int BindPort,
string? TargetHost = null,
int TargetPort = 0)
{
/// Checks the spec can be started as it stands.
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;
}
}
/// Why a forward is no longer carrying a connection it accepted.
/// What went wrong, as SSH.NET put it.
public sealed class SshPortForwardErrorEventArgs(string message) : EventArgs
{
/// What went wrong.
public string Message { get; } = message;
}
/// A forward that is listening, and stops when disposed.
///
///
/// 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.
///
///
/// ◆ No byte counts. 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.
///
///
public interface ISshPortForward : IAsyncDisposable
{
/// What was asked for.
SshPortForwardSpec Spec { get; }
///
/// The port actually listened on, which differs from only when
/// that asked for an ephemeral one.
///
int BoundPort { get; }
/// Whether the listener is still up.
///
/// False once disposed, and once the connection under it has gone — SSH.NET stops every forward on a
/// session that ends.
///
bool IsListening { get; }
///
/// The most recent failure to carry a connection, or null if there has not been one.
///
///
/// 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.
///
string? LastError { get; }
///
/// Raised when a connection through this forward fails, on whichever thread SSH.NET noticed it.
///
event EventHandler? ErrorOccurred;
}
/// A forward that could not be started, with a sentence saying why.
///
/// 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.
///
public sealed class SshPortForwardException(string message, Exception? innerException = null)
: Exception(message, innerException);