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; /// /// Reaching a host through Spelunk: the editor's half, the connect path's half, and a link from Spelunk. /// /// /// 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 and . /// internal sealed partial class VaultViewModel { /// The tag every host made from a Spelunk link wears, and what a link looks for. /// /// Matched without regard to case, as the editor matches every tag name — see AddEditorTagAsync. /// internal const string SpelunkTag = "spelunk"; /// What the status bar and the log name as the identity of a Spelunk sign-in. private const string SpelunkSignInIdentity = "Spelunk sign-in"; /// What opens a tunnel when no caller supplied something else. /// /// Static and shared for the reason 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. /// private static readonly ISpelunkTunnel DefaultSpelunk = new SpelunkTunnel(); /// Whether the host being edited is reached through Spelunk. /// /// 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 would only catch after the /// fact what these two boxes can simply not express. /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(EditorAsksForLogin))] private bool editorThroughSpelunk; /// Whether Spelunk signs the user in to the host being edited, so it needs no login of its own. /// /// Inside the Spelunk card, and meaningful only while that is ticked. See /// . /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(EditorAsksForLogin))] private bool editorSpelunkSignIn; /// The Spelunk API address in the editor, as typed. [ObservableProperty] private string editorSpelunkApi = string.Empty; /// The Spelunk server id in the editor, as typed. [ObservableProperty] private string editorSpelunkServerId = string.Empty; /// The credentials the editor offers as the Spelunk API key. /// /// 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 keeps one. /// internal ObservableCollection EditorSpelunkKeyChoices { get; } = []; /// The credential chosen to hold the Spelunk API key. [ObservableProperty] private AuthenticationChoice? editorSelectedSpelunkKey; /// /// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to. /// /// /// 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. /// internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn); /// private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk; partial void OnEditorThroughSpelunkChanged(bool value) { if (value) { EditorRelayEnabled = false; } } partial void OnEditorRelayEnabledChanged(bool value) { if (value) { EditorThroughSpelunk = false; } } /// /// Opens the route a host is reached by, or null for a host that is dialled. /// /// /// /// The one place that decides, for both transports: a shell comes through here from /// , 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. /// /// /// A host naming a route that cannot be opened is a refusal, never a quiet direct dial. See /// 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. /// /// /// The request a route is used for is made with , which is where a Spelunk sign-in's /// key and account replace the ones the host resolved to. /// /// internal async Task 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; } /// /// 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. /// /// /// /// Both transports make their request through this, so a sign-in cannot reach one of them with the /// stand-in credential gives it — which the SSH layer would refuse /// before dialling anyway. See . /// /// /// 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. /// /// 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 }, }; } /// /// Opens a Spelunk tunnel to a host, with the key its credential holds. /// /// /// /// 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 . /// /// /// 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. /// /// /// A host Spelunk signs in to gets a new key pair here, every time. 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 . A retry after a host key question, or a reconnect, comes back through /// here and makes another. /// /// private async Task 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); } /// /// Does what a dodossh://spelunk/connect link asks, as far as it is allowed to. /// /// /// The work the link started, connection attempt included, so a caller that wants to can wait for it. /// /// /// /// A host for that server in the personal vault, tagged , is connected to, /// 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. /// /// /// Otherwise, when that Spelunk already gets an API key, the host is made and connected to. 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. /// /// /// The first link from a Spelunk opens the editor instead, and nothing more. 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. /// /// 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); } /// The personal vault, when this session can read and write it. private Guid? PersonalVaultId() => session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId; /// Whether a host wears a tag called , from whichever vault. 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); /// /// The credential a saved host already sends to this Spelunk as its API key, or null when none does. /// /// /// 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. /// 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)); /// The host a link makes: what the link says, signing in through Spelunk with a known key. 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, }, }; /// Saves the host a link describes into the personal vault, tagged, and connects to it. 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); } /// /// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to. /// /// /// 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. /// 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); } } /// Adds the tag to the host a link opened the editor on. /// The personal vault, where the host is to be saved and the tag is. /// What the status line says about the link, put back after the reload. 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})"; } } /// The tag in a vault, made there if it has none. private async Task 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); } /// /// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential /// holding its Spelunk API key. /// /// /// 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. /// 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); /// Puts a host's route into the editor, or clears it for a host that has none. 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); } /// Fills the API key picker, keeping whatever the host names selectable. 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); } /// The editor's route, or null when the box is not ticked. /// /// What cannot be read is written as empty rather than left out, so that /// 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 https://api.example.com/ saves as the same Spelunk as the link's. /// 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, }; } }