Public Access
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
544 lines
24 KiB
C#
544 lines
24 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>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
|
|
/// <remarks>
|
|
/// 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 it names.
|
|
/// </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 — and for a saved Spelunk key, only when
|
|
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
|
|
/// </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)
|
|
{
|
|
var apiKey = SpelunkApiKeyFor(row, route);
|
|
|
|
return await Spelunk
|
|
.OpenAsync(
|
|
route.ApiBase,
|
|
route.ServerId,
|
|
apiKey,
|
|
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 saved for that Spelunk under Settings → Security, or else the key
|
|
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
|
|
/// Nothing leaks by this: the key goes where the user already said it 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 with no key 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 the user
|
|
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
|
/// vault — and the user chooses 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 key the user has already agreed may go to this Spelunk, or null when there is none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
|
|
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
|
|
/// keys existed and is still true of every host set up that way.
|
|
/// </para>
|
|
/// <para>
|
|
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
|
|
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
|
|
/// </para>
|
|
/// </remarks>
|
|
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
|
|
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
|
|
?? 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)
|
|
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
|
|
|
|
/// <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 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. Saving the key under Settings → Security does the same."
|
|
: $"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>
|
|
/// <remarks>
|
|
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
|
|
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
|
|
/// </remarks>
|
|
private void BuildSpelunkKeyChoices(Guid? selectedId)
|
|
{
|
|
EditorSpelunkKeyChoices.Clear();
|
|
|
|
foreach (var key in SpelunkKeys)
|
|
{
|
|
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
|
|
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|