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