Public Access
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
810 lines
33 KiB
C#
810 lines
33 KiB
C#
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, 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.
|
|
/// </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");
|
|
}
|
|
|
|
// ---- 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<RelayBridge>();
|
|
|
|
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<RelayBridge>();
|
|
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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
|
|
private async Task<VaultViewModel> 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);
|
|
|
|
/// <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; } = [];
|
|
|
|
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
|
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
|
|
|
/// <summary>The account a sign-in answer names.</summary>
|
|
internal string User { get; set; } = "root";
|
|
|
|
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,
|
|
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 }));
|
|
}
|
|
}
|
|
}
|