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,
};
}
}