Public Access
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.
529 lines
23 KiB
C#
529 lines
23 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Reaching a host through Spelunk: the editor's half, the connect path's half, and a link from Spelunk.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A file of its own because it is one feature and this class is long enough without it. Everything here
|
|
/// follows the relay, which is the other route a host can take: a tick box in the editor, a route opened by
|
|
/// the connect that will use it, no falling back to a direct dial, and a stated failure when the route
|
|
/// cannot be opened. See <see cref="HostSecret.Spelunk"/> and <see cref="SpelunkRoute"/>.
|
|
/// </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
|
|
/// transport, and every tunnel is a fresh ticket and a fresh socket. Optional in the constructor so both
|
|
/// heads and every existing test go on meaning what they did, and a test that wants to see what was
|
|
/// opened passes a substitute.
|
|
/// </remarks>
|
|
private static readonly ISpelunkTunnel DefaultSpelunk = new SpelunkTunnel();
|
|
|
|
/// <summary>Whether the host being edited is reached through Spelunk.</summary>
|
|
/// <remarks>
|
|
/// Ticking it unticks the relay and the other way round, rather than letting both be ticked and refusing
|
|
/// the save: a host has one route, and <see cref="HostSecret.TryValidate"/> would only catch after the
|
|
/// 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;
|
|
|
|
/// <summary>The Spelunk server id in the editor, as typed.</summary>
|
|
[ObservableProperty]
|
|
private string editorSpelunkServerId = string.Empty;
|
|
|
|
/// <summary>The credentials the editor offers as the Spelunk API key.</summary>
|
|
/// <remarks>
|
|
/// Credentials only, and no "none" entry: a route with no key cannot be opened, and nothing selected is
|
|
/// how a host made from a link says it has not been chosen yet. A key the host names that has since been
|
|
/// deleted keeps a placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
|
|
/// </remarks>
|
|
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
|
|
|
|
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
|
|
[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;
|
|
|
|
partial void OnEditorThroughSpelunkChanged(bool value)
|
|
{
|
|
if (value)
|
|
{
|
|
EditorRelayEnabled = false;
|
|
}
|
|
}
|
|
|
|
partial void OnEditorRelayEnabledChanged(bool value)
|
|
{
|
|
if (value)
|
|
{
|
|
EditorThroughSpelunk = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the route a host is reached by, or null for a host that is dialled.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one place that decides, for both transports: a shell comes through here from
|
|
/// <see cref="OpenThroughAsync"/>, and file transfer opens its own connection and comes through here from
|
|
/// the transfers screen — or that screen would fail for exactly the hosts a route exists for.
|
|
/// </para>
|
|
/// <para>
|
|
/// A host naming a route that cannot be opened is a refusal, never a quiet direct dial. See
|
|
/// <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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(row);
|
|
|
|
if (row.Host.Spelunk is { } route)
|
|
{
|
|
return await OpenSpelunkTunnelAsync(row, route, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
return row.Host.RelayEnabled
|
|
? await OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
|
: 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>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
|
|
/// path uses, and goes only to the API address the host names. A credential that has gone is refused in
|
|
/// words, exactly as a missing SSH credential is — see <see cref="TryBuildAuthentication"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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,
|
|
SpelunkRoute route,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is not { } key)
|
|
{
|
|
throw new SpelunkTunnelException(
|
|
$"'{row.Label}' is reached through Spelunk with an API key credential that is not in this "
|
|
+ "keychain any more. Edit the host to choose another one.");
|
|
}
|
|
|
|
return await Spelunk
|
|
.OpenAsync(
|
|
route.ApiBase,
|
|
route.ServerId,
|
|
key.Credential.Password,
|
|
row.Resolved.Port.Value,
|
|
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
|
cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <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, 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 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 async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(link);
|
|
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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();
|
|
|
|
BuildSpelunkKeyChoices(knownKey);
|
|
|
|
IsHostPaneOpen = true;
|
|
|
|
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>
|
|
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
|
|
/// holding its Spelunk API key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.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;
|
|
|
|
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
|
|
}
|
|
|
|
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
|
|
private void BuildSpelunkKeyChoices(Guid? selectedId)
|
|
{
|
|
EditorSpelunkKeyChoices.Clear();
|
|
|
|
foreach (var credential in Credentials)
|
|
{
|
|
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
|
}
|
|
|
|
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
|
|
{
|
|
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
|
|
}
|
|
|
|
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
|
|
}
|
|
|
|
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
|
/// <remarks>
|
|
/// What cannot be read is written as empty rather than left out, so that
|
|
/// <see cref="HostSecret.TryValidate"/> says what is wrong with it — "needs the id of its Spelunk server"
|
|
/// — instead of the save quietly producing a host that is dialled directly. The address is normalised
|
|
/// here when it can be, so <c>https://api.example.com/</c> saves as the same Spelunk as the link's.
|
|
/// </remarks>
|
|
private SpelunkRoute? BuildSpelunkRoute()
|
|
{
|
|
if (!EditorThroughSpelunk)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new SpelunkRoute
|
|
{
|
|
ApiBase = SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var normalized, out _)
|
|
? normalized
|
|
: EditorSpelunkApi.Trim(),
|
|
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
|
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
|
SignInThroughSpelunk = EditorSpelunkSignIn,
|
|
};
|
|
}
|
|
}
|