Public Access
Merge claude/spelunk-sign-in: sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
This commit is contained in:
commit
ee423b25ce
18 files changed
+1239
-115
No files matched your search
@@ -471,12 +471,33 @@ this way; the relay and Spelunk are one choice, and ticking one unticks the othe
|
|||||||
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
||||||
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
||||||
|
|
||||||
|
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
|
||||||
|
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
|
||||||
|
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
|
||||||
|
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
||||||
|
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
|
||||||
|
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
|
||||||
|
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
|
||||||
|
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
|
||||||
|
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
|
||||||
|
so a DodoSSH older than this shows it read-only.
|
||||||
|
|
||||||
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
|
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
|
||||||
link, or hands the link to the copy already running. The link never carries a credential. If a saved host
|
link, or hands the link to the copy already running, and **connects straight away.** The link never
|
||||||
already reaches that server through that Spelunk, DodoSSH connects to it; otherwise it opens the host editor
|
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
||||||
filled in for a new Spelunk host — name, address, API and server — and you choose how to log in and which
|
|
||||||
credential holds the key, and save. Nothing is ever connected that you have not saved, and a link that
|
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
|
||||||
arrives while the vault is locked waits for the unlock.
|
- Otherwise, when a saved host already sends an API key to that same Spelunk, DodoSSH saves a new host in
|
||||||
|
your personal vault — named as the link names it, at the link's address (or `spelunk-<server id>`), port
|
||||||
|
22, signing in through Spelunk with that same key credential, tagged `spelunk` (the tag is made if the
|
||||||
|
vault has none) — and connects to it. It syncs like any host you saved yourself.
|
||||||
|
- **The first link from a Spelunk** opens the host editor instead, filled in the same way with sign-in
|
||||||
|
ticked and the tag on, and you choose the credential holding that Spelunk's API key once and save. Every
|
||||||
|
later link from that Spelunk connects straight away. A link can be sent by anything on this machine, so
|
||||||
|
an API key is never sent to an address that no saved host already sends it to.
|
||||||
|
|
||||||
|
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
|
||||||
|
its own. A link that arrives while the vault is locked waits for the unlock.
|
||||||
|
|
||||||
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
||||||
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
||||||
|
|||||||
@@ -1002,7 +1002,8 @@
|
|||||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||||
@@ -1016,6 +1017,7 @@
|
|||||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" MinHeight="44">
|
HorizontalAlignment="Stretch" MinHeight="44">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -1028,6 +1030,9 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
|
||||||
|
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||||
@@ -1237,9 +1242,15 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||||
|
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
|
Text="Sign in through Spelunk (no SSH key needed)"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Connects only while Spelunk and its agent are reachable." />
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||||
|
|||||||
@@ -578,7 +578,8 @@
|
|||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,78">
|
<Grid ColumnDefinitions="*,8,78">
|
||||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||||
@@ -599,6 +600,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" Height="40">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -612,6 +614,15 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
|
||||||
|
than hidden, so a login somebody chose is still there to see and comes back if the box in
|
||||||
|
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !EditorAsksForLogin}"
|
||||||
|
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||||
@@ -726,6 +737,15 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<!--
|
||||||
|
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
||||||
|
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||||
|
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
|
||||||
|
-->
|
||||||
|
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
|
||||||
|
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!--
|
<!--
|
||||||
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
||||||
@@ -733,7 +753,7 @@
|
|||||||
only while Spelunk and its agent are.
|
only while Spelunk and its agent are.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11.5"
|
<TextBlock Classes="hint" FontSize="11.5"
|
||||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,10 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public SpelunkRoute? Spelunk { get; init; }
|
public SpelunkRoute? Spelunk { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether Spelunk signs the user in to this host, so it needs no login of its own.</summary>
|
||||||
|
/// <remarks>See <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</remarks>
|
||||||
|
public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the fields that must hold before this can be stored.
|
/// Checks the fields that must hold before this can be stored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -135,12 +135,28 @@ public static class HostSecretCodec
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int SpelunkSchemaVersion = 9;
|
public const int SpelunkSchemaVersion = 9;
|
||||||
|
|
||||||
|
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
|
||||||
|
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
|
||||||
|
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
|
||||||
|
/// and the host would start asking for a password it never had. Written at this version, that build
|
||||||
|
/// shows the host, refuses to edit it, and says to update.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
||||||
|
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public const int SpelunkSignInSchemaVersion = 10;
|
||||||
|
|
||||||
/// <summary>The highest schema version this build can write.</summary>
|
/// <summary>The highest schema version this build can write.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||||
/// maximum. A new field added below this line has to be named here too.
|
/// maximum. A new field added below this line has to be named here too.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int CurrentSchemaVersion = SpelunkSchemaVersion;
|
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||||
|
|
||||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||||
@@ -193,14 +209,7 @@ public static class HostSecretCodec
|
|||||||
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
||||||
// Last of all, and absent for a host that is dialled, so every host without a route encodes
|
// Last of all, and absent for a host that is dialled, so every host without a route encodes
|
||||||
// exactly as it did before routes existed.
|
// exactly as it did before routes existed.
|
||||||
Spelunk = host.Spelunk is { } route
|
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
|
||||||
? new SpelunkPayloadDocument
|
|
||||||
{
|
|
||||||
Api = route.ApiBase,
|
|
||||||
ServerId = route.ServerId,
|
|
||||||
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return JsonSerializer.SerializeToUtf8Bytes(
|
return JsonSerializer.SerializeToUtf8Bytes(
|
||||||
@@ -286,6 +295,11 @@ public static class HostSecretCodec
|
|||||||
version = Math.Max(version, SpelunkSchemaVersion);
|
version = Math.Max(version, SpelunkSchemaVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (host.SignsInThroughSpelunk)
|
||||||
|
{
|
||||||
|
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +451,9 @@ public static class HostSecretCodec
|
|||||||
ApiBase = parsed.Api ?? string.Empty,
|
ApiBase = parsed.Api ?? string.Empty,
|
||||||
ServerId = parsed.ServerId ?? Guid.Empty,
|
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||||
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
||||||
|
|
||||||
|
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
||||||
|
SignInThroughSpelunk = parsed.SignIn is true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +545,23 @@ internal sealed class SpelunkPayloadDocument
|
|||||||
public Guid? ServerId { get; set; }
|
public Guid? ServerId { get; set; }
|
||||||
|
|
||||||
public Guid? ApiKeyCredentialId { get; set; }
|
public Guid? ApiKeyCredentialId { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
|
||||||
|
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public bool? SignIn { get; set; }
|
||||||
|
|
||||||
|
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
||||||
|
{
|
||||||
|
Api = route.ApiBase,
|
||||||
|
ServerId = route.ServerId,
|
||||||
|
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||||
|
|
||||||
|
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
|
||||||
|
// field existed — the rule AsksForPassword follows.
|
||||||
|
SignIn = route.SignInThroughSpelunk ? true : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ public sealed record SpelunkRoute
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public required Guid ApiKeyCredentialId { get; init; }
|
public required Guid ApiKeyCredentialId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
||||||
|
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
|
||||||
|
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
|
||||||
|
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
|
||||||
|
/// and the key is gone when the connection is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What the host says about logging in is then not asked.</b> Its username, key, credential and
|
||||||
|
/// typed-password choice — its own or a group's — are left alone rather than refused, because they are
|
||||||
|
/// the host's answer for the day this is unticked, and refusing a host that carries them would make a
|
||||||
|
/// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path
|
||||||
|
/// never reads one for a host that has it. See <c>VaultViewModel.TryBuildAuthentication</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Written from <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>, and only when true, so a route
|
||||||
|
/// without it encodes exactly as it did before it existed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool SignInThroughSpelunk { get; init; }
|
||||||
|
|
||||||
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
|
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
|
||||||
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
||||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
||||||
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
||||||
/// and the most it can ever do is select a host the user already saved, or open the editor on a new one
|
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
|
||||||
/// that the user then has to finish and save. It never connects to anything the user has not saved.
|
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
|
||||||
|
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
|
||||||
|
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The throwaway key one connection signs in to a Spelunk server with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
|
||||||
|
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
|
||||||
|
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
|
||||||
|
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
|
||||||
|
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
|
||||||
|
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkSignIn
|
||||||
|
{
|
||||||
|
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
|
||||||
|
public const string DefaultUser = "root";
|
||||||
|
|
||||||
|
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
|
||||||
|
internal const string KeyComment = "dodossh-spelunk-sign-in";
|
||||||
|
|
||||||
|
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
|
||||||
|
{
|
||||||
|
PublicKeyLine = publicKeyLine;
|
||||||
|
Credential = credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
|
||||||
|
public string PublicKeyLine { get; }
|
||||||
|
|
||||||
|
/// <summary>The private half, in the form a connection authenticates with.</summary>
|
||||||
|
public SshPrivateKeyCredential Credential { get; }
|
||||||
|
|
||||||
|
/// <summary>Makes a fresh key pair for one connection.</summary>
|
||||||
|
public static SpelunkSignIn Create()
|
||||||
|
{
|
||||||
|
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
|
||||||
|
|
||||||
|
return new SpelunkSignIn(
|
||||||
|
key.PublicKeyLine.Trim(),
|
||||||
|
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
|
||||||
|
public override string ToString() => "Spelunk sign-in key";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
|
||||||
|
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
|
||||||
|
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
|
||||||
|
/// with the username, as the connection is made through it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
|
||||||
|
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
|
||||||
|
/// sending something else in its place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SpelunkSignInPending : SshCredential
|
||||||
|
{
|
||||||
|
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
|
||||||
|
public static SpelunkSignInPending Instance { get; } = new();
|
||||||
|
|
||||||
|
private SpelunkSignInPending()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,10 @@ public static class SpelunkTunnelProtocol
|
|||||||
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
||||||
public const int AgentDisconnectedCloseCode = 4503;
|
public const int AgentDisconnectedCloseCode = 4503;
|
||||||
|
|
||||||
/// <summary>What an agent that predates tunnels says, inside a <see cref="TargetUnreachableCloseCode"/>.</summary>
|
/// <summary>
|
||||||
|
/// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a
|
||||||
|
/// <see cref="TargetUnreachableCloseCode"/>.
|
||||||
|
/// </summary>
|
||||||
internal const string UnknownStreamKind = "Unknown stream kind";
|
internal const string UnknownStreamKind = "Unknown stream kind";
|
||||||
|
|
||||||
/// <summary>Where a ticket for one server is asked for.</summary>
|
/// <summary>Where a ticket for one server is asked for.</summary>
|
||||||
@@ -67,7 +70,15 @@ public interface ISpelunkTunnel
|
|||||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||||
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||||
/// <param name="port">The port the agent is to connect to on the server's own loopback.</param>
|
/// <param name="port">
|
||||||
|
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
|
||||||
|
/// agent starts an SSH server of its own and Spelunk ignores it.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="signIn">
|
||||||
|
/// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to <paramref name="port"/>.
|
||||||
|
/// Its public half goes with the ticket request; the route hands the whole of it back, with the account
|
||||||
|
/// Spelunk named, for the connection made through it.
|
||||||
|
/// </param>
|
||||||
/// <param name="cancellationToken">Cancellation.</param>
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||||
@@ -81,6 +92,7 @@ public interface ISpelunkTunnel
|
|||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
int port,
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,32 +111,59 @@ public sealed class SpelunkTunnelRoute : IConnectionRoute
|
|||||||
private readonly RelayBridge bridge;
|
private readonly RelayBridge bridge;
|
||||||
private readonly int port;
|
private readonly int port;
|
||||||
|
|
||||||
internal SpelunkTunnelRoute(RelayBridge bridge, int port)
|
internal SpelunkTunnelRoute(RelayBridge bridge, int port, SpelunkSignIn? signIn = null, string? user = null)
|
||||||
{
|
{
|
||||||
this.bridge = bridge;
|
this.bridge = bridge;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
SignIn = signIn;
|
||||||
|
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public SshLoopbackProxy Proxy => bridge.Proxy;
|
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||||
|
|
||||||
|
/// <summary>The key Spelunk was asked to sign in with, or null for a plain tunnel.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Handed back rather than kept by whoever asked, so the connection made through this route takes its
|
||||||
|
/// credential from the same place it takes its proxy, and cannot be given one without the other.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkSignIn? SignIn { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The account Spelunk signs in as — its answer's <c>user</c>, or <see cref="SpelunkSignIn.DefaultUser"/>
|
||||||
|
/// when it named none — or null for a plain tunnel.
|
||||||
|
/// </summary>
|
||||||
|
public string? User { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
|
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
|
||||||
/// account of that is the accurate one.
|
/// account of that is the accurate one.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? Failure =>
|
public string? Failure =>
|
||||||
bridge.CloseStatus is { } status ? Describe((int)status, bridge.CloseDescription, port) : null;
|
bridge.CloseStatus is { } status
|
||||||
|
? Describe((int)status, bridge.CloseDescription, port, signingIn: SignIn is not null)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||||
|
|
||||||
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
|
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
|
||||||
internal static string? Describe(int code, string? reason, int port) => code switch
|
internal static string? Describe(int code, string? reason, int port, bool signingIn = false) => code switch
|
||||||
{
|
{
|
||||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
||||||
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
||||||
"The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
signingIn
|
||||||
|
? "The Spelunk agent on this server is too old to sign you in. Update the agent, then try again."
|
||||||
|
: "The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
||||||
|
|
||||||
|
// Signing in, the agent connects to nothing: it starts an SSH server of its own, and what it could not
|
||||||
|
// do is that — most often because the server has no OpenSSH server to start, which Spelunk's reason
|
||||||
|
// says in its own words.
|
||||||
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode when signingIn =>
|
||||||
|
"The Spelunk agent could not sign you in on this server"
|
||||||
|
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||||
|
+ ". Signing in through Spelunk needs OpenSSH's server (sshd) installed there.",
|
||||||
|
|
||||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
|
||||||
string.Create(
|
string.Create(
|
||||||
@@ -223,6 +262,9 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
|
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
|
||||||
private const int MaxTicketLength = 2048;
|
private const int MaxTicketLength = 2048;
|
||||||
|
|
||||||
|
/// <summary>The longest account name taken from Spelunk's answer — well past what a Unix system allows.</summary>
|
||||||
|
private const int MaxUserLength = 64;
|
||||||
|
|
||||||
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||||
private const int MaxProblemBytes = 16 * 1024;
|
private const int MaxProblemBytes = 16 * 1024;
|
||||||
|
|
||||||
@@ -263,6 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
int port,
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||||
@@ -270,7 +313,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
throw new SpelunkTunnelException(reason);
|
throw new SpelunkTunnelException(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, cancellationToken)
|
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var socket = await connect(
|
var socket = await connect(
|
||||||
@@ -279,7 +322,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port);
|
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -320,6 +363,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
int port,
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(
|
using var request = new HttpRequestMessage(
|
||||||
@@ -330,8 +374,16 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
|
|
||||||
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
||||||
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||||
|
//
|
||||||
|
// Signing in sends the public key and no port: the agent starts an SSH server of its own rather than
|
||||||
|
// connecting to one, and Spelunk ignores the port beside a key anyway.
|
||||||
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||||
new SpelunkTicketRequestDocument { Port = port },
|
signIn is null
|
||||||
|
? new SpelunkTicketRequestDocument { Port = port }
|
||||||
|
: new SpelunkTicketRequestDocument
|
||||||
|
{
|
||||||
|
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
|
||||||
|
},
|
||||||
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
||||||
{
|
{
|
||||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||||
@@ -365,13 +417,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
|
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ReadTicketAsync(response, cancellationToken).ConfigureAwait(false);
|
return await ReadTicketAsync(response, signingIn: signIn is not null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
|
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
|
||||||
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
|
bool signingIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
SpelunkTicketDocument? ticket;
|
SpelunkTicketDocument? ticket;
|
||||||
@@ -397,7 +451,8 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
|| path.StartsWith("//", StringComparison.Ordinal)
|
|| path.StartsWith("//", StringComparison.Ordinal)
|
||||||
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
||||||
|| (ticket.Subprotocol is { } named
|
|| (ticket.Subprotocol is { } named
|
||||||
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal)))
|
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||||
|
|| (signingIn && ticket.User is { } user && !IsAccountName(user)))
|
||||||
{
|
{
|
||||||
throw new SpelunkTunnelException(Unintelligible);
|
throw new SpelunkTunnelException(Unintelligible);
|
||||||
}
|
}
|
||||||
@@ -412,6 +467,18 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
private static bool IsBase64UrlCharacter(char character) =>
|
private static bool IsBase64UrlCharacter(char character) =>
|
||||||
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
|
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the account Spelunk named is one an SSH login could be for: a short run of the characters
|
||||||
|
/// Unix account names are made of.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked because it goes to the server as the login and onto the tab and the status line, and an
|
||||||
|
/// answer carrying anything else is not one this client understands.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IsAccountName(string user) =>
|
||||||
|
user.Length is > 0 and <= MaxUserLength
|
||||||
|
&& user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
|
||||||
|
|
||||||
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
|
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
|
||||||
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
||||||
{
|
{
|
||||||
@@ -419,6 +486,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
|
|
||||||
return status switch
|
return status switch
|
||||||
{
|
{
|
||||||
|
// Spelunk's validation says which field and why; for a sign-in it is the public key.
|
||||||
|
HttpStatusCode.BadRequest =>
|
||||||
|
"Spelunk would not accept the request to open the tunnel"
|
||||||
|
+ ((SpelunkTunnelRoute.Readable(PublicKeyError(problem))
|
||||||
|
?? detail
|
||||||
|
?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said
|
||||||
|
? $": {said}"
|
||||||
|
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
||||||
|
|
||||||
HttpStatusCode.Unauthorized =>
|
HttpStatusCode.Unauthorized =>
|
||||||
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
||||||
+ "Spelunk and paste it into the credential this host uses for it.",
|
+ "Spelunk and paste it into the credential this host uses for it.",
|
||||||
@@ -443,6 +519,17 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What a validation problem says about the sign-in key, if it says anything.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched on the end of the name and without regard to case, because a validator may name the field
|
||||||
|
/// <c>publicKey</c>, <c>PublicKey</c> or <c>signIn.publicKey</c>, and all three mean this one.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? PublicKeyError(SpelunkProblemDocument? problem) =>
|
||||||
|
problem?.Errors?
|
||||||
|
.FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Value?
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
|
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
|
||||||
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
@@ -520,9 +607,21 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The body of a ticket request.</summary>
|
/// <summary>The body of a ticket request.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Either a port or a sign-in, never both: the one not asked for is null and left out of the JSON, so a plain
|
||||||
|
/// tunnel still asks for exactly <c>{"port":22}</c>.
|
||||||
|
/// </remarks>
|
||||||
internal sealed class SpelunkTicketRequestDocument
|
internal sealed class SpelunkTicketRequestDocument
|
||||||
{
|
{
|
||||||
public int Port { get; set; }
|
public int? Port { get; set; }
|
||||||
|
|
||||||
|
public SpelunkSignInRequestDocument? SignIn { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept.</summary>
|
||||||
|
internal sealed class SpelunkSignInRequestDocument
|
||||||
|
{
|
||||||
|
public string? PublicKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||||
@@ -535,19 +634,26 @@ internal sealed class SpelunkTicketDocument
|
|||||||
public string? Path { get; set; }
|
public string? Path { get; set; }
|
||||||
|
|
||||||
public string? Subprotocol { get; set; }
|
public string? Subprotocol { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The account a sign-in logs in as; absent or null for a plain tunnel.</summary>
|
||||||
|
public string? User { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The two fields of an RFC 7807 problem this client repeats.</summary>
|
/// <summary>The fields of an RFC 7807 problem this client repeats.</summary>
|
||||||
internal sealed class SpelunkProblemDocument
|
internal sealed class SpelunkProblemDocument
|
||||||
{
|
{
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
public string? Detail { get; set; }
|
public string? Detail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A validation problem's messages by field, as ASP.NET Core writes them.</summary>
|
||||||
|
public Dictionary<string, string[]>? Errors { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(
|
[JsonSourceGenerationOptions(
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
||||||
[JsonSerializable(typeof(SpelunkTicketDocument))]
|
[JsonSerializable(typeof(SpelunkTicketDocument))]
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel
|
|||||||
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
||||||
/// passed down the doorbell, or one the platform delivered to the running application. The link has
|
/// passed down the doorbell, or one the platform delivered to the running application. The link has
|
||||||
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
||||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never connects to a host the user has not
|
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||||
/// saved.
|
/// host already sends it to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||||
|
|||||||
@@ -1158,7 +1158,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
internal bool SelectedHostAsksForAPassword =>
|
internal bool SelectedHostAsksForAPassword =>
|
||||||
ShowsHostPicker
|
ShowsHostPicker
|
||||||
&& (SelectedHost is null
|
&& (SelectedHost is null
|
||||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||||
|
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
|
||||||
|
|
||||||
// ---- The remote pane ----
|
// ---- The remote pane ----
|
||||||
|
|
||||||
@@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await CloseSessionAsync().ConfigureAwait(true);
|
await CloseSessionAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
||||||
is not { } opened)
|
is not ({ } opened, { } dialled))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session = opened;
|
session = opened;
|
||||||
TypedPassword = string.Empty;
|
TypedPassword = string.Empty;
|
||||||
MarkHostConnected(opened, request, row, identityLabel);
|
MarkHostConnected(opened, dialled, row, identityLabel);
|
||||||
|
|
||||||
reconnectTo = (RemoteKind.Host, row.EntityId);
|
reconnectTo = (RemoteKind.Host, row.EntityId);
|
||||||
|
|
||||||
@@ -1818,8 +1819,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
/// <returns>
|
||||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
|
||||||
|
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
|
||||||
|
/// there is none.
|
||||||
|
/// </returns>
|
||||||
|
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
|
||||||
VaultViewModel open,
|
VaultViewModel open,
|
||||||
HostRowViewModel row,
|
HostRowViewModel row,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
@@ -1833,9 +1838,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
// The session owns the route from here, on success and on failure alike.
|
// The session owns the route from here, on success and on failure alike.
|
||||||
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
return await sftp
|
var through = VaultViewModel.Through(request, route);
|
||||||
.OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
when (route?.Failure is { } failure
|
when (route?.Failure is { } failure
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DodoSSH.Client.Domain;
|
using DodoSSH.Client.Domain;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
@@ -16,6 +17,15 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class VaultViewModel
|
internal sealed partial class VaultViewModel
|
||||||
{
|
{
|
||||||
|
/// <summary>The tag every host made from a Spelunk link wears, and what a link looks for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched without regard to case, as the editor matches every tag name — see <c>AddEditorTagAsync</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal const string SpelunkTag = "spelunk";
|
||||||
|
|
||||||
|
/// <summary>What the status bar and the log name as the identity of a Spelunk sign-in.</summary>
|
||||||
|
private const string SpelunkSignInIdentity = "Spelunk sign-in";
|
||||||
|
|
||||||
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
|
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
|
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
|
||||||
@@ -32,8 +42,18 @@ internal sealed partial class VaultViewModel
|
|||||||
/// fact what these two boxes can simply not express.
|
/// fact what these two boxes can simply not express.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||||
private bool editorThroughSpelunk;
|
private bool editorThroughSpelunk;
|
||||||
|
|
||||||
|
/// <summary>Whether Spelunk signs the user in to the host being edited, so it needs no login of its own.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Inside the Spelunk card, and meaningful only while that is ticked. See
|
||||||
|
/// <see cref="SpelunkRoute.SignInThroughSpelunk"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||||
|
private bool editorSpelunkSignIn;
|
||||||
|
|
||||||
/// <summary>The Spelunk API address in the editor, as typed.</summary>
|
/// <summary>The Spelunk API address in the editor, as typed.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string editorSpelunkApi = string.Empty;
|
private string editorSpelunkApi = string.Empty;
|
||||||
@@ -54,6 +74,16 @@ internal sealed partial class VaultViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private AuthenticationChoice? editorSelectedSpelunkKey;
|
private AuthenticationChoice? editorSelectedSpelunkKey;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a
|
||||||
|
/// login somebody had chosen is still there to see — and comes back as it was if the box is unticked
|
||||||
|
/// before saving.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn);
|
||||||
|
|
||||||
/// <inheritdoc cref="DefaultSpelunk" />
|
/// <inheritdoc cref="DefaultSpelunk" />
|
||||||
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
|
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
|
||||||
|
|
||||||
@@ -87,6 +117,10 @@ internal sealed partial class VaultViewModel
|
|||||||
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
|
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
|
||||||
/// because a Spelunk host's address may be nothing that answers at all.
|
/// because a Spelunk host's address may be nothing that answers at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
|
||||||
|
/// key and account replace the ones the host resolved to.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -102,6 +136,39 @@ internal sealed partial class VaultViewModel
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with
|
||||||
|
/// the key the tunnel was opened with and the account Spelunk named.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both transports make their request through this, so a sign-in cannot reach one of them with the
|
||||||
|
/// stand-in credential <see cref="TryBuildAuthentication"/> gives it — which the SSH layer would refuse
|
||||||
|
/// before dialling anyway. See <see cref="SpelunkSignInPending"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's
|
||||||
|
/// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are
|
||||||
|
/// checked against the same pin.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return route switch
|
||||||
|
{
|
||||||
|
null => request,
|
||||||
|
SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with
|
||||||
|
{
|
||||||
|
Proxy = route.Proxy,
|
||||||
|
Username = user,
|
||||||
|
Credential = signIn.Credential,
|
||||||
|
},
|
||||||
|
_ => request with { Proxy = route.Proxy },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
|
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -115,6 +182,12 @@ internal sealed partial class VaultViewModel
|
|||||||
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||||
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
|
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A host Spelunk signs in to gets a new key pair here, every time.</b> It is made in memory for this
|
||||||
|
/// one tunnel, its public half goes with the ticket request, and the route carries it to the connection —
|
||||||
|
/// see <see cref="SpelunkSignIn"/>. A retry after a host key question, or a reconnect, comes back through
|
||||||
|
/// here and makes another.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
|
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
|
||||||
HostRowViewModel row,
|
HostRowViewModel row,
|
||||||
@@ -129,7 +202,13 @@ internal sealed partial class VaultViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await Spelunk
|
return await Spelunk
|
||||||
.OpenAsync(route.ApiBase, route.ServerId, key.Credential.Password, row.Resolved.Port.Value, cancellationToken)
|
.OpenAsync(
|
||||||
|
route.ApiBase,
|
||||||
|
route.ServerId,
|
||||||
|
key.Credential.Password,
|
||||||
|
row.Resolved.Port.Value,
|
||||||
|
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||||
|
cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,66 +216,243 @@ internal sealed partial class VaultViewModel
|
|||||||
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a
|
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
|
||||||
/// completed task otherwise.
|
|
||||||
/// </returns>
|
/// </returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>A saved host for that server on that Spelunk is connected to</b>, exactly as a tap on its row
|
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
|
||||||
/// would — a password prompt if it asks for one, a host key question if it has not been seen.
|
/// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for:
|
||||||
|
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Otherwise the editor opens on a new host, and nothing more.</b> The link came from outside, and
|
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
||||||
/// anything on this machine can make one; it must never be able to make this application connect
|
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||||
/// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is
|
/// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is
|
||||||
/// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and
|
/// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already
|
||||||
/// the user chooses how to log in and which credential holds the key, and presses SAVE.
|
/// goes, and the server is one that key's own organisation holds or Spelunk refuses it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The key is pre-chosen only when another host already sends it to the same Spelunk.</b> That is a
|
/// <b>The first link from a Spelunk opens the editor instead, and nothing more.</b> Anything on this
|
||||||
/// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a
|
/// machine can make a link, so it must never be able to send an API key to an address no saved host
|
||||||
/// Spelunk no host uses would be offering to send it to whatever address the link named.
|
/// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
||||||
|
/// vault — and the user chooses the credential holding the key and saves; every later link from that
|
||||||
|
/// Spelunk connects straight away.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal Task OpenSpelunkLinkAsync(SpelunkLink link)
|
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(link);
|
ArgumentNullException.ThrowIfNull(link);
|
||||||
|
|
||||||
if (Hosts.FirstOrDefault(row => row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true) is { } saved)
|
var personal = PersonalVaultId();
|
||||||
|
|
||||||
|
if (personal is { } vaultId
|
||||||
|
&& Hosts.FirstOrDefault(row =>
|
||||||
|
row.VaultId == vaultId
|
||||||
|
&& row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true
|
||||||
|
&& WearsSpelunkTag(row.Host)) is { } saved)
|
||||||
{
|
{
|
||||||
return ConnectToRowCommand.ExecuteAsync(saved);
|
await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
|
||||||
|
|
||||||
|
if (personal is { } into && knownKey is { } key)
|
||||||
|
{
|
||||||
|
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||||
|
private Guid? PersonalVaultId() =>
|
||||||
|
session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId;
|
||||||
|
|
||||||
|
/// <summary>Whether a host wears a tag called <see cref="SpelunkTag"/>, from whichever vault.</summary>
|
||||||
|
private bool WearsSpelunkTag(HostSecret host) =>
|
||||||
|
host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag));
|
||||||
|
|
||||||
|
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||||
|
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The credential a saved host already sends to this Spelunk as its API key, or null when none does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in;
|
||||||
|
/// and only a credential still in the keychain, because a dangling one would make a host that cannot
|
||||||
|
/// connect.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
|
||||||
|
Hosts
|
||||||
|
.OrderByDescending(row => row.VaultId == personal)
|
||||||
|
.Select(row => row.Host.Spelunk)
|
||||||
|
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
|
||||||
|
.Select(route => (Guid?)route!.ApiKeyCredentialId)
|
||||||
|
.FirstOrDefault(id => Credentials.Any(credential => credential.EntityId == id));
|
||||||
|
|
||||||
|
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
|
||||||
|
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
|
||||||
|
{
|
||||||
|
Label = link.Name,
|
||||||
|
Hostname = link.Hostname,
|
||||||
|
Port = HostSecret.DefaultPort,
|
||||||
|
TagIds = tags,
|
||||||
|
Spelunk = new SpelunkRoute
|
||||||
|
{
|
||||||
|
ApiBase = link.ApiBase,
|
||||||
|
ServerId = link.ServerId,
|
||||||
|
ApiKeyCredentialId = apiKeyCredentialId,
|
||||||
|
SignInThroughSpelunk = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
||||||
|
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
|
||||||
|
{
|
||||||
|
if (IsBusy)
|
||||||
|
{
|
||||||
|
// RunAsync would drop it without a word, and the user pressed a button somewhere else to get here.
|
||||||
|
Status = $"Busy — open {link.Name} from Spelunk again in a moment.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? hostId = null;
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
$"Adding {link.Name} from Spelunk…",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
hostId = await session.Hosts
|
||||||
|
.CreateAsync(
|
||||||
|
vaultId,
|
||||||
|
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// A failure has said so on the status line already.
|
||||||
|
if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = $"Added {link.Name} from Spelunk at {link.ApiBase}.";
|
||||||
|
|
||||||
|
await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// After the connect rather than before it: the host is saved either way, and the terminal is what the
|
||||||
|
// user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick.
|
||||||
|
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
|
||||||
|
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
|
||||||
|
/// cancelled stays, as the editor's own new-tag box leaves it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
|
||||||
|
{
|
||||||
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
||||||
// is not worth somebody's unsaved host.
|
// is not worth somebody's unsaved host.
|
||||||
if (AHostEditorIsInTheWay())
|
if (AHostEditorIsInTheWay())
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NewHost();
|
NewHost();
|
||||||
|
|
||||||
|
if (personal is { } vaultId
|
||||||
|
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
|
||||||
|
{
|
||||||
|
EditorSelectedVault = choice;
|
||||||
|
}
|
||||||
|
|
||||||
EditorLabel = link.Name;
|
EditorLabel = link.Name;
|
||||||
EditorHostname = link.Hostname;
|
EditorHostname = link.Hostname;
|
||||||
EditorPort = HostSecret.DefaultPort;
|
EditorPort = HostSecret.DefaultPort;
|
||||||
EditorThroughSpelunk = true;
|
EditorThroughSpelunk = true;
|
||||||
|
EditorSpelunkSignIn = true;
|
||||||
EditorSpelunkApi = link.ApiBase;
|
EditorSpelunkApi = link.ApiBase;
|
||||||
EditorSpelunkServerId = link.ServerId.ToString();
|
EditorSpelunkServerId = link.ServerId.ToString();
|
||||||
|
|
||||||
var knownKey = Hosts
|
BuildSpelunkKeyChoices(knownKey);
|
||||||
.Select(row => row.Host.Spelunk)
|
|
||||||
.FirstOrDefault(route => route is not null && string.Equals(route.ApiBase, link.ApiBase, StringComparison.Ordinal))
|
|
||||||
?.ApiKeyCredentialId;
|
|
||||||
|
|
||||||
BuildSpelunkKeyChoices(Credentials.Any(credential => credential.EntityId == knownKey) ? knownKey : null);
|
|
||||||
|
|
||||||
IsHostPaneOpen = true;
|
IsHostPaneOpen = true;
|
||||||
Status = EditorSelectedSpelunkKey is null
|
|
||||||
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in and the credential "
|
|
||||||
+ "holding your Spelunk API key, then save."
|
|
||||||
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in, then save.";
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
var said = EditorSelectedSpelunkKey is null
|
||||||
|
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential "
|
||||||
|
+ "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and "
|
||||||
|
+ "later links from this Spelunk connect straight away."
|
||||||
|
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
||||||
|
|
||||||
|
Status = said;
|
||||||
|
|
||||||
|
if (personal is { } into)
|
||||||
|
{
|
||||||
|
await TagHostFromLinkAsync(into, said).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds the <see cref="SpelunkTag"/> tag to the host a link opened the editor on.</summary>
|
||||||
|
/// <param name="vaultId">The personal vault, where the host is to be saved and the tag is.</param>
|
||||||
|
/// <param name="said">What the status line says about the link, put back after the reload.</param>
|
||||||
|
private async Task TagHostFromLinkAsync(Guid vaultId, string said)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Only onto the host this link opened: the user may have cancelled it, or moved on to another, in
|
||||||
|
// the moment the tag took.
|
||||||
|
if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorTagIds = TagSet.Create([.. editorTagIds, tagId]);
|
||||||
|
|
||||||
|
if (!tagsById.ContainsKey(tagId))
|
||||||
|
{
|
||||||
|
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildTagChoices();
|
||||||
|
Status = said;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The host can still be saved; it is the tag that could not be made, and a later link would then
|
||||||
|
// not find it. Said, rather than failing the whole link over it.
|
||||||
|
Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The <see cref="SpelunkTag"/> tag in a vault, made there if it has none.</summary>
|
||||||
|
private async Task<Guid> EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (listing.Items
|
||||||
|
.Where(tag => IsSpelunkTag(tag.Secret))
|
||||||
|
.OrderBy(tag => tag.EntityId)
|
||||||
|
.FirstOrDefault() is { } existing)
|
||||||
|
{
|
||||||
|
return existing.EntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await session.Tags
|
||||||
|
.CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -207,15 +463,20 @@ internal sealed partial class VaultViewModel
|
|||||||
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
||||||
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
|
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
|
||||||
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
|
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
|
||||||
|
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||||
(row.Resolved.Binding is { } binding && binding.Kind == kind && binding.EntityId == entityId)
|
(!row.Host.SignsInThroughSpelunk
|
||||||
|
&& row.Resolved.Binding is { } binding
|
||||||
|
&& binding.Kind == kind
|
||||||
|
&& binding.EntityId == entityId)
|
||||||
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
|
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
|
||||||
|
|
||||||
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||||
{
|
{
|
||||||
EditorThroughSpelunk = route is not null;
|
EditorThroughSpelunk = route is not null;
|
||||||
|
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||||
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||||
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||||
|
|
||||||
@@ -261,6 +522,7 @@ internal sealed partial class VaultViewModel
|
|||||||
: EditorSpelunkApi.Trim(),
|
: EditorSpelunkApi.Trim(),
|
||||||
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
||||||
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
||||||
|
SignInThroughSpelunk = EditorSpelunkSignIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4308,10 +4308,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
||||||
/// every host under a group that binds a key, and the box would do nothing.
|
/// every host under a group that binds a key, and the box would do nothing.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box —
|
||||||
|
/// or on the phone, a sheet — asking for a password there would be a question with no use for its answer.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool SelectedHostAsksForAPassword =>
|
internal bool SelectedHostAsksForAPassword =>
|
||||||
SelectedHost is null
|
SelectedHost is null
|
||||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||||
|
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||||
@@ -4330,11 +4335,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// changed.
|
/// changed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
|
internal string SelectedHostAuthenticationNote => SelectedHost switch
|
||||||
{
|
{
|
||||||
{ Kind: ResolvedBindingKind.Credential } binding =>
|
{ Host.SignsInThroughSpelunk: true } =>
|
||||||
|
"Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.",
|
||||||
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } =>
|
||||||
$"This host uses a password stored in your keychain{From(binding)}.",
|
$"This host uses a password stored in your keychain{From(binding)}.",
|
||||||
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
|
||||||
$"This host authenticates with an SSH key{From(binding)}.",
|
$"This host authenticates with an SSH key{From(binding)}.",
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
@@ -4363,14 +4370,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
||||||
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
||||||
/// value that had not loaded.
|
/// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says
|
||||||
|
/// that rather than a username the connection will not use.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
internal string SelectedHostUsernameLabel => SelectedHost switch
|
||||||
? user
|
{
|
||||||
: "no account set";
|
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
|
||||||
|
{ Resolved.Username.Value: { Length: > 0 } user } => user,
|
||||||
|
_ => "no account set",
|
||||||
|
};
|
||||||
|
|
||||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
internal bool SelectedHostUsernameIsInherited =>
|
||||||
|
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
||||||
@@ -4386,11 +4398,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
|
internal string SelectedHostBindingLabel => SelectedHost switch
|
||||||
{
|
{
|
||||||
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
|
{ Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity,
|
||||||
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } =>
|
||||||
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
||||||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
|
||||||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||||||
?? "(a password that is no longer here)",
|
?? "(a password that is no longer here)",
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
@@ -11991,7 +12004,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Username,
|
authentication.Username,
|
||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
|
var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
// The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel.
|
||||||
|
authentication = authentication with { Username = dialled.Username };
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||||
@@ -12086,7 +12103,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// bridge listening until its own timeout; disposing twice is safe.
|
/// bridge listening until its own timeout; disposing twice is safe.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<uint> OpenThroughAsync(
|
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
|
||||||
ConnectionTarget target,
|
ConnectionTarget target,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
PhaseReporter reporter,
|
PhaseReporter reporter,
|
||||||
@@ -12098,15 +12115,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
var through = Through(request, route);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(
|
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
|
||||||
route is null ? request : request with { Proxy = route.Proxy },
|
|
||||||
TerminalSize.Default,
|
|
||||||
reporter,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
return (sessionId, through);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (route is not null)
|
catch (Exception exception) when (route is not null)
|
||||||
{
|
{
|
||||||
@@ -12432,8 +12449,25 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
||||||
/// nothing about keys.
|
/// nothing about keys.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
|
||||||
|
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
|
||||||
|
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool TryBuildAuthentication(
|
private bool TryBuildAuthentication(
|
||||||
|
HostSecret host,
|
||||||
|
ResolvedHost resolved,
|
||||||
|
string typedPassword,
|
||||||
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||||
|
[NotNullWhen(false)] out string? reason) =>
|
||||||
|
host.SignsInThroughSpelunk
|
||||||
|
? SignedInBySpelunk(out authentication, out reason)
|
||||||
|
: TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason);
|
||||||
|
|
||||||
|
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
|
||||||
|
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
|
||||||
|
private bool TryBuildBoundAuthentication(
|
||||||
HostSecret host,
|
HostSecret host,
|
||||||
ResolvedHost resolved,
|
ResolvedHost resolved,
|
||||||
string typedPassword,
|
string typedPassword,
|
||||||
@@ -12498,6 +12532,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which
|
||||||
|
/// exists yet. These stand-ins are replaced as the connection is made through the route — see
|
||||||
|
/// <see cref="Through"/> — and refused by the SSH layer if they ever reach it. It is the only way to
|
||||||
|
/// connect without a binding, and only for a host that says so; every other host still needs one.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool SignedInBySpelunk(
|
||||||
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||||
|
out string? reason)
|
||||||
|
{
|
||||||
|
authentication = new HostAuthentication(
|
||||||
|
SpelunkSignIn.DefaultUser,
|
||||||
|
SpelunkSignInPending.Instance,
|
||||||
|
SpelunkSignInIdentity);
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -12570,7 +12625,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
||||||
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
||||||
// host in the vault the first time it was edited.
|
// host in the vault the first time it was edited.
|
||||||
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
// Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in.
|
||||||
|
AsksForPassword = EditorAsksForLogin
|
||||||
|
&& EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
||||||
&& EditorSelectedGroup?.EntityId is not null
|
&& EditorSelectedGroup?.EntityId is not null
|
||||||
? true
|
? true
|
||||||
: null,
|
: null,
|
||||||
@@ -12605,7 +12662,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||||||
private Guid? Bound(AuthenticationKind kind) =>
|
private Guid? Bound(AuthenticationKind kind) =>
|
||||||
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
|
||||||
|
? choice.EntityId
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ namespace DodoSSH.Client.App.Tests;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
/// 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 probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
|
||||||
/// never connects anything the user has not saved.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed partial class ShellFlowTests
|
public sealed partial class ShellFlowTests
|
||||||
@@ -173,6 +174,131 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
|
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 ----
|
// ---- A link from Spelunk ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -188,12 +314,15 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorHostname.ShouldBe("203.0.113.10");
|
vault.EditorHostname.ShouldBe("203.0.113.10");
|
||||||
vault.EditorPort.ShouldBe(22);
|
vault.EditorPort.ShouldBe(22);
|
||||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||||
|
vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk");
|
||||||
|
vault.EditorAsksForLogin.ShouldBeFalse();
|
||||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||||
|
|
||||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
// 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.
|
// for it would be offering to send the key wherever a link says.
|
||||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("first server");
|
||||||
|
|
||||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
@@ -211,34 +340,138 @@ public sealed partial class ShellFlowTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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.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]
|
[Fact]
|
||||||
public async Task ALinkForASavedHost_ConnectsToIt()
|
public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
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 using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
||||||
|
|
||||||
vault.IsEditing.ShouldBeFalse();
|
vault.IsEditing.ShouldBeFalse();
|
||||||
|
vault.Hosts.ShouldHaveSingleItem();
|
||||||
spelunkTunnels.Opened.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();
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
request.Host.ShouldBe("203.0.113.10");
|
request.Host.ShouldBe("203.0.113.10");
|
||||||
|
request.Username.ShouldBe("deploy");
|
||||||
|
|
||||||
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
|
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]
|
[Fact]
|
||||||
public async Task ALinkWhileLocked_WaitsForTheUnlock()
|
public async Task ALinkWhileLocked_WaitsForTheUnlock()
|
||||||
{
|
{
|
||||||
@@ -255,6 +488,7 @@ public sealed partial class ShellFlowTests
|
|||||||
var vault = shell.Vault.ShouldNotBeNull();
|
var vault = shell.Vault.ShouldNotBeNull();
|
||||||
vault.IsEditing.ShouldBeTrue();
|
vault.IsEditing.ShouldBeTrue();
|
||||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||||
|
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -304,6 +538,32 @@ public sealed partial class ShellFlowTests
|
|||||||
return vault;
|
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) =>
|
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
|
||||||
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
|
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; } = [];
|
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; }
|
internal SpelunkTunnelException? Refusal { get; set; }
|
||||||
|
|
||||||
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
|
/// <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,
|
Guid serverId,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
int port,
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Opened.Add((apiBase, serverId, apiKey, port));
|
Opened.Add((apiBase, serverId, apiKey, port));
|
||||||
|
SignIns.Add(signIn);
|
||||||
|
|
||||||
if (Refusal is { } refusal)
|
if (Refusal is { } refusal)
|
||||||
{
|
{
|
||||||
@@ -364,7 +632,7 @@ public sealed partial class ShellFlowTests
|
|||||||
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
|
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)
|
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>
|
/// <summary>A server's id in Spelunk.</summary>
|
||||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||||
|
|
||||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
/// <summary>
|
||||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
|
||||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey };
|
/// 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>
|
/// <remarks>
|
||||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
/// <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
|
/// "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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// flag has its own tests.
|
||||||
@@ -41,7 +42,7 @@ public sealed class HostSecretCodecTests
|
|||||||
tags: [Pci, EuWest],
|
tags: [Pci, EuWest],
|
||||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||||
portForwards: [Postgres, Socks],
|
portForwards: [Postgres, Socks],
|
||||||
spelunk: ThroughSpelunk());
|
spelunk: ThroughSpelunk(signIn: true));
|
||||||
|
|
||||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ public sealed class SpelunkRouteTests
|
|||||||
document.ShouldNotBeNull();
|
document.ShouldNotBeNull();
|
||||||
document.Host.ShouldBe(host);
|
document.Host.ShouldBe(host);
|
||||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
||||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
document.IsReadOnly.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests
|
|||||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||||
{
|
{
|
||||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
// 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(
|
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();
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||||
@@ -168,6 +168,108 @@ public sealed class SpelunkRouteTests
|
|||||||
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
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 ----
|
// ---- The merge ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
||||||
{
|
{
|
||||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
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");
|
refusal.Message.ShouldContain("does not hold one");
|
||||||
spelunk.Requests.ShouldBeEmpty();
|
spelunk.Requests.ShouldBeEmpty();
|
||||||
@@ -215,7 +215,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
public async Task AKeyPastedWithANewline_IsTrimmed()
|
public async Task AKeyPastedWithANewline_IsTrimmed()
|
||||||
{
|
{
|
||||||
await using var route = await new SpelunkTunnel()
|
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}");
|
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
||||||
}
|
}
|
||||||
@@ -230,11 +230,130 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
closed.Stop();
|
closed.Stop();
|
||||||
|
|
||||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
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}");
|
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);
|
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>
|
/// <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>
|
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
|
||||||
file static class Opening
|
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(
|
new SpelunkTunnel().OpenAsync(
|
||||||
apiBase,
|
apiBase,
|
||||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||||
port,
|
port,
|
||||||
|
signIn,
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user