using System.Buffers;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
namespace DodoSSH.Client.Ssh;
///
/// A loopback SOCKS5 proxy that carries exactly one connection over one relay WebSocket.
///
///
///
/// SSH.NET cannot be handed a stream, only an address — so the relay, which is a WebSocket, is reached by
/// giving SSH.NET an address that leads into one. This listens on 127.0.0.1 at a port the system
/// chooses, answers one SOCKS5 CONNECT, and from then on moves bytes: what SSH.NET writes goes out as binary
/// messages, and what the relay sends comes back as the stream SSH.NET reads. That is ADR 0004's loopback
/// bridge, and step 3 of docs/reaching-a-host-you-cannot-dial.md.
///
///
/// SOCKS5 rather than a plain pipe, so the host stays the host. The connection made through this is
/// told the target's real name and port, and pins its host key under them; the CONNECT carrying that name
/// arrives here and is answered without being acted on, because where the bytes go was decided by the
/// server when it issued the ticket, and nothing this side says can change it.
///
///
/// Three properties are the security of this, and each is held by a test. It binds loopback and nothing
/// else — asserted on the bound address, because a bridge on every interface would be a proxy into the
/// relay for anyone on the network and nothing would report it. It accepts once and stops listening, so a
/// second process on this machine cannot take a turn. And it gives up if nobody arrives within
/// , so a connect that failed before dialling cannot leave it waiting.
///
///
/// No operation on the WebSocket is cancelled. Cancelling one aborts a .NET WebSocket, and an aborted
/// one never reads the close frame that says why the relay ended — which is the one thing the user needs to
/// be told when the server could not reach the host. The bridge stops a direction by closing, and waits a
/// bounded time for the other side to agree, as the relay's own endpoint does.
///
///
public sealed class RelayBridge : IAsyncDisposable
{
/// How long the bridge waits for its one connection before closing the relay unused.
///
/// The connection is made the moment the bridge exists, so this bounds a caller that failed between the
/// two. Longer than any SSH connect timeout, so it never ends a connect that is merely slow.
///
public static readonly TimeSpan AcceptTimeout = TimeSpan.FromSeconds(60);
private const int BufferBytes = 32 * 1024;
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
private readonly TcpListener listener;
private readonly WebSocket socket;
private readonly CancellationTokenSource stopping = new();
private readonly Task running;
private int disposed;
private RelayBridge(WebSocket socket, TimeSpan acceptTimeout)
{
this.socket = socket;
// The address rather than "localhost", so there is no resolver and no hosts file between this and
// the interface it means. See the remarks on SshLoopbackProxy.
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start(backlog: 1);
Endpoint = (IPEndPoint)listener.LocalEndpoint;
running = RunAsync(acceptTimeout);
}
/// Where the bridge is listening. Loopback, always.
public IPEndPoint Endpoint { get; }
/// The proxy to hand the connection, which then owns this bridge.
public SshLoopbackProxy Proxy => new(Endpoint.Port, this);
///
/// How the relay closed the socket, once it has; null while it is open or when it vanished without
/// saying.
///
///
/// Read after a connect through the bridge fails. The relay says in a close code what went wrong on its
/// side — the server could not reach the host, or has too many sessions open — and that is the sentence
/// the user needs in place of the transport error SSH.NET reports for a stream that ended.
///
public WebSocketCloseStatus? CloseStatus { get; private set; }
/// The relay's own words about why it closed, alongside .
public string? CloseDescription { get; private set; }
/// Completes when the bridge has finished: its one connection is over, or nobody came.
public Task Completion => running;
/// Starts a bridge over an open relay socket, which it then owns.
public static RelayBridge Start(WebSocket socket) => Start(socket, AcceptTimeout);
/// Starts a bridge with a shorter wait for its connection, for tests.
internal static RelayBridge Start(WebSocket socket, TimeSpan acceptTimeout)
{
ArgumentNullException.ThrowIfNull(socket);
return new RelayBridge(socket, acceptTimeout);
}
///
///
/// Idempotent, because two owners may reach it: the connection that was handed the proxy, and a caller
/// tidying up after a connect that never got that far.
///
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) != 0)
{
return;
}
await stopping.CancelAsync().ConfigureAwait(false);
listener.Stop();
if (!await SettlesAsync(running, CloseTimeout * 2).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(running, CloseTimeout).ConfigureAwait(false);
}
socket.Dispose();
stopping.Dispose();
}
private async Task RunAsync(TimeSpan acceptTimeout)
{
TcpClient? client = null;
try
{
using (var accepting = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token))
{
accepting.CancelAfter(acceptTimeout);
client = await listener.AcceptTcpClientAsync(accepting.Token).ConfigureAwait(false);
}
// Once. A second connection to this port is somebody else's, and there is nothing for it here.
listener.Stop();
client.NoDelay = true;
var stream = client.GetStream();
if (await Socks5.AcceptConnectAsync(stream, stopping.Token).ConfigureAwait(false))
{
await PipeAsync(stream).ConfigureAwait(false);
}
}
catch (Exception exception)
when (exception is OperationCanceledException or SocketException or IOException or ObjectDisposedException)
{
// Nobody came, the bridge was disposed, or the connection went away mid-handshake. The relay is
// closed below either way.
}
finally
{
listener.Stop();
client?.Dispose();
await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
}
}
/// Moves bytes both ways until either side ends, then lets the other finish.
private async Task PipeAsync(NetworkStream stream)
{
var upstream = TcpToRelayAsync(stream);
var downstream = RelayToTcpAsync(stream);
var first = await Task.WhenAny(upstream, downstream).ConfigureAwait(false);
if (first == upstream)
{
// SSH.NET closed its end. Say so to the relay, then give it a moment to answer, which is what
// ends the receive in progress without aborting it.
await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
if (!await SettlesAsync(downstream, CloseTimeout).ConfigureAwait(false))
{
socket.Abort();
}
}
else
{
// The relay ended. Closing the TCP side is how SSH.NET hears it, as the end of its stream.
stream.Socket.Shutdown(SocketShutdown.Both);
await SettlesAsync(upstream, CloseTimeout).ConfigureAwait(false);
}
}
private async Task TcpToRelayAsync(NetworkStream stream)
{
var buffer = ArrayPool.Shared.Rent(BufferBytes);
try
{
int read;
while ((read = await stream.ReadAsync(buffer.AsMemory(0, BufferBytes), stopping.Token).ConfigureAwait(false)) > 0)
{
// Not cancellable, deliberately. See the remarks on this class.
await socket
.SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None)
.ConfigureAwait(false);
}
}
catch (Exception exception)
when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
{
// Either end went away. PipeAsync decides what that means.
}
finally
{
ArrayPool.Shared.Return(buffer);
}
}
private async Task RelayToTcpAsync(NetworkStream stream)
{
var buffer = ArrayPool.Shared.Rent(BufferBytes);
try
{
while (true)
{
// Not cancellable, deliberately. See the remarks on this class.
var received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
.ConfigureAwait(false);
if (received.MessageType == WebSocketMessageType.Close)
{
CloseStatus = socket.CloseStatus;
CloseDescription = socket.CloseStatusDescription;
return;
}
await stream.WriteAsync(buffer.AsMemory(0, received.Count), stopping.Token).ConfigureAwait(false);
}
}
catch (Exception exception)
when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
{
// Either end went away. PipeAsync decides what that means.
}
finally
{
ArrayPool.Shared.Return(buffer);
}
}
/// Sends a close, if one can still be sent, bounded so a vanished peer cannot hold it.
private async Task CloseSocketAsync(WebSocketCloseStatus status)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
{
return;
}
using var timeout = new CancellationTokenSource(CloseTimeout);
try
{
await socket.CloseOutputAsync(status, string.Empty, timeout.Token).ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// Gone already.
}
}
private static async Task SettlesAsync(Task task, TimeSpan within)
{
try
{
await task.WaitAsync(within).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
}
///
/// The server half of SOCKS5 (RFC 1928), as much of it as SSH.NET speaks: no authentication, CONNECT.
///
private static class Socks5
{
private const byte Version = 5;
private const byte NoAuthentication = 0;
private const byte NoAcceptableMethod = 0xFF;
private const byte Connect = 1;
private const byte Succeeded = 0;
private const byte CommandNotSupported = 7;
private const byte AddressTypeNotSupported = 8;
///
/// Answers the greeting and one CONNECT, whatever it names.
///
/// Whether the stream is now a tunnel; false when the client asked for something else.
///
/// The target in the request is read and discarded. It is the host the connection is about,
/// which is why SSH.NET sends it, and it is not where the bytes go, which the relay decided. The bound
/// address in the reply is all zeroes: nothing on the far side of a relay has an address the client
/// could use, and SSH.NET does not read it.
///
internal static async Task AcceptConnectAsync(Stream stream, CancellationToken cancellationToken)
{
var header = await ReadAsync(stream, 2, cancellationToken).ConfigureAwait(false);
if (header[0] != Version)
{
return false;
}
var methods = await ReadAsync(stream, header[1], cancellationToken).ConfigureAwait(false);
if (!methods.Contains(NoAuthentication))
{
await stream.WriteAsync(new byte[] { Version, NoAcceptableMethod }, cancellationToken).ConfigureAwait(false);
return false;
}
await stream.WriteAsync(new byte[] { Version, NoAuthentication }, cancellationToken).ConfigureAwait(false);
var request = await ReadAsync(stream, 4, cancellationToken).ConfigureAwait(false);
if (request[0] != Version || request[1] != Connect)
{
await ReplyAsync(stream, CommandNotSupported, cancellationToken).ConfigureAwait(false);
return false;
}
var addressLength = request[3] switch
{
1 => 4,
4 => 16,
3 => (await ReadAsync(stream, 1, cancellationToken).ConfigureAwait(false))[0],
_ => -1,
};
if (addressLength < 0)
{
await ReplyAsync(stream, AddressTypeNotSupported, cancellationToken).ConfigureAwait(false);
return false;
}
// The address, then the port. Neither is used; see the remarks above.
await ReadAsync(stream, addressLength + 2, cancellationToken).ConfigureAwait(false);
await ReplyAsync(stream, Succeeded, cancellationToken).ConfigureAwait(false);
return true;
}
private static Task ReplyAsync(Stream stream, byte status, CancellationToken cancellationToken) =>
stream.WriteAsync(new byte[] { Version, status, 0, 1, 0, 0, 0, 0, 0, 0 }, cancellationToken).AsTask();
private static async Task ReadAsync(Stream stream, int count, CancellationToken cancellationToken)
{
var buffer = new byte[count];
await stream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
return buffer;
}
}
}