Public Access
Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
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.
This commit is contained in:
1 parent
07bd5c9886
commit
38ab9b431b
18 files changed
+1239
-115
No files matched your search
@@ -20,8 +20,9 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, and a link
|
||||
/// never connects anything the user has not saved.
|
||||
/// 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
|
||||
@@ -173,6 +174,131 @@ public sealed partial class ShellFlowTests
|
||||
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]
|
||||
@@ -188,12 +314,15 @@ public sealed partial class ShellFlowTests
|
||||
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();
|
||||
@@ -211,34 +340,138 @@ public sealed partial class ShellFlowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
|
||||
public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
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 ALinkForASavedHost_ConnectsToIt()
|
||||
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.
|
||||
// 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()
|
||||
{
|
||||
@@ -255,6 +488,7 @@ public sealed partial class ShellFlowTests
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -304,6 +538,32 @@ public sealed partial class ShellFlowTests
|
||||
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);
|
||||
|
||||
@@ -338,6 +598,12 @@ public sealed partial class ShellFlowTests
|
||||
{
|
||||
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>
|
||||
@@ -348,9 +614,11 @@ public sealed partial class ShellFlowTests
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Opened.Add((apiBase, serverId, apiKey, port));
|
||||
SignIns.Add(signIn);
|
||||
|
||||
if (Refusal is { } refusal)
|
||||
{
|
||||
@@ -364,7 +632,7 @@ public sealed partial class ShellFlowTests
|
||||
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port);
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User);
|
||||
}
|
||||
|
||||
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -45,9 +45,12 @@ internal static class HostFactory
|
||||
/// <summary>A server's id in Spelunk.</summary>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||
|
||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey };
|
||||
/// <summary>
|
||||
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
|
||||
/// when <paramref name="signIn"/> says so.
|
||||
/// </summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn };
|
||||
|
||||
/// <remarks>
|
||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||
|
||||
@@ -18,7 +18,8 @@ public sealed class HostSecretCodecTests
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the
|
||||
/// binding (a sign-in leaves a stored binding alone rather than refusing it) and
|
||||
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
|
||||
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
|
||||
/// flag has its own tests.
|
||||
@@ -41,7 +42,7 @@ public sealed class HostSecretCodecTests
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
portForwards: [Postgres, Socks],
|
||||
spelunk: ThroughSpelunk());
|
||||
spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ public sealed class SpelunkRouteTests
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||
// version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
// version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
@@ -168,6 +168,108 @@ public sealed class SpelunkRouteTests
|
||||
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn()
|
||||
{
|
||||
// No username, no key, no credential, no typed password: Spelunk names the account and makes the key.
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
host.TryValidate(out var reason).ShouldBeTrue(reason);
|
||||
host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries()
|
||||
{
|
||||
// Left alone rather than refused: it is the host's answer for the day the box is unticked, and a
|
||||
// merge of one machine ticking it with another binding a key must still decode.
|
||||
Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true))
|
||||
.TryValidate(out var reason)
|
||||
.ShouldBeTrue(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn()
|
||||
{
|
||||
Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse();
|
||||
Host().SignsInThroughSpelunk.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt()
|
||||
{
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn()
|
||||
{
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true)));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
document.Host.SignsInThroughSpelunk.ShouldBeFalse();
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout()
|
||||
{
|
||||
// One state, one spelling — the merge would otherwise see a change nobody made.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":false}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickingSignIn_MergesAsPartOfTheOneRoute()
|
||||
{
|
||||
var ancestor = Host(spelunk: ThroughSpelunk());
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) };
|
||||
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
||||
|
||||
// Alone, the tick arrives.
|
||||
HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local);
|
||||
|
||||
// Beside a different edit to the route, the route is still one value: one side wins whole, with the
|
||||
// other reported, rather than a sign-in to a server nobody chose it for.
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
||||
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
||||
}
|
||||
|
||||
// ---- The merge ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -205,7 +205,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
||||
{
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, Token));
|
||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain("does not hold one");
|
||||
spelunk.Requests.ShouldBeEmpty();
|
||||
@@ -215,7 +215,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
public async Task AKeyPastedWithANewline_IsTrimmed()
|
||||
{
|
||||
await using var route = await new SpelunkTunnel()
|
||||
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, Token);
|
||||
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token);
|
||||
|
||||
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
||||
}
|
||||
@@ -230,11 +230,130 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
closed.Stop();
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, Token));
|
||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
var signIn = SpelunkSignIn.Create();
|
||||
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn);
|
||||
|
||||
// The one-line OpenSSH public key and nothing else: the port means nothing beside a key.
|
||||
// Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is
|
||||
// the same string to anything that parses it.
|
||||
using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body))
|
||||
{
|
||||
body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]);
|
||||
var sent = body.RootElement.GetProperty("signIn");
|
||||
sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]);
|
||||
sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine);
|
||||
}
|
||||
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA");
|
||||
|
||||
// The same key comes back with the route, for the connection made through it, under Spelunk's account.
|
||||
route.SignIn.ShouldBeSameAs(signIn);
|
||||
route.User.ShouldBe("root");
|
||||
|
||||
// And the socket is spent exactly as a plain tunnel's is.
|
||||
spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot()
|
||||
{
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
route.User.ShouldBe(SpelunkSignIn.DefaultUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
|
||||
await using var route = await OpenAsync();
|
||||
|
||||
route.SignIn.ShouldBeNull();
|
||||
route.User.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("root; rm -rf /")]
|
||||
[InlineData("root\\nsomeone")]
|
||||
[InlineData("")]
|
||||
public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user)
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs(user);
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.Message.ShouldContain("does not understand");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords()
|
||||
{
|
||||
spelunk.OnTicket = _ => new FakeSpelunk.Answer(
|
||||
HttpStatusCode.BadRequest,
|
||||
"application/problem+json",
|
||||
"""{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}""");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
refusal.Message.ShouldContain("would not accept");
|
||||
refusal.Message.ShouldContain("must be an ssh-ed25519 key");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")]
|
||||
[InlineData("Unknown stream kind: signIn", "too old to sign you in")]
|
||||
public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation)
|
||||
{
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
|
||||
|
||||
var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInKey_IsNewEachTime_AndNeverPrinted()
|
||||
{
|
||||
var first = SpelunkSignIn.Create();
|
||||
var second = SpelunkSignIn.Create();
|
||||
|
||||
first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine);
|
||||
first.PublicKeyLine.Split(' ').Length.ShouldBe(3);
|
||||
first.Credential.Passphrase.ShouldBeNull();
|
||||
Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
|
||||
first.ToString().ShouldNotContain("AAAA");
|
||||
first.ToString().ShouldNotContain("PRIVATE");
|
||||
}
|
||||
|
||||
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
|
||||
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> SignedInAs(string user) => request => FakeSpelunk.Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"}
|
||||
""");
|
||||
|
||||
private Task<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
|
||||
|
||||
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
|
||||
@@ -273,11 +392,12 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
|
||||
file static class Opening
|
||||
{
|
||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port) =>
|
||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
|
||||
new SpelunkTunnel().OpenAsync(
|
||||
apiBase,
|
||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||
port,
|
||||
signIn,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
Reference in new issue
Block a user