Public Access
Compare commits
2
Commits
07bd5c9886
...
ee423b25ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee423b25ce | ||
|
|
38ab9b431b |
No files matched your search
@@ -471,12 +471,33 @@ this way; the relay and Spelunk are one choice, and ticking one unticks the othe
|
||||
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
||||
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=…`
|
||||
link, or hands the link to the copy already running. The link never carries a credential. If a saved host
|
||||
already reaches that server through that Spelunk, DodoSSH connects to it; otherwise it opens the host editor
|
||||
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
|
||||
arrives while the vault is locked waits for the unlock.
|
||||
link, or hands the link to the copy already running, and **connects straight away.** The link never
|
||||
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
||||
|
||||
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
|
||||
- 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
|
||||
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"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||
@@ -1016,6 +1017,7 @@
|
||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -1028,6 +1030,9 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</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
|
||||
@@ -1237,9 +1242,15 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</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>
|
||||
<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
|
||||
|
||||
@@ -578,7 +578,8 @@
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,78">
|
||||
<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}"
|
||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||
@@ -599,6 +600,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -612,6 +614,15 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</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
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
@@ -726,6 +737,15 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</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>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<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>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Checks the fields that must hold before this can be stored.
|
||||
/// </summary>
|
||||
|
||||
@@ -135,12 +135,28 @@ public static class HostSecretCodec
|
||||
/// </remarks>
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = SpelunkSchemaVersion;
|
||||
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -193,14 +209,7 @@ public static class HostSecretCodec
|
||||
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
||||
// 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.
|
||||
Spelunk = host.Spelunk is { } route
|
||||
? new SpelunkPayloadDocument
|
||||
{
|
||||
Api = route.ApiBase,
|
||||
ServerId = route.ServerId,
|
||||
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||
}
|
||||
: null,
|
||||
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -286,6 +295,11 @@ public static class HostSecretCodec
|
||||
version = Math.Max(version, SpelunkSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.SignsInThroughSpelunk)
|
||||
{
|
||||
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -437,6 +451,9 @@ public static class HostSecretCodec
|
||||
ApiBase = parsed.Api ?? string.Empty,
|
||||
ServerId = parsed.ServerId ?? 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? 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>
|
||||
|
||||
@@ -59,6 +59,31 @@ public sealed record SpelunkRoute
|
||||
/// </remarks>
|
||||
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>
|
||||
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
|
||||
@@ -18,8 +18,10 @@ namespace DodoSSH.Client.Session;
|
||||
/// <para>
|
||||
/// <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,
|
||||
/// and the most it can ever do is select a host the user already saved, or open the editor on a new one
|
||||
/// that the user then has to finish and save. It never connects to anything the user has not saved.
|
||||
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
|
||||
/// 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>
|
||||
/// </remarks>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The throwaway key one connection signs in to a Spelunk server with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
|
||||
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
|
||||
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
|
||||
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
|
||||
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
|
||||
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkSignIn
|
||||
{
|
||||
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
|
||||
public const string DefaultUser = "root";
|
||||
|
||||
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
|
||||
internal const string KeyComment = "dodossh-spelunk-sign-in";
|
||||
|
||||
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
|
||||
{
|
||||
PublicKeyLine = publicKeyLine;
|
||||
Credential = credential;
|
||||
}
|
||||
|
||||
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
|
||||
public string PublicKeyLine { get; }
|
||||
|
||||
/// <summary>The private half, in the form a connection authenticates with.</summary>
|
||||
public SshPrivateKeyCredential Credential { get; }
|
||||
|
||||
/// <summary>Makes a fresh key pair for one connection.</summary>
|
||||
public static SpelunkSignIn Create()
|
||||
{
|
||||
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
|
||||
|
||||
return new SpelunkSignIn(
|
||||
key.PublicKeyLine.Trim(),
|
||||
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
|
||||
public override string ToString() => "Spelunk sign-in key";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
|
||||
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
|
||||
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
|
||||
/// with the username, as the connection is made through it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
|
||||
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
|
||||
/// sending something else in its place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkSignInPending : SshCredential
|
||||
{
|
||||
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
|
||||
public static SpelunkSignInPending Instance { get; } = new();
|
||||
|
||||
private SpelunkSignInPending()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ public static class SpelunkTunnelProtocol
|
||||
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
||||
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";
|
||||
|
||||
/// <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="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="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>
|
||||
/// <remarks>
|
||||
/// 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,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -99,32 +111,59 @@ public sealed class SpelunkTunnelRoute : IConnectionRoute
|
||||
private readonly RelayBridge bridge;
|
||||
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.port = port;
|
||||
SignIn = signIn;
|
||||
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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 />
|
||||
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||
|
||||
/// <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
|
||||
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 =>
|
||||
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>
|
||||
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>
|
||||
private const int MaxProblemBytes = 16 * 1024;
|
||||
|
||||
@@ -263,6 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||
@@ -270,7 +313,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
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);
|
||||
|
||||
var socket = await connect(
|
||||
@@ -279,7 +322,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port);
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -320,6 +363,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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
|
||||
// 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(
|
||||
new SpelunkTicketRequestDocument { Port = port },
|
||||
signIn is null
|
||||
? new SpelunkTicketRequestDocument { Port = port }
|
||||
: new SpelunkTicketRequestDocument
|
||||
{
|
||||
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
|
||||
},
|
||||
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||
@@ -365,13 +417,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
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>
|
||||
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
||||
HttpResponseMessage response,
|
||||
bool signingIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SpelunkTicketDocument? ticket;
|
||||
@@ -397,7 +451,8 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
|| path.StartsWith("//", StringComparison.Ordinal)
|
||||
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| (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);
|
||||
}
|
||||
@@ -412,6 +467,18 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
private static bool IsBase64UrlCharacter(char character) =>
|
||||
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>
|
||||
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
||||
{
|
||||
@@ -419,6 +486,15 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
|
||||
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 =>
|
||||
"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.",
|
||||
@@ -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>
|
||||
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
||||
HttpResponseMessage response,
|
||||
@@ -520,9 +607,21 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
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>
|
||||
@@ -535,19 +634,26 @@ internal sealed class SpelunkTicketDocument
|
||||
public string? Path { 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
|
||||
{
|
||||
public string? Title { 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(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
||||
[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
|
||||
/// 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
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never connects to a host the user has not
|
||||
/// saved.
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||
/// host already sends it to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <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 =>
|
||||
ShowsHostPicker
|
||||
&& (SelectedHost is null
|
||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
@@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
||||
is not { } opened)
|
||||
is not ({ } opened, { } dialled))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session = opened;
|
||||
TypedPassword = string.Empty;
|
||||
MarkHostConnected(opened, request, row, identityLabel);
|
||||
MarkHostConnected(opened, dialled, row, identityLabel);
|
||||
|
||||
reconnectTo = (RemoteKind.Host, row.EntityId);
|
||||
|
||||
@@ -1818,8 +1819,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// <summary>
|
||||
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
||||
/// </summary>
|
||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
||||
/// <returns>
|
||||
/// 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,
|
||||
HostRowViewModel row,
|
||||
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.
|
||||
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return await sftp
|
||||
.OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var through = VaultViewModel.Through(request, route);
|
||||
|
||||
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (route?.Failure is { } failure
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
@@ -16,6 +17,15 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
||||
/// </remarks>
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||
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>
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkApi = string.Empty;
|
||||
@@ -54,6 +74,16 @@ internal sealed partial class VaultViewModel
|
||||
[ObservableProperty]
|
||||
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" />
|
||||
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,
|
||||
/// because a Spelunk host's address may be nothing that answers at all.
|
||||
/// </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>
|
||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -102,6 +136,39 @@ internal sealed partial class VaultViewModel
|
||||
: 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>
|
||||
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
|
||||
/// </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
|
||||
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
|
||||
/// </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>
|
||||
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
|
||||
HostRowViewModel row,
|
||||
@@ -129,7 +202,13 @@ internal sealed partial class VaultViewModel
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a
|
||||
/// completed task otherwise.
|
||||
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A saved host for that server on that Spelunk is connected to</b>, exactly as a tap on its row
|
||||
/// would — a password prompt if it asks for one, a host key question if it has not been seen.
|
||||
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
|
||||
/// 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>
|
||||
/// <b>Otherwise the editor opens on a new host, and nothing more.</b> The link came from outside, and
|
||||
/// anything on this machine can make one; it must never be able to make this application connect
|
||||
/// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is
|
||||
/// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and
|
||||
/// the user chooses how to log in and which credential holds the key, and presses SAVE.
|
||||
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
||||
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||
/// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is
|
||||
/// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already
|
||||
/// goes, and the server is one that key's own organisation holds or Spelunk refuses it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key is pre-chosen only when another host already sends it to the same Spelunk.</b> That is a
|
||||
/// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a
|
||||
/// Spelunk no host uses would be offering to send it to whatever address the link named.
|
||||
/// <b>The first link from a Spelunk opens the editor instead, and nothing more.</b> Anything on this
|
||||
/// machine can make a link, so it must never be able to send an API key to an address no saved host
|
||||
/// 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>
|
||||
/// </remarks>
|
||||
internal Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||
internal async Task OpenSpelunkLinkAsync(SpelunkLink 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
|
||||
// is not worth somebody's unsaved host.
|
||||
if (AHostEditorIsInTheWay())
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
NewHost();
|
||||
|
||||
if (personal is { } vaultId
|
||||
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
|
||||
{
|
||||
EditorSelectedVault = choice;
|
||||
}
|
||||
|
||||
EditorLabel = link.Name;
|
||||
EditorHostname = link.Hostname;
|
||||
EditorPort = HostSecret.DefaultPort;
|
||||
EditorThroughSpelunk = true;
|
||||
EditorSpelunkSignIn = true;
|
||||
EditorSpelunkApi = link.ApiBase;
|
||||
EditorSpelunkServerId = link.ServerId.ToString();
|
||||
|
||||
var knownKey = Hosts
|
||||
.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);
|
||||
BuildSpelunkKeyChoices(knownKey);
|
||||
|
||||
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>
|
||||
@@ -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,
|
||||
/// 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.
|
||||
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||
{
|
||||
EditorThroughSpelunk = route is not null;
|
||||
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||
|
||||
@@ -261,6 +522,7 @@ internal sealed partial class VaultViewModel
|
||||
: EditorSpelunkApi.Trim(),
|
||||
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : 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
|
||||
/// every host under a group that binds a key, and the box would do nothing.
|
||||
/// </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>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </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)}.",
|
||||
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
|
||||
$"This host authenticates with an SSH key{From(binding)}.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
@@ -4363,14 +4370,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// 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>
|
||||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
||||
? user
|
||||
: "no account set";
|
||||
internal string SelectedHostUsernameLabel => SelectedHost switch
|
||||
{
|
||||
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
|
||||
{ Resolved.Username.Value: { Length: > 0 } user } => user,
|
||||
_ => "no account set",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
||||
internal bool SelectedHostUsernameIsInherited =>
|
||||
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </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)",
|
||||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
|
||||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||||
?? "(a password that is no longer here)",
|
||||
_ => string.Empty,
|
||||
@@ -11991,7 +12004,11 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
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
|
||||
// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<uint> OpenThroughAsync(
|
||||
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
|
||||
ConnectionTarget target,
|
||||
SshConnectionRequest request,
|
||||
PhaseReporter reporter,
|
||||
@@ -12098,15 +12115,15 @@ internal sealed partial class VaultViewModel(
|
||||
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
|
||||
var through = Through(request, route);
|
||||
|
||||
try
|
||||
{
|
||||
return await workspace
|
||||
.OpenSessionAsync(
|
||||
route is null ? request : request with { Proxy = route.Proxy },
|
||||
TerminalSize.Default,
|
||||
reporter,
|
||||
cancellationToken)
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
return (sessionId, through);
|
||||
}
|
||||
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
|
||||
/// nothing about keys.
|
||||
/// </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>
|
||||
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,
|
||||
ResolvedHost resolved,
|
||||
string typedPassword,
|
||||
@@ -12498,6 +12532,27 @@ internal sealed partial class VaultViewModel(
|
||||
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>
|
||||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||||
/// </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
|
||||
// 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.
|
||||
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
|
||||
? true
|
||||
: null,
|
||||
@@ -12605,7 +12662,9 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||||
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>
|
||||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||||
|
||||
@@ -20,8 +20,9 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, and a link
|
||||
/// never connects anything the user has not saved.
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
|
||||
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
|
||||
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
@@ -173,6 +174,131 @@ public sealed partial class ShellFlowTests
|
||||
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn()
|
||||
{
|
||||
spelunkTunnels.User = "ops";
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type");
|
||||
vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
|
||||
|
||||
// The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("ops");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
shell.Tabs.ShouldHaveSingleItem();
|
||||
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession()
|
||||
{
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic;
|
||||
vault.EditorNewForwardBindPort = "1080";
|
||||
vault.EditorNewForwardTarget = string.Empty;
|
||||
vault.AddEditorForwardCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening");
|
||||
ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080));
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo()
|
||||
{
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
var request = ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("root");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10");
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin()
|
||||
{
|
||||
// The sign-in is what lets a host connect with no binding; without it, the old rule stands.
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSpelunkSignIn = false;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("no username");
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorAsksForLogin.ShouldBeTrue();
|
||||
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorAsksForLogin.ShouldBeFalse();
|
||||
|
||||
// The choice is still there to see, and to come back to, but it is not saved beside the sign-in.
|
||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = vault.Hosts.ShouldHaveSingleItem().Host;
|
||||
host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
host.CredentialId.ShouldBeNull();
|
||||
host.SshKeyId.ShouldBeNull();
|
||||
host.AsksForPassword.ShouldBeNull();
|
||||
|
||||
// Unticking the route altogether gives the login back.
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||
vault.EditorThroughSpelunk = false;
|
||||
vault.EditorAsksForLogin.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- A link from Spelunk ----
|
||||
|
||||
[Fact]
|
||||
@@ -188,12 +314,15 @@ public sealed partial class ShellFlowTests
|
||||
vault.EditorHostname.ShouldBe("203.0.113.10");
|
||||
vault.EditorPort.ShouldBe(22);
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk");
|
||||
vault.EditorAsksForLogin.ShouldBeFalse();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
|
||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
||||
// for it would be offering to send the key wherever a link says.
|
||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("first server");
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
@@ -211,34 +340,138 @@ public sealed partial class ShellFlowTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
|
||||
public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue();
|
||||
vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
|
||||
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
|
||||
chip => chip.IsWorn.ShouldBeTrue());
|
||||
|
||||
// So once it is saved, the next link for that server finds it rather than adding a second one.
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem();
|
||||
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForASavedHost_ConnectsToIt()
|
||||
public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
|
||||
|
||||
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
|
||||
// key that Spelunk already gets, and no login of its own.
|
||||
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
|
||||
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
||||
added.Label.ShouldBe("web 01");
|
||||
added.Host.Hostname.ShouldBe($"spelunk-{other}");
|
||||
added.Host.Port.ShouldBe(22);
|
||||
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
|
||||
added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId);
|
||||
added.Host.SshKeyId.ShouldBeNull();
|
||||
added.Host.CredentialId.ShouldBeNull();
|
||||
added.Host.Username.ShouldBeNull();
|
||||
added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId);
|
||||
|
||||
// And connected, through Spelunk, signing in.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("root");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(server: other));
|
||||
await vault.OpenSpelunkLinkAsync(Link(server: other));
|
||||
|
||||
vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1);
|
||||
spelunkTunnels.Opened.Count.ShouldBe(2);
|
||||
|
||||
// The two SignIns are two keys: one per connection, never reused.
|
||||
spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine
|
||||
.ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine);
|
||||
|
||||
foreach (var request in ssh.Requests)
|
||||
{
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewTag = VaultViewModel.SpelunkTag;
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.Hosts.ShouldHaveSingleItem();
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
|
||||
// The saved host, as saved — not the address the link happened to carry.
|
||||
// The saved host, as saved — not the address the link happened to carry, and its own login, since
|
||||
// this one does not sign in through Spelunk.
|
||||
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull();
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
request.Username.ShouldBe("deploy");
|
||||
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn()
|
||||
{
|
||||
// Untagged, so not one a link made: it is left as it is, and the link gets the host links make.
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.Hosts.Count.ShouldBe(2);
|
||||
vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty();
|
||||
vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkWhileLocked_WaitsForTheUnlock()
|
||||
{
|
||||
@@ -255,6 +488,7 @@ public sealed partial class ShellFlowTests
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -304,6 +538,32 @@ public sealed partial class ShellFlowTests
|
||||
return vault;
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
|
||||
private async Task<VaultViewModel> ReadyToSignInThroughSpelunkAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
|
||||
vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
|
||||
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
|
||||
|
||||
@@ -338,6 +598,12 @@ public sealed partial class ShellFlowTests
|
||||
{
|
||||
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
|
||||
|
||||
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
||||
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
||||
|
||||
/// <summary>The account a sign-in answer names.</summary>
|
||||
internal string User { get; set; } = "root";
|
||||
|
||||
internal SpelunkTunnelException? Refusal { get; set; }
|
||||
|
||||
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
|
||||
@@ -348,9 +614,11 @@ public sealed partial class ShellFlowTests
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Opened.Add((apiBase, serverId, apiKey, port));
|
||||
SignIns.Add(signIn);
|
||||
|
||||
if (Refusal is { } refusal)
|
||||
{
|
||||
@@ -364,7 +632,7 @@ public sealed partial class ShellFlowTests
|
||||
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port);
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User);
|
||||
}
|
||||
|
||||
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -45,9 +45,12 @@ internal static class HostFactory
|
||||
/// <summary>A server's id in Spelunk.</summary>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||
|
||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey };
|
||||
/// <summary>
|
||||
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
|
||||
/// when <paramref name="signIn"/> says so.
|
||||
/// </summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn };
|
||||
|
||||
/// <remarks>
|
||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||
|
||||
@@ -18,7 +18,8 @@ public sealed class HostSecretCodecTests
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the
|
||||
/// binding (a sign-in leaves a stored binding alone rather than refusing it) and
|
||||
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
|
||||
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
|
||||
/// flag has its own tests.
|
||||
@@ -41,7 +42,7 @@ public sealed class HostSecretCodecTests
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
portForwards: [Postgres, Socks],
|
||||
spelunk: ThroughSpelunk());
|
||||
spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ public sealed class SpelunkRouteTests
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||
// version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
// version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
@@ -168,6 +168,108 @@ public sealed class SpelunkRouteTests
|
||||
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn()
|
||||
{
|
||||
// No username, no key, no credential, no typed password: Spelunk names the account and makes the key.
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
host.TryValidate(out var reason).ShouldBeTrue(reason);
|
||||
host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries()
|
||||
{
|
||||
// Left alone rather than refused: it is the host's answer for the day the box is unticked, and a
|
||||
// merge of one machine ticking it with another binding a key must still decode.
|
||||
Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true))
|
||||
.TryValidate(out var reason)
|
||||
.ShouldBeTrue(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn()
|
||||
{
|
||||
Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse();
|
||||
Host().SignsInThroughSpelunk.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt()
|
||||
{
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn()
|
||||
{
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true)));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
document.Host.SignsInThroughSpelunk.ShouldBeFalse();
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout()
|
||||
{
|
||||
// One state, one spelling — the merge would otherwise see a change nobody made.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":false}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickingSignIn_MergesAsPartOfTheOneRoute()
|
||||
{
|
||||
var ancestor = Host(spelunk: ThroughSpelunk());
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) };
|
||||
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
||||
|
||||
// Alone, the tick arrives.
|
||||
HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local);
|
||||
|
||||
// Beside a different edit to the route, the route is still one value: one side wins whole, with the
|
||||
// other reported, rather than a sign-in to a server nobody chose it for.
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
||||
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
||||
}
|
||||
|
||||
// ---- The merge ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -205,7 +205,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
||||
{
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, Token));
|
||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain("does not hold one");
|
||||
spelunk.Requests.ShouldBeEmpty();
|
||||
@@ -215,7 +215,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
public async Task AKeyPastedWithANewline_IsTrimmed()
|
||||
{
|
||||
await using var route = await new SpelunkTunnel()
|
||||
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, Token);
|
||||
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token);
|
||||
|
||||
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
||||
}
|
||||
@@ -230,11 +230,130 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
closed.Stop();
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, Token));
|
||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
var signIn = SpelunkSignIn.Create();
|
||||
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn);
|
||||
|
||||
// The one-line OpenSSH public key and nothing else: the port means nothing beside a key.
|
||||
// Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is
|
||||
// the same string to anything that parses it.
|
||||
using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body))
|
||||
{
|
||||
body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]);
|
||||
var sent = body.RootElement.GetProperty("signIn");
|
||||
sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]);
|
||||
sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine);
|
||||
}
|
||||
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA");
|
||||
|
||||
// The same key comes back with the route, for the connection made through it, under Spelunk's account.
|
||||
route.SignIn.ShouldBeSameAs(signIn);
|
||||
route.User.ShouldBe("root");
|
||||
|
||||
// And the socket is spent exactly as a plain tunnel's is.
|
||||
spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot()
|
||||
{
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
route.User.ShouldBe(SpelunkSignIn.DefaultUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
|
||||
await using var route = await OpenAsync();
|
||||
|
||||
route.SignIn.ShouldBeNull();
|
||||
route.User.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("root; rm -rf /")]
|
||||
[InlineData("root\\nsomeone")]
|
||||
[InlineData("")]
|
||||
public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user)
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs(user);
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.Message.ShouldContain("does not understand");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords()
|
||||
{
|
||||
spelunk.OnTicket = _ => new FakeSpelunk.Answer(
|
||||
HttpStatusCode.BadRequest,
|
||||
"application/problem+json",
|
||||
"""{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}""");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
refusal.Message.ShouldContain("would not accept");
|
||||
refusal.Message.ShouldContain("must be an ssh-ed25519 key");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")]
|
||||
[InlineData("Unknown stream kind: signIn", "too old to sign you in")]
|
||||
public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation)
|
||||
{
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
|
||||
|
||||
var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInKey_IsNewEachTime_AndNeverPrinted()
|
||||
{
|
||||
var first = SpelunkSignIn.Create();
|
||||
var second = SpelunkSignIn.Create();
|
||||
|
||||
first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine);
|
||||
first.PublicKeyLine.Split(' ').Length.ShouldBe(3);
|
||||
first.Credential.Passphrase.ShouldBeNull();
|
||||
Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
|
||||
first.ToString().ShouldNotContain("AAAA");
|
||||
first.ToString().ShouldNotContain("PRIVATE");
|
||||
}
|
||||
|
||||
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
|
||||
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> SignedInAs(string user) => request => FakeSpelunk.Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"}
|
||||
""");
|
||||
|
||||
private Task<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
|
||||
|
||||
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
|
||||
@@ -273,11 +392,12 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
|
||||
file static class Opening
|
||||
{
|
||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port) =>
|
||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
|
||||
new SpelunkTunnel().OpenAsync(
|
||||
apiBase,
|
||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||
port,
|
||||
signIn,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
Reference in new issue
Block a user