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 SignInToken = "spelunkapp_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 {SignInToken}"); 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, "signed this device out")] [InlineData(HttpStatusCode.Forbidden, "not allowed to open a terminal")] [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 ADamagedSignIn_IsRefusedBeforeAnythingIsSent_AsASignedOutDevice() { // It came from Spelunk and was checked then, so a token that is not a header value is a damaged cache, // and the remedy is the one a 401 gets: sign in again. var refusal = await Should.ThrowAsync( new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunkapp_abc\ndef", 22, signIn: null, Token)); refusal.Message.ShouldContain("Sign in to Spelunk again"); refusal.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); spelunk.Requests.ShouldBeEmpty(); } [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, SignInToken, 22, signIn: null, Token)); refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}"); } // ---- Signing in through Spelunk ---- [Fact] public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames() { spelunk.OnTicket = SignedInAs("root"); var signIn = SpelunkSignIn.Create(); await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn); // The one-line OpenSSH public key and nothing else: the port means nothing beside a key. // Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is // the same string to anything that parses it. using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body)) { body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]); var sent = body.RootElement.GetProperty("signIn"); sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]); sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine); } signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA"); // The same key comes back with the route, for the connection made through it, under Spelunk's account. route.SignIn.ShouldBeSameAs(signIn); route.User.ShouldBe("root"); // And the socket is spent exactly as a plain tunnel's is. spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}"); } [Fact] public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot() { await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()); route.User.ShouldBe(SpelunkSignIn.DefaultUser); } [Fact] public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays() { spelunk.OnTicket = SignedInAs("root"); await using var route = await OpenAsync(); route.SignIn.ShouldBeNull(); route.User.ShouldBeNull(); } [Theory] [InlineData("root; rm -rf /")] [InlineData("root\\nsomeone")] [InlineData("")] public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user) { spelunk.OnTicket = SignedInAs(user); var refusal = await Should.ThrowAsync( spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create())); refusal.Message.ShouldContain("does not understand"); spelunk.Requests.ShouldHaveSingleItem(); } [Fact] public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords() { spelunk.OnTicket = _ => new FakeSpelunk.Answer( HttpStatusCode.BadRequest, "application/problem+json", """{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}"""); var refusal = await Should.ThrowAsync( spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create())); refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest); refusal.Message.ShouldContain("would not accept"); refusal.Message.ShouldContain("must be an ssh-ed25519 key"); spelunk.Requests.ShouldHaveSingleItem(); } [Theory] [InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")] [InlineData("Unknown stream kind: signIn", "too old to sign you in")] public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation) { spelunk.OnTunnel = socket => socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None); var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()); await EndOfStreamAsync(route); route.Failure.ShouldNotBeNull().ShouldContain(explanation); } [Fact] public void ASignInKey_IsNewEachTime_AndNeverPrinted() { var first = SpelunkSignIn.Create(); var second = SpelunkSignIn.Create(); first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine); first.PublicKeyLine.Split(' ').Length.ShouldBe(3); first.Credential.Passphrase.ShouldBeNull(); Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----"); first.ToString().ShouldNotContain("AAAA"); first.ToString().ShouldNotContain("PRIVATE"); } /// A ticket answer for a sign-in, naming the account it logs in as. private static Func SignedInAs(string user) => request => FakeSpelunk.Answer.Json( HttpStatusCode.OK, $$""" {"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"} """); 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 sign-in and server the suite uses. file static class Opening { internal static Task OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) => new SpelunkTunnel().OpenAsync( apiBase, Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG", port, signIn, TestContext.Current.CancellationToken); }