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, a sign-in /// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects /// straight away but never sends a key to a Spelunk no saved host already sends it to. /// /// 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"); } // ---- Signing in through Spelunk ---- [Fact] public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn() { spelunkTunnels.User = "ops"; var vault = await ReadyToSignInThroughSpelunkAsync(); vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type"); vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 "); // The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host. var request = ssh.Requests.ShouldHaveSingleItem(); request.Username.ShouldBe("ops"); request.Credential.ShouldBeSameAs(signIn.Credential); request.Host.ShouldBe("203.0.113.10"); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); shell.IsTerminalShowing.ShouldBeTrue(); shell.Tabs.ShouldHaveSingleItem(); vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in"); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession() { var vault = await ReadyToSignInThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic; vault.EditorNewForwardBindPort = "1080"; vault.EditorNewForwardTarget = string.Empty; vault.AddEditorForwardCommand.Execute(null); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); var tab = shell.Tabs.ShouldHaveSingleItem(); await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening"); ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080)); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo() { var vault = await ReadyToSignInThroughSpelunkAsync(); shell.Transfers.Attach(vault, knownHosts); shell.Transfers.SelectedHost = shell.Transfers.Hosts[0]; shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse(); await shell.Transfers.ConnectCommand.ExecuteAsync(null); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); var request = ssh.SftpRequests.ShouldHaveSingleItem(); request.Username.ShouldBe("root"); request.Credential.ShouldBeSameAs(signIn.Credential); request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType(); shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10"); await request.Proxy.Lifetime.DisposeAsync(); } [Fact] public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin() { // The sign-in is what lets a host connect with no binding; without it, the old rule stands. var vault = await ReadyToSignInThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorSpelunkSignIn = false; await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.Single(); vault.SelectedHostAsksForAPassword.ShouldBeTrue(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); vault.Status.ShouldNotBeNull().ShouldContain("no username"); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); } [Fact] public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing() { var vault = await ReadyToConnectThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorAsksForLogin.ShouldBeTrue(); vault.EditorSpelunkSignIn = true; vault.EditorAsksForLogin.ShouldBeFalse(); // The choice is still there to see, and to come back to, but it is not saved beside the sign-in. vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password"); await vault.SaveHostCommand.ExecuteAsync(null); var host = vault.Hosts.ShouldHaveSingleItem().Host; host.SignsInThroughSpelunk.ShouldBeTrue(); host.CredentialId.ShouldBeNull(); host.SshKeyId.ShouldBeNull(); host.AsksForPassword.ShouldBeNull(); // Unticking the route altogether gives the login back. vault.EditSelectedHostCommand.Execute(null); vault.EditorSpelunkSignIn.ShouldBeTrue(); vault.EditorThroughSpelunk = false; vault.EditorAsksForLogin.ShouldBeTrue(); } // ---- 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.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk"); vault.EditorAsksForLogin.ShouldBeFalse(); 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(); vault.Status.ShouldNotBeNull().ShouldContain("first server"); 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 TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault() { await UnlockedAsync(); var vault = shell.Vault!; await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.IsEditing.ShouldBeTrue(); vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue(); vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions( chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag), chip => chip.IsWorn.ShouldBeTrue()); // So once it is saved, the next link for that server finds it rather than adding a second one. await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey); vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key"); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.Hosts.ShouldHaveSingleItem(); spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor() { var vault = await ReadyToConnectThroughSpelunkAsync(); var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: null, server: other)); vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk"); // Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the // key that Spelunk already gets, and no login of its own. var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other); added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId); added.Label.ShouldBe("web 01"); added.Host.Hostname.ShouldBe($"spelunk-{other}"); added.Host.Port.ShouldBe(22); added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi); added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue(); added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId); added.Host.SshKeyId.ShouldBeNull(); added.Host.CredentialId.ShouldBeNull(); added.Host.Username.ShouldBeNull(); added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId); // And connected, through Spelunk, signing in. var opened = spelunkTunnels.Opened.ShouldHaveSingleItem(); opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); var request = ssh.Requests.ShouldHaveSingleItem(); request.Username.ShouldBe("root"); request.Credential.ShouldBeSameAs(signIn.Credential); shell.IsTerminalShowing.ShouldBeTrue(); await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded() { var vault = await ReadyToConnectThroughSpelunkAsync(); var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(server: other)); await vault.OpenSpelunkLinkAsync(Link(server: other)); vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1); spelunkTunnels.Opened.Count.ShouldBe(2); // The two SignIns are two keys: one per connection, never reused. spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine .ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine); foreach (var request in ssh.Requests) { await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } } [Fact] public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved() { var vault = await ReadyToConnectThroughSpelunkAsync(); vault.EditSelectedHostCommand.Execute(null); vault.EditorNewTag = VaultViewModel.SpelunkTag; await vault.AddEditorTagCommand.ExecuteAsync(null); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7")); vault.IsEditing.ShouldBeFalse(); vault.Hosts.ShouldHaveSingleItem(); spelunkTunnels.Opened.ShouldHaveSingleItem(); // The saved host, as saved — not the address the link happened to carry, and its own login, since // this one does not sign in through Spelunk. spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull(); var request = ssh.Requests.ShouldHaveSingleItem(); request.Host.ShouldBe("203.0.113.10"); request.Username.ShouldBe("deploy"); await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync(); } [Fact] public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn() { // Untagged, so not one a link made: it is left as it is, and the link gets the host links make. var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.Hosts.Count.ShouldBe(2); vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty(); vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue(); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().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()); vault.EditorSpelunkSignIn.ShouldBeTrue(); } [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 Spelunk key saved on the Security page ---- [Fact] public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway() { await UnlockedAsync(); var vault = shell.Vault!; await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey); // Saved in the one form, in the personal vault, and gone from the form. var saved = vault.SpelunkKeys.ShouldHaveSingleItem(); saved.ApiBase.ShouldBe(SpelunkApi); saved.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId); vault.SpelunkKeyFormKey.ShouldBeEmpty(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); vault.IsEditing.ShouldBeFalse("saving the key for this Spelunk is the say-so the first link used to ask for"); var added = vault.Hosts.ShouldHaveSingleItem(); added.Host.Spelunk.ShouldNotBeNull().ApiKeyCredentialId.ShouldBe(saved.EntityId); added.Host.SignsInThroughSpelunk.ShouldBeTrue(); spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22)); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne() { await UnlockedAsync(); var vault = shell.Vault!; await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key"); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link()); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); vault.SpelunkKeys.ShouldHaveSingleItem().UsedBy.ShouldBe("1 host uses it"); vault.SpelunkKeyFormNote.ShouldContain("Replaced"); await vault.OpenSpelunkLinkAsync(Link()); spelunkTunnels.Opened.Select(opened => opened.ApiKey).ShouldBe(["spelunk_the_old_key", SpelunkApiKey]); await ssh.Requests[1].Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] public async Task ASavedKey_IsNeverSentToAnotherSpelunk() { await UnlockedAsync(); var vault = shell.Vault!; await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); var saved = vault.SpelunkKeys.ShouldHaveSingleItem(); // A host that names the key but another address — the one thing a key kept with its address refuses. vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; vault.EditorThroughSpelunk = true; vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example"; vault.EditorSpelunkServerId = SpelunkServer.ToString(); vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else"); } [Fact] public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "an older key", password: "spelunk_older"); await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); vault.NewHostCommand.Execute(null); vault.EditorThroughSpelunk = true; vault.EditorSpelunkApi = SpelunkApi + "/"; var chosen = vault.EditorSelectedSpelunkKey.ShouldNotBeNull(); chosen.EntityId.ShouldBe(vault.SpelunkKeys.Single().EntityId); chosen.Qualifier.ShouldBe($"Spelunk key · {SpelunkApi}"); // Saved keys come before credentials, which stay on offer for keys kept the older way. vault.EditorSpelunkKeyChoices.Select(choice => choice.Label) .ShouldBe(["Spelunk at api.spelunk.example.com", "an older key"]); } [Fact] public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm() { await UnlockedAsync(); var vault = shell.Vault!; await SaveSpelunkKeyAsync(vault, "http://api.spelunk.example.com", SpelunkApiKey); vault.SpelunkKeys.ShouldBeEmpty(); vault.SpelunkKeyFormNote.ShouldContain("https"); vault.SpelunkKeyFormKey.ShouldBe(SpelunkApiKey, "a refused key stays in the box to be saved once the address is fixed"); } [Fact] public async Task DeletingASavedKey_SaysWhichHostsWillStopConnecting() { await UnlockedAsync(); var vault = shell.Vault!; await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link()); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single()); var request = vault.PendingDeletion.ShouldNotBeNull(); request.Usage.ShouldContain("web 01 reaches Spelunk with it"); await vault.ConfirmDeleteCommand.ExecuteAsync(null); vault.SpelunkKeys.ShouldBeEmpty(); await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts.Single()); vault.Status.ShouldNotBeNull().ShouldContain("not in this keychain any more"); } private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey) { vault.SpelunkKeyFormApi = apiBase; vault.SpelunkKeyFormKey = apiKey; await vault.SaveSpelunkKeyCommand.ExecuteAsync(null); } /// 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; } /// A saved host through Spelunk that Spelunk signs in to: no username, no key, no password. private async Task ReadyToSignInThroughSpelunkAsync() { await UnlockedAsync(); var vault = shell.Vault!; await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey); vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; vault.EditorThroughSpelunk = true; vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = SpelunkApi; vault.EditorSpelunkServerId = SpelunkServer.ToString(); vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key"); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue(); 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; } = []; /// The sign-in key each tunnel was asked for, in order; null for a plain tunnel. internal List SignIns { get; } = []; /// The account a sign-in answer names. internal string User { get; set; } = "root"; 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, SpelunkSignIn? signIn, CancellationToken cancellationToken) { Opened.Add((apiBase, serverId, apiKey, port)); SignIns.Add(signIn); 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, signIn, signIn is null ? null : User); } 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 })); } } }