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