Public Access
Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
This commit is contained in:
1 parent
c8848ec1e3
commit
6e39807cc0
32 files changed
+3428
-85
No files matched your search
@@ -0,0 +1,389 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tunnel is stood in for at <see cref="ISpelunkTunnel"/>, and what it hands back is a real
|
||||
/// <see cref="SpelunkTunnelRoute"/> over the relay's real bridge and an in-process WebSocket pair — so the
|
||||
/// proxy SSH is given, and the close code a failed connect is explained by, are the real ones. The tunnel's
|
||||
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, and a link
|
||||
/// never connects anything the user has not saved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
{
|
||||
private const string SpelunkApi = "https://api.spelunk.example.com";
|
||||
|
||||
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||
|
||||
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
|
||||
private readonly FakeSpelunkTunnel spelunkTunnels = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
|
||||
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedTunnel_IsExplained_AndNothingIsDialledOrProbed()
|
||||
{
|
||||
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it.");
|
||||
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// No fallback to the address. For a Spelunk host it may be nothing that answers at all.
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
reachability.Probed.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("agent on this server is not connected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATunnelWhoseAgentCouldNotReachThePort_SaysSo_InsteadOfTheTransportError()
|
||||
{
|
||||
// What Spelunk does when the agent's own connect to 127.0.0.1:22 fails: accept, then close with 4502.
|
||||
spelunkTunnels.CloseWith = (4502, "dial tcp 127.0.0.1:22: connection refused");
|
||||
|
||||
ssh.BeforeConnect = request => ThroughSocksToTheEndAsync(request.Proxy.ShouldNotBeNull().Port);
|
||||
ssh.Failure = new InvalidOperationException("An established connection was aborted by the server.");
|
||||
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("could not connect to port 22 on the server itself");
|
||||
vault.Status.ShouldNotContain("aborted");
|
||||
reachability.Probed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostsFileTransfer_GoesThroughTheTunnelToo()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
var request = ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRelayAndSpelunk_AreOneChoice_InTheEditor()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorRelayEnabled = true;
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorRelayEnabled.ShouldBeFalse("ticking Spelunk unticks the relay");
|
||||
|
||||
vault.EditorRelayEnabled = true;
|
||||
vault.EditorThroughSpelunk.ShouldBeFalse("and the other way round");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
|
||||
vault.Hosts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASavedSpelunkHost_OpensInTheEditorWithItsRoute()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn()
|
||||
{
|
||||
// So deleting it is warned about like any other credential a host needs, rather than looking unused.
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||
var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal));
|
||||
|
||||
row.UsedBySummary.ShouldBe("web-01");
|
||||
|
||||
vault.SelectedVaultItem = row;
|
||||
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
|
||||
}
|
||||
|
||||
// ---- A link from Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForAServerWithNoHost_OpensTheEditor_AndConnectsNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorLabel.ShouldBe("web 01");
|
||||
vault.EditorHostname.ShouldBe("203.0.113.10");
|
||||
vault.EditorPort.ShouldBe(22);
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
|
||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
||||
// for it would be offering to send the key wherever a link says.
|
||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkWithNoAddress_NamesTheHostAfterTheServer()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.OpenLink(Link(host: null));
|
||||
|
||||
shell.Vault!.EditorHostname.ShouldBe($"spelunk-{SpelunkServer}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForASavedHost_ConnectsToIt()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
|
||||
// The saved host, as saved — not the address the link happened to carry.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkWhileLocked_WaitsForTheUnlock()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
shell.Vault.ShouldBeNull();
|
||||
shell.StatusMessage.ShouldContain("Unlock to open web 01");
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALink_LeavesAHalfEditedHostAlone()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "something I was typing";
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.EditorLabel.ShouldBe("something I was typing");
|
||||
vault.EditorThroughSpelunk.ShouldBeFalse();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Label == "deploy password");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(row => row.Host.Spelunk is not null);
|
||||
|
||||
// Saved in the one form, whatever was typed — which is what lets a link find it.
|
||||
vault.SelectedHost.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
|
||||
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
|
||||
|
||||
/// <summary>
|
||||
/// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which it
|
||||
/// does once the tunnel has closed, so its reason is recorded by the time this returns.
|
||||
/// </summary>
|
||||
private static async Task ThroughSocksToTheEndAsync(int port)
|
||||
{
|
||||
using 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);
|
||||
|
||||
var rest = new byte[256];
|
||||
while (await stream.ReadAsync(rest, Token) > 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges.
|
||||
/// </summary>
|
||||
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
|
||||
{
|
||||
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
|
||||
|
||||
internal SpelunkTunnelException? Refusal { get; set; }
|
||||
|
||||
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
|
||||
internal (int Code, string Reason)? CloseWith { get; set; }
|
||||
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Opened.Add((apiBase, serverId, apiKey, port));
|
||||
|
||||
if (Refusal is { } refusal)
|
||||
{
|
||||
throw refusal;
|
||||
}
|
||||
|
||||
var (client, far) = await PairAsync(cancellationToken);
|
||||
|
||||
if (CloseWith is { } close)
|
||||
{
|
||||
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port);
|
||||
}
|
||||
|
||||
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var dialling = new TcpClient();
|
||||
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken);
|
||||
var accepted = await listener.AcceptTcpClientAsync(cancellationToken);
|
||||
await connecting;
|
||||
|
||||
return (
|
||||
WebSocket.CreateFromStream(
|
||||
dialling.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero }),
|
||||
WebSocket.CreateFromStream(
|
||||
accepted.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
// Scripted rather than real, so that a failed connection in any test in this file does not go
|
||||
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||
// the wall clock.
|
||||
reachability: reachability);
|
||||
reachability: reachability,
|
||||
|
||||
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
|
||||
// ShellFlowTests.Spelunk.cs.
|
||||
spelunk: spelunkTunnels);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
@@ -198,4 +201,149 @@ public sealed class SingleInstanceTests : IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- A launch that carries a link ----
|
||||
|
||||
/// <remarks>
|
||||
/// Clicking "Open in DodoSSH" in Spelunk while DodoSSH is running is a second launch like any other, and
|
||||
/// the link has to reach the instance that can act on it — along with the window coming forward.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedLaunchWithALink_PassesTheLinkOn()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var cameForward = new TaskCompletionSource();
|
||||
var opened = new TaskCompletionSource<SpelunkLink>();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() => cameForward.TrySetResult(),
|
||||
link => opened.TrySetResult(link));
|
||||
|
||||
var link = Link();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient, link).ShouldBeNull("the profile is taken");
|
||||
|
||||
await cameForward.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
(await opened.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken)).ShouldBe(link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThePlainRing_IsStillThePlainRing()
|
||||
{
|
||||
// An older running build compares the whole line against this, so a launch with no link must go on
|
||||
// saying exactly it.
|
||||
SingleInstance.Message(link: null).ShouldBe("dodossh.come-forward");
|
||||
|
||||
SingleInstance.TryRead("dodossh.come-forward", out var link).ShouldBeTrue();
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALinkMessage_ReadsBackToTheSameLink()
|
||||
{
|
||||
var link = Link();
|
||||
|
||||
SingleInstance.TryRead(SingleInstance.Message(link), out var read).ShouldBeTrue();
|
||||
|
||||
read.ShouldBe(link);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Anything on this machine can write to the pipe, so a message that is not exactly one of the two is
|
||||
/// ignored whole — the window does not even come forward for it.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("dodossh.come-forward ")]
|
||||
[InlineData("DODOSSH.COME-FORWARD")]
|
||||
[InlineData("dodossh.open-link")]
|
||||
[InlineData("dodossh.open-link https://evil.example")]
|
||||
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=http%3A%2F%2Fevil.example&server=0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b")]
|
||||
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=not-a-server")]
|
||||
[InlineData("dodossh.run calc.exe")]
|
||||
public void AnythingElse_IsNotARing(string? said)
|
||||
{
|
||||
SingleInstance.TryRead(said, out var link).ShouldBeFalse();
|
||||
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverlongMessage_IsNotARing()
|
||||
{
|
||||
SingleInstance.TryRead("dodossh.open-link " + new string('a', 64 * 1024), out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGarbledRing_IsIgnored_AndTheDoorbellKeepsAnswering()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var rings = 0;
|
||||
var answered = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() =>
|
||||
{
|
||||
Interlocked.Increment(ref rings);
|
||||
answered.TrySetResult();
|
||||
});
|
||||
|
||||
// Somebody else's program, writing something that is not a ring and going on far past a line. The
|
||||
// doorbell stops reading and hangs up part way, which the writer sees as a broken pipe.
|
||||
using (var pipe = new NamedPipeClientStream(".", PipeNameOf(paths), PipeDirection.Out))
|
||||
{
|
||||
await pipe.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.WriteAsync(
|
||||
Encoding.UTF8.GetBytes(new string('x', 20_000) + "\n"),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Hung up on, which is the point.
|
||||
}
|
||||
}
|
||||
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
|
||||
await answered.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
rings.ShouldBe(1, "the garbage rang nothing, and the real ring after it was still answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWindowsHandler_RunsThisExecutableWithTheLinkAsOneArgument()
|
||||
{
|
||||
Assert.SkipUnless(OperatingSystem.IsWindows(), "The registry handler exists only on Windows.");
|
||||
|
||||
const string executable = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe";
|
||||
const string nightly = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop.Nightly\current\DodoSSH.exe";
|
||||
|
||||
WindowsUrlScheme.CommandFor(executable)
|
||||
.ShouldBe(@"""C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe"" ""%1""");
|
||||
|
||||
// Uninstalling one channel must not take the handler away from the other if it registered since.
|
||||
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(executable), executable).ShouldBeTrue();
|
||||
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(nightly), executable).ShouldBeFalse();
|
||||
WindowsUrlScheme.IsOurs(null, executable).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static SpelunkLink Link() =>
|
||||
new("https://api.spelunk.example.com", Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "web 01", "203.0.113.10");
|
||||
|
||||
/// <summary>The pipe name <c>SingleInstance</c> derives, so a test can write to it as a stranger would.</summary>
|
||||
private static string PipeNameOf(ClientPaths profile)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(profile.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ internal static class HostFactory
|
||||
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
|
||||
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
|
||||
|
||||
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
|
||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
|
||||
|
||||
/// <summary>A server's id in Spelunk.</summary>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
|
||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey };
|
||||
|
||||
/// <remarks>
|
||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
|
||||
@@ -40,7 +50,8 @@ internal static class HostFactory
|
||||
bool? asksForPassword = null,
|
||||
Guid? groupId = null,
|
||||
Guid[]? tags = null,
|
||||
string[]? pinnedPaths = null) =>
|
||||
string[]? pinnedPaths = null,
|
||||
SpelunkRoute? spelunk = null) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
@@ -59,6 +70,7 @@ internal static class HostFactory
|
||||
GroupId = groupId,
|
||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||
Spelunk = spelunk,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -17,9 +17,11 @@ public sealed class HostSecretCodecTests
|
||||
/// <remarks>
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
|
||||
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
|
||||
/// highest schema version a valid host can.
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths and a Spelunk route — which are orthogonal to the binding and are what make this
|
||||
/// host reach the highest schema version a valid host can. The route and the relay are exclusive in the
|
||||
/// same way the bindings are, so this carries the newer of those two as well; the relay's flag has its
|
||||
/// own tests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
@@ -34,11 +36,11 @@ public sealed class HostSecretCodecTests
|
||||
notes: "primary replica",
|
||||
jumps: [Bastion, Relay],
|
||||
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
|
||||
relayEnabled: true,
|
||||
credentialId: credentialId,
|
||||
groupId: Production,
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"]);
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
spelunk: ThroughSpelunk());
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
@@ -49,6 +51,21 @@ public sealed class HostSecretCodecTests
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARelayedHost_RoundTrips_AtTheVersionItsOtherFieldsNeed()
|
||||
{
|
||||
// The relay flag predates schema versions, so it costs none; it is here because the full host above
|
||||
// now takes the Spelunk route, which the relay excludes.
|
||||
var host = Host(relayEnabled: true);
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.Host.RelayEnabled.ShouldBeTrue();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
||||
}
|
||||
|
||||
// ---- The schema version is content-dependent ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A host reached through Spelunk: the route's own rules, how it is stored, and how it merges.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The weight is on the compatibility rules, because a Spelunk host misread by some build is a host dialled
|
||||
/// directly at an address that exists only to name its key — the one misreading that does something the
|
||||
/// user never asked for.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkRouteTests
|
||||
{
|
||||
// ---- The API address ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.spelunk.example.com", "https://api.spelunk.example.com")]
|
||||
[InlineData(" https://API.Spelunk.Example.com/ ", "https://api.spelunk.example.com")]
|
||||
[InlineData("https://api.spelunk.example.com:8443/", "https://api.spelunk.example.com:8443")]
|
||||
[InlineData("https://example.com/spelunk/", "https://example.com/spelunk")]
|
||||
[InlineData("http://localhost:5080", "http://localhost:5080")]
|
||||
[InlineData("http://127.0.0.1:5080/", "http://127.0.0.1:5080")]
|
||||
public void AnApiAddress_IsStoredInOneForm(string typed, string stored)
|
||||
{
|
||||
// One form, so the host a link finds is the host that was saved for it however either was typed.
|
||||
SpelunkRoute.TryNormalizeApiBase(typed, out var normalized, out _).ShouldBeTrue();
|
||||
|
||||
normalized.ShouldBe(stored);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("api.spelunk.example.com")]
|
||||
[InlineData("ftp://api.spelunk.example.com")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("https://user:pass@api.spelunk.example.com")]
|
||||
[InlineData("https://api.spelunk.example.com/?x=1")]
|
||||
[InlineData("https://api.spelunk.example.com/#top")]
|
||||
public void AnAddressThatIsNotASpelunkApi_IsRefused(string typed)
|
||||
{
|
||||
SpelunkRoute.TryNormalizeApiBase(typed, out _, out var reason).ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainHttp_IsRefusedAnywhereButThisMachine()
|
||||
{
|
||||
// The key is a bearer token sent with every tunnel; in clear across a network it is somebody else's.
|
||||
SpelunkRoute.TryNormalizeApiBase("http://api.spelunk.example.com", out _, out var reason)
|
||||
.ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNull().ShouldContain("https");
|
||||
}
|
||||
|
||||
// ---- The host ----
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunk_IsValid()
|
||||
{
|
||||
Host(spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeTrue(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostCannotTakeTheRelayAndSpelunkBoth()
|
||||
{
|
||||
Host(relayEnabled: true, spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNull().ShouldContain("not both");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("server")]
|
||||
[InlineData("key")]
|
||||
[InlineData("api")]
|
||||
public void ARouteMissingAPart_IsRefused(string missing)
|
||||
{
|
||||
var route = missing switch
|
||||
{
|
||||
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
|
||||
"key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty },
|
||||
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
|
||||
};
|
||||
|
||||
Host(spelunk: route).TryValidate(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- The payload ----
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunk_RoundTrips_AtTheVersionThatIntroducedIt()
|
||||
{
|
||||
var host = Host(spelunk: ThroughSpelunk(), credentialId: Guid.CreateVersion7());
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostWithoutARoute_EncodesExactlyAsBefore()
|
||||
{
|
||||
// Not using the feature is not using the feature: no key in the JSON, and no version bump, so every
|
||||
// existing host keeps its bytes and the first sync after the upgrade pushes nothing.
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRoute_IsWrittenLastAndWhole()
|
||||
{
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk()));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"}}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOlderPayload_StillDecodes_AsAHostThatIsDialled()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/log"]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBeNull();
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHalfWrittenRoute_MakesTheHostUnreadable_RatherThanDialled()
|
||||
{
|
||||
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||
// version 8 reads a version 9 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"},"somethingNewer":true}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
|
||||
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
// ---- The merge ----
|
||||
|
||||
[Fact]
|
||||
public void TheRoute_IsRoutedThroughTheMerge()
|
||||
{
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk() };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.ShouldBe(local);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDifferentRoutes_MergeAsOneValue_AndTheDiscardedOneIsReported()
|
||||
{
|
||||
// Whole, not per part: one side's API address with the other side's server id is a route nobody
|
||||
// chose, to a server that may not exist on that Spelunk.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk("https://spelunk.local.example") };
|
||||
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
||||
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's two tunnel endpoints, as far as the wire is concerned: HTTP/1.1 over a real loopback socket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Real sockets rather than a stubbed handler, because what is being tested is the client's own HTTP and
|
||||
/// WebSocket stack talking to something: the header the key goes in, the subprotocols the upgrade offers,
|
||||
/// a refused upgrade's status read back through <c>CollectHttpResponseDetails</c>, and a close code
|
||||
/// arriving through <see cref="Client.Ssh.RelayBridge"/>. A handler stub would skip exactly those.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Hand-rolled rather than Kestrel, so this client suite takes no dependency on the ASP.NET Core framework
|
||||
/// for one fixture. It speaks only what the client sends: one request per connection, answered with
|
||||
/// <c>Connection: close</c>, and the one upgrade.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class FakeSpelunk : IAsyncDisposable
|
||||
{
|
||||
internal const string Ticket = "dGlja2V0LWZvci1hLXR1bm5lbA";
|
||||
|
||||
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly Task accepting;
|
||||
|
||||
internal FakeSpelunk()
|
||||
{
|
||||
listener.Start();
|
||||
accepting = AcceptAsync();
|
||||
}
|
||||
|
||||
/// <summary>The API base a client is given.</summary>
|
||||
internal string ApiBase => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
|
||||
|
||||
/// <summary>Every request that arrived, in order.</summary>
|
||||
internal List<Request> Requests { get; } = [];
|
||||
|
||||
/// <summary>How the ticket endpoint answers. By default, with a ticket.</summary>
|
||||
internal Func<Request, Answer> OnTicket { get; set; } = request => Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"ticket":"{{Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1"}
|
||||
""");
|
||||
|
||||
/// <summary>How the upgrade is answered: null accepts it, anything else refuses it with that status.</summary>
|
||||
internal Func<Request, HttpStatusCode?> OnUpgrade { get; set; } = _ => null;
|
||||
|
||||
/// <summary>What Spelunk does with an accepted tunnel — by default nothing, until the client closes.</summary>
|
||||
internal Func<WebSocket, Task> OnTunnel { get; set; } = DrainAsync;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await stopping.CancelAsync();
|
||||
listener.Stop();
|
||||
|
||||
try
|
||||
{
|
||||
await accepting;
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
// Stopped.
|
||||
}
|
||||
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Reads until the client closes, and answers the close.</summary>
|
||||
internal static async Task DrainAsync(WebSocket socket)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while ((await socket.ReceiveAsync(buffer, CancellationToken.None)).MessageType != WebSocketMessageType.Close)
|
||||
{
|
||||
}
|
||||
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(stopping.Token);
|
||||
_ = ServeAsync(client);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
var request = await ReadRequestAsync(stream);
|
||||
|
||||
lock (Requests)
|
||||
{
|
||||
Requests.Add(request);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("Upgrade", out var upgrade)
|
||||
&& upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await UpgradeAsync(stream, request);
|
||||
return;
|
||||
}
|
||||
|
||||
var answer = OnTicket(request);
|
||||
var body = Encoding.UTF8.GetBytes(answer.Body);
|
||||
|
||||
await WriteAsync(
|
||||
stream,
|
||||
$"HTTP/1.1 {(int)answer.Status} {answer.Status}\r\nContent-Type: {answer.ContentType}\r\n"
|
||||
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n");
|
||||
await stream.WriteAsync(body);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpgradeAsync(NetworkStream stream, Request request)
|
||||
{
|
||||
if (OnUpgrade(request) is { } refusal)
|
||||
{
|
||||
await WriteAsync(
|
||||
stream,
|
||||
$"HTTP/1.1 {(int)refusal} {refusal}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteAsync(
|
||||
stream,
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
+ $"Sec-WebSocket-Accept: {AcceptFor(request.Headers["Sec-WebSocket-Key"])}\r\n"
|
||||
+ "Sec-WebSocket-Protocol: spelunk.tunnel.v1\r\n\r\n");
|
||||
|
||||
using var socket = WebSocket.CreateFromStream(
|
||||
stream,
|
||||
new WebSocketCreationOptions
|
||||
{
|
||||
IsServer = true,
|
||||
SubProtocol = "spelunk.tunnel.v1",
|
||||
KeepAliveInterval = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await OnTunnel(socket);
|
||||
}
|
||||
catch (Exception exception) when (exception is WebSocketException or IOException)
|
||||
{
|
||||
// The client went away; nothing here is under test.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The handshake's accept value, as RFC 6455 section 4.2.2 defines it.</summary>
|
||||
private static string AcceptFor(string key)
|
||||
{
|
||||
#pragma warning disable RS0030, CA5350 // RFC 6455 fixes SHA-1 for this value. It authenticates nothing; it proves the peer read the key.
|
||||
return Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
|
||||
#pragma warning restore RS0030, CA5350
|
||||
}
|
||||
|
||||
private static async Task<Request> ReadRequestAsync(NetworkStream stream)
|
||||
{
|
||||
var head = new List<byte>();
|
||||
var one = new byte[1];
|
||||
|
||||
while (!(head.Count >= 4 && head[^4] == '\r' && head[^3] == '\n' && head[^2] == '\r' && head[^1] == '\n'))
|
||||
{
|
||||
if (await stream.ReadAsync(one) == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
head.Add(one[0]);
|
||||
}
|
||||
|
||||
var lines = Encoding.ASCII.GetString([.. head]).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
var requestLine = lines[0].Split(' ');
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var colon = line.IndexOf(':', StringComparison.Ordinal);
|
||||
headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||
}
|
||||
|
||||
var body = string.Empty;
|
||||
|
||||
if (headers.TryGetValue("Content-Length", out var length) && int.Parse(length, System.Globalization.CultureInfo.InvariantCulture) is > 0 and var count)
|
||||
{
|
||||
var bytes = new byte[count];
|
||||
await stream.ReadExactlyAsync(bytes);
|
||||
body = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
return new Request(requestLine[0], requestLine[1], headers, body);
|
||||
}
|
||||
|
||||
private static Task WriteAsync(NetworkStream stream, string text) =>
|
||||
stream.WriteAsync(Encoding.ASCII.GetBytes(text)).AsTask();
|
||||
|
||||
/// <summary>One request as it arrived.</summary>
|
||||
internal sealed record Request(string Method, string Path, Dictionary<string, string> Headers, string Body);
|
||||
|
||||
/// <summary>What the ticket endpoint says back.</summary>
|
||||
internal sealed record Answer(HttpStatusCode Status, string ContentType, string Body)
|
||||
{
|
||||
internal static Answer Json(HttpStatusCode status, string body) => new(status, "application/json", body);
|
||||
|
||||
internal static Answer Problem(HttpStatusCode status, string title, string? detail = null) => new(
|
||||
status,
|
||||
"application/problem+json",
|
||||
detail is null
|
||||
? $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}}}"""
|
||||
: $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}},"detail":"{{detail}}"}""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reading a <c>dodossh://spelunk/connect</c> link, which anything on the machine can hand the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A link is data to check, never an instruction. The refusals below are the part that matters: each is a
|
||||
/// link that is nearly right, and "nearly right" is the shape a crafted one takes.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkLinkTests
|
||||
{
|
||||
private const string Server = "0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b";
|
||||
|
||||
private const string Link =
|
||||
"dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=" + Server
|
||||
+ "&name=web%2001&host=203.0.113.10";
|
||||
|
||||
[Fact]
|
||||
public void ALinkFromSpelunk_IsRead()
|
||||
{
|
||||
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
|
||||
|
||||
link.ShouldBe(new SpelunkLink("https://api.spelunk.example.com", Guid.Parse(Server), "web 01", "203.0.113.10"));
|
||||
link.Hostname.ShouldBe("203.0.113.10");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALinkWithNoHost_NamesTheHostAfterTheServer()
|
||||
{
|
||||
// The name the host key is pinned under, so it has to be stable and this server's alone.
|
||||
SpelunkLink.TryParse(Link.Replace("&host=203.0.113.10", string.Empty, StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Host.ShouldBeNull();
|
||||
link.Hostname.ShouldBe($"spelunk-{Server}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALink_WritesOutAsItReadsBack()
|
||||
{
|
||||
// What a second launch sends the running instance: the one spelling this build writes.
|
||||
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
|
||||
|
||||
SpelunkLink.TryParse(link.ToString(), out var again).ShouldBeTrue();
|
||||
|
||||
again.ShouldBe(link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANameFormEncoded_AndOneWithControlCharacters_AreCleanedForDisplay()
|
||||
{
|
||||
SpelunkLink.TryParse(Link.Replace("web%2001", "web+01%0A%1B%5B31m", StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Name.ShouldNotContain('\n');
|
||||
link.Name.ShouldNotContain('\u001b');
|
||||
link.Name.ShouldStartWith("web 01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownParameter_IsIgnored()
|
||||
{
|
||||
// So a newer Spelunk can add one without every DodoSSH already installed refusing its links.
|
||||
SpelunkLink.TryParse(Link + "&port=22", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://elsewhere/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/disconnect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://user@spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk:8080/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "#x")]
|
||||
[InlineData("dodossh://spelunk/connect?server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=not-a-guid")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("dodossh://spelunk/connect?api=http%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fu%3Ap%40api.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&api=https%3A%2F%2Fevil.example&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&host=bad%20host")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&name=a b")]
|
||||
[InlineData("")]
|
||||
public void ALinkThatIsNotExactlyThis_IsRefused(string text)
|
||||
{
|
||||
SpelunkLink.TryParse(text, out var link).ShouldBeFalse();
|
||||
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverlongLink_IsRefusedBeforeItIsParsed()
|
||||
{
|
||||
SpelunkLink.TryParse(Link + "&name=" + new string('a', SpelunkLink.MaxLength), out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANameThatRunsOn_IsCut()
|
||||
{
|
||||
SpelunkLink.TryParse(Link.Replace("web%2001", new string('a', 500), StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Name.Length.ShouldBe(SpelunkLink.MaxNameLength);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dodossh://spelunk/connect", true)]
|
||||
[InlineData("DODOSSH://anything", true)]
|
||||
[InlineData("--veloapp-install", false)]
|
||||
[InlineData(null, false)]
|
||||
public void TheLauncher_TellsALinkFromAnotherArgument(string? argument, bool looksLikeOne)
|
||||
{
|
||||
SpelunkLink.LooksLikeOne(argument).ShouldBe(looksLikeOne);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
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, 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<SpelunkTunnelException>(
|
||||
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<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) =>
|
||||
new SpelunkTunnel().OpenAsync(
|
||||
apiBase,
|
||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||
port,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
Reference in new issue
Block a user