using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
namespace DodoSSH.Client.Ssh.Tests;
///
/// The relay bridge: a real SSH connection, through a real SOCKS5 handshake, over a real WebSocket.
///
///
///
/// The relay server is stood in for by the other end of an in-process WebSocket pair that pipes to the
/// fixture's sshd — which is the whole of what the server's socket does once a ticket is spent, so the
/// bytes on the wire here are the bytes a relayed session carries. What is not here is the ticket, which
/// is the server's half and is tested there.
///
///
/// Three of these are the security properties the bridge has to keep, from
/// docs/reaching-a-host-you-cannot-dial.md: it binds loopback only, it accepts once, and a
/// connection that goes away takes the bridge — and so the relay session — with it. The failure each
/// guards against is silent: an open proxy, or a relay session left running behind a closed tab.
///
///
[Collection(SshCollection.Name)]
public sealed class RelayBridgeTests(SshServerFixture fixture)
{
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task TheBridge_ListensOnLoopbackOnly()
{
var (client, _) = await PairAsync();
await using var bridge = RelayBridge.Start(client);
// Asserted on the address rather than trusted to the constructor: a bridge on every interface
// would be a proxy into the relay for anyone on the network, and nothing would report it.
bridge.Endpoint.Address.ShouldBe(IPAddress.Loopback);
bridge.Proxy.Port.ShouldBe(bridge.Endpoint.Port);
bridge.Proxy.Lifetime.ShouldBeSameAs(bridge);
}
[Fact]
public async Task AShell_ThroughTheBridge_ReachesTheHost_AndGoesWithTheConnection()
{
var knownHosts = await TrustedStoreAsync();
var (client, server) = await PairAsync();
var relaying = RelayToSshdAsync(server);
var bridge = RelayBridge.Start(client);
// The host as the request names it, so the pin is the host's — the bridge is a SOCKS proxy, and
// the CONNECT carries this name through it rather than 127.0.0.1.
var connection = await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(bridge.Proxy), progress: null, Token);
connection.IsConnected.ShouldBeTrue();
connection.HostKey.Host.ShouldBe(fixture.Host);
// The connection owns the bridge. Closing the one is what closes the other, and the relay hears it
// as a close rather than as a socket that stopped answering.
await connection.DisposeAsync();
await bridge.Completion.WaitAsync(Settles, Token);
(await relaying.WaitAsync(Settles, Token)).ShouldBe(WebSocketMessageType.Close);
}
[Fact]
public async Task TheBridge_AcceptsOnce()
{
var (client, _) = await PairAsync();
await using var bridge = RelayBridge.Start(client);
using var first = new TcpClient();
await first.ConnectAsync(bridge.Endpoint, Token);
// A greeting answered means the first connection has been accepted, which is when the listener
// stops — so what the second attempt meets is a closed port, not a race with the backlog.
var stream = first.GetStream();
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
var reply = new byte[2];
await stream.ReadExactlyAsync(reply, Token);
reply.ShouldBe(new byte[] { 5, 0 });
using var second = new TcpClient();
await Should.ThrowAsync(second.ConnectAsync(bridge.Endpoint, Token).AsTask());
}
[Fact]
public async Task ARelayThatClosesWithAReason_FailsTheConnect_AndKeepsTheReason()
{
// What the server does when it cannot reach the host: accept the socket, then close it with 4502.
var (client, server) = await PairAsync();
await server.CloseOutputAsync((WebSocketCloseStatus)4502, "unreachable", Token);
var bridge = RelayBridge.Start(client);
var failure = await Should.ThrowAsync(async () =>
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
.ConnectAsync(Request(bridge.Proxy), progress: null, Token));
// A connection error, not a question about a host key nobody presented.
failure.ShouldNotBeOfType();
bridge.CloseStatus.ShouldBe((WebSocketCloseStatus)4502);
// And the failed connect disposed the bridge it was handed, rather than leaving it listening.
await bridge.Completion.WaitAsync(Settles, Token);
}
[Fact]
public async Task NobodyArriving_ClosesTheRelay()
{
var (client, server) = await PairAsync();
await using var bridge = RelayBridge.Start(client, TimeSpan.FromMilliseconds(200));
await bridge.Completion.WaitAsync(Settles, Token);
var received = await server.ReceiveAsync(new byte[16], Token);
received.MessageType.ShouldBe(WebSocketMessageType.Close);
}
/// Two ends of one WebSocket, over a loopback TCP connection this test owns.
private static async Task<(WebSocket Client, WebSocket Server)> PairAsync()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var dialling = new TcpClient();
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, Token);
var accepted = await listener.AcceptTcpClientAsync(Token);
await connecting;
var client = WebSocket.CreateFromStream(
dialling.GetStream(),
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero });
var server = WebSocket.CreateFromStream(
accepted.GetStream(),
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero });
return (client, server);
}
///
/// The relay's half: bytes from the socket to sshd and back, until one side ends.
///
/// The type of the last message the relay received — a close, when the client said goodbye.
private Task RelayToSshdAsync(WebSocket server) => Task.Run(async () =>
{
using var target = new TcpClient();
await target.ConnectAsync(fixture.Host, fixture.Port, Token);
var stream = target.GetStream();
var down = Task.Run(async () =>
{
var buffer = new byte[32 * 1024];
int read;
try
{
while ((read = await stream.ReadAsync(buffer, Token)) > 0)
{
await server.SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, true, Token);
}
}
catch (Exception exception) when (exception is IOException or WebSocketException or ObjectDisposedException)
{
// The test's connection is over.
}
});
var inbound = new byte[32 * 1024];
while (true)
{
var received = await server.ReceiveAsync(inbound.AsMemory(), Token);
if (received.MessageType == WebSocketMessageType.Close)
{
await server.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, Token);
target.Close();
await down;
return received.MessageType;
}
await stream.WriteAsync(inbound.AsMemory(0, received.Count), Token);
}
});
private SshConnectionRequest Request(SshLoopbackProxy proxy) =>
new(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password),
ConnectTimeout: null,
proxy);
/// A store that already trusts the container's host key, learned directly.
private async Task TrustedStoreAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var unknown = await Should.ThrowAsync(async () =>
await new SshNetConnectionFactory(knownHosts).ConnectAsync(
new SshConnectionRequest(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password)),
progress: null,
Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
return knownHosts;
}
}