Merge claude/spelunk-sign-in: sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m36s

This commit is contained in:
jaap-jan committed 2026-09-29 09:16:12 +02:00
commit ee423b25ce
18 files changed
+1239 -115

No files matched your search

+26 -5
View File
@@ -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
+22 -2
View File
@@ -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>
+4
View File
@@ -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>
+43 -9
View File
@@ -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>
+25
View File
@@ -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>
+4 -2
View File
@@ -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()
{
}
}
+119 -13
View File
@@ -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);
} }