using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
namespace DodoSSH.Client.Session.Tests;
///
/// The Spelunk tunnel client against Spelunk's contract, over real sockets.
///
///
///
/// Every test here uses the application's own — its shared transport and its
/// real WebSocket dialler — against . What is asserted is what the contract fixes:
/// the ticket request, the two subprotocols, bytes both ways once the tunnel is up, and a sentence for every
/// way Spelunk says no.
///
///
/// The bytes are not SSH here. The bridge is the relay's, and a real SSH connection through it is
/// RelayBridgeTests' job; this suite holds the half that is Spelunk's.
///
///
public sealed class SpelunkTunnelTests : IAsyncLifetime
{
private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
private readonly FakeSpelunk spelunk = new();
private static CancellationToken Token => TestContext.Current.CancellationToken;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => spelunk.DisposeAsync();
[Fact]
public async Task ATicket_IsAskedForWithTheKey_AndSpentAsTheSecondSubprotocol()
{
await using var route = await OpenAsync();
var ticket = spelunk.Requests[0];
ticket.Method.ShouldBe("POST");
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
ticket.Body.ShouldBe("""{"port":22}""");
var upgrade = spelunk.Requests[1];
upgrade.Path.ShouldBe($"/api/servers/{ServerId}/tunnel");
upgrade.Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
// The key opened the ticket and went nowhere else: the socket carries the ticket alone.
upgrade.Headers.ContainsKey("Authorization").ShouldBeFalse();
upgrade.Path.ShouldNotContain(FakeSpelunk.Ticket);
}
[Fact]
public async Task BytesCross_BothWays_ThroughTheBridge()
{
var received = new TaskCompletionSource();
spelunk.OnTunnel = async socket =>
{
// What the agent's end does: read what the client sent, answer with the server's own bytes.
var buffer = new byte[64];
var read = await socket.ReceiveAsync(buffer, CancellationToken.None);
received.TrySetResult(Encoding.ASCII.GetString(buffer, 0, read.Count));
await socket.SendAsync(
Encoding.ASCII.GetBytes("SSH-2.0-OpenSSH_9.9\r\n"),
WebSocketMessageType.Binary,
endOfMessage: true,
CancellationToken.None);
await FakeSpelunk.DrainAsync(socket);
};
await using var route = await OpenAsync();
using var client = await ThroughSocksAsync(route.Proxy.Port);
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("SSH-2.0-DodoSSH\r\n"), Token);
(await received.Task.WaitAsync(Settles, Token)).ShouldBe("SSH-2.0-DodoSSH\r\n");
var answer = new byte["SSH-2.0-OpenSSH_9.9\r\n".Length];
await stream.ReadExactlyAsync(answer, Token);
Encoding.ASCII.GetString(answer).ShouldBe("SSH-2.0-OpenSSH_9.9\r\n");
}
[Fact]
public async Task ThePortAsked_IsTheHostsPort()
{
await using var route = await spelunk.ApiBase.OpenAsync(port: 2222);
spelunk.Requests[0].Body.ShouldBe("""{"port":2222}""");
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")]
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
public async Task ARefusedTicket_IsExplained_AndNoSocketIsOpened(HttpStatusCode status, string explanation)
{
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(status, "refused");
var refusal = await Should.ThrowAsync(OpenAsync);
refusal.Message.ShouldContain(explanation);
refusal.StatusCode.ShouldBe(status);
spelunk.Requests.ShouldHaveSingleItem();
}
[Fact]
public async Task AKeyWithTooLowARole_IsToldWhatSpelunkSaid()
{
// Spelunk's detail says which role the key has and which it needs, and it is the useful half.
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(
HttpStatusCode.Forbidden,
"Forbidden",
"This API key has the Viewer role; opening a tunnel needs Admin.");
var refusal = await Should.ThrowAsync(OpenAsync);
refusal.Message.ShouldContain("not allowed to open a terminal");
refusal.Message.ShouldContain("needs Admin");
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, "refused the tunnel ticket")]
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
public async Task ARefusedUpgrade_IsExplained(HttpStatusCode status, string explanation)
{
spelunk.OnUpgrade = _ => status;
var refusal = await Should.ThrowAsync(OpenAsync);
refusal.Message.ShouldContain(explanation);
}
[Theory]
[InlineData("//evil.example/api/servers/x/tunnel")]
[InlineData("@evil.example/api/servers/x/tunnel")]
[InlineData("api/servers/x/tunnel")]
[InlineData("/api/servers/x/tunnel?elsewhere=1")]
public async Task ATicketNamingAnythingButAPath_IsRefused_AndNothingIsDialled(string path)
{
// The path is appended to the API's own origin. Anything that would move it to another host — and
// take the ticket with it — is not a path.
spelunk.OnTicket = _ => FakeSpelunk.Answer.Json(
HttpStatusCode.OK,
$$"""{"ticket":"{{FakeSpelunk.Ticket}}","path":"{{path}}","subprotocol":"spelunk.tunnel.v1"}""");
var refusal = await Should.ThrowAsync(OpenAsync);
refusal.Message.ShouldContain("does not understand");
spelunk.Requests.ShouldHaveSingleItem();
}
[Theory]
[InlineData("connection refused", "could not connect to port 22 on the server itself (connection refused)")]
[InlineData("Unknown stream kind: tunnel", "too old to open a terminal")]
public async Task AnAgentThatCouldNotReachThePort_SaysSo(string reason, string explanation)
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
}
[Fact]
public async Task AnAgentThatDisconnected_SaysSo()
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4503, "agent gone", CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain("disconnected from Spelunk");
}
[Fact]
public async Task ANormalClose_IsNotReworded()
{
// 1000 is the SSH server hanging up, and SSH.NET's own account of that is the accurate one.
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldBeNull();
}
[Fact]
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
{
var refusal = await Should.ThrowAsync(
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, Token));
refusal.Message.ShouldContain("does not hold one");
spelunk.Requests.ShouldBeEmpty();
}
[Fact]
public async Task AKeyPastedWithANewline_IsTrimmed()
{
await using var route = await new SpelunkTunnel()
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, Token);
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
}
[Fact]
public async Task AnUnreachableSpelunk_SaysWhere()
{
// A port that was listening a moment ago and is not any more.
var closed = new TcpListener(IPAddress.Loopback, 0);
closed.Start();
var port = ((IPEndPoint)closed.LocalEndpoint).Port;
closed.Stop();
var refusal = await Should.ThrowAsync(
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, Token));
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
}
private Task OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
/// Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.
private static async Task ThroughSocksAsync(int port)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, Token);
var stream = client.GetStream();
var name = Encoding.ASCII.GetBytes("203.0.113.10");
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
await stream.ReadExactlyAsync(new byte[2], Token);
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
await stream.ReadExactlyAsync(new byte[10], Token);
return client;
}
/// Arrives at the bridge and reads until it closes the stream, which it does once Spelunk has.
private static async Task EndOfStreamAsync(SpelunkTunnelRoute route)
{
using var client = await ThroughSocksAsync(route.Proxy.Port);
var stream = client.GetStream();
var rest = new byte[256];
while (await stream.ReadAsync(rest, Token).AsTask().WaitAsync(Settles, Token) > 0)
{
}
await route.DisposeAsync();
}
}
/// Opens a tunnel to the test server with the one key and server the suite uses.
file static class Opening
{
internal static Task OpenAsync(this string apiBase, int port) =>
new SpelunkTunnel().OpenAsync(
apiBase,
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
port,
TestContext.Current.CancellationToken);
}