Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
16 KiB
C#
399 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 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<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, "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<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 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<SpelunkTunnelException>(
|
|
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<SpelunkTunnelException>(
|
|
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<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 sign-in 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"),
|
|
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
|
port,
|
|
signIn,
|
|
TestContext.Current.CancellationToken);
|
|
}
|