Public Access
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.
A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
658 lines
27 KiB
C#
658 lines
27 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");
|
|
}
|
|
|
|
/// <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 }));
|
|
}
|
|
}
|
|
}
|