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; /// /// 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 /// throwaway 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, as whoever this device is signed in to that Spelunk as. /// /// /// /// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see /// . The sign-in only ever goes to the Spelunk it came from, since it is /// found by that address. One Spelunk refuses (a 401) is forgotten, so the next attempt asks to sign in. /// /// /// 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) { var signIn = await SpelunkSignInForAsync(row, route, cancellationToken).ConfigureAwait(true); try { // The sign-in's own address rather than the route's, though they are one string: the token goes to // the Spelunk that issued it, and that is said here rather than left to the lookup that found it. return await Spelunk .OpenAsync( signIn.ApiBase, route.ServerId, signIn.Token, row.Resolved.Port.Value, route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null, cancellationToken) .ConfigureAwait(true); } catch (SpelunkTunnelException refusal) { await ForgetRefusedSpelunkSignInAsync(route.ApiBase, refusal).ConfigureAwait(true); throw; } } /// /// 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 this device is signed in to that Spelunk, 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. It is saved the ordinary way and syncs like any host. Nothing leaks by this: the /// sign-in goes back to the Spelunk the user signed in to, and the server is one they may reach or Spelunk /// refuses it. /// /// /// A link from a Spelunk this device is not signed in to opens the editor instead, and nothing /// more. Anything on this machine can make a link, so it must never be able to start a sign-in by /// itself. The host is pre-filled — signing in through Spelunk, tagged, in the personal vault — and the /// editor offers to sign in; 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 signedIn = await session.SpelunkSignIns.FindAsync(link.ApiBase, CancellationToken.None).ConfigureAwait(true) is not null; if (personal is { } into && signedIn) { await AddFromLinkAndConnectAsync(link, into).ConfigureAwait(true); return; } await OpenLinkInEditorAsync(link, personal).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 host a link makes: what the link says, signing in through Spelunk. private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new() { Label = link.Name, Hostname = link.Hostname, Port = HostSecret.DefaultPort, TagIds = tags, Spelunk = new SpelunkRoute { ApiBase = link.ApiBase, ServerId = link.ServerId, 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) { 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, 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 a link from a Spelunk this device is not signed in 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) { // 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(); IsHostPaneOpen = true; var said = EditorSpelunkNeedsSignIn ? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Sign in to that Spelunk from the editor and " + "save — Spelunk signs you in to the server, 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. /// /// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask. /// 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; /// 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; EditorSpelunkSignInNote = string.Empty; UpdateEditorSpelunkSignIn(); } /// 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, SignInThroughSpelunk = EditorSpelunkSignIn, }; } }