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