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; /// /// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH. /// /// /// /// The tunnel is stood in for at , and what it hands back is a real /// 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 SpelunkTunnelTests' job. /// /// /// 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. /// /// 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(); 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(); 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"); } /// A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain. private async Task 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); /// /// 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. /// 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) { } } /// /// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges. /// private sealed class FakeSpelunkTunnel : ISpelunkTunnel { internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = []; internal SpelunkTunnelException? Refusal { get; set; } /// A close Spelunk sends as soon as the tunnel is open, or null to leave it open. internal (int Code, string Reason)? CloseWith { get; set; } public async Task 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 })); } } }