Files
DodoSSH/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs
T
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
2026-09-28 22:53:51 +02:00

404 lines
16 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// The Spelunk tunnel client against Spelunk's contract, over real sockets.
/// </summary>
/// <remarks>
/// <para>
/// Every test here uses the application's own <see cref="SpelunkTunnel"/> — its shared transport and its
/// real WebSocket dialler — against <see cref="FakeSpelunk"/>. 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.
/// </para>
/// <para>
/// The bytes are not SSH here. The bridge is the relay's, and a real SSH connection through it is
/// <c>RelayBridgeTests</c>' job; this suite holds the half that is Spelunk's.
/// </para>
/// </remarks>
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<string>();
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<SpelunkTunnelException>(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<SpelunkTunnelException>(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<SpelunkTunnelException>(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<SpelunkTunnelException>(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<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, 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, signIn: null, 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<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 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<SpelunkTunnelException>(
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<SpelunkTunnelException>(
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");
}
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> 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<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
private static async Task<TcpClient> 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;
}
/// <summary>Arrives at the bridge and reads until it closes the stream, which it does once Spelunk has.</summary>
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();
}
}
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
file static class Opening
{
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
new SpelunkTunnel().OpenAsync(
apiBase,
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
port,
signIn,
TestContext.Current.CancellationToken);
}