Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs
T
laurensandClaude Opus 5.5 abd7e56bb5 Spelunk sign-in: end it with a refused DodoSSH resume, keep the token the check rotated, and send the token by the sign-in's own address
A DodoSSH resume the provider refuses now signs the device out of every Spelunk as well, as the check
before a Spelunk connection already did, so the README's "an account the provider no longer signs in is
signed out of DodoSSH and of every Spelunk" holds whichever finds it first. The forced refresh that check
makes is written down at once rather than at the next sync pass, so a crash in between cannot cost a
browser sign-in on the next launch. The tunnel is opened at the sign-in's own address rather than the
route's: the same string, but the token going where it came from is now said where it is sent.

Relaunches in the shell tests use the scripted Spelunk sign-in too, so none can open a browser or send a
sign-out anywhere real. Fixes a doc comment the account-refused handler had split, a README line still
pointing at the key picker, and the Android composition's comment on which redirect Spelunk allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:11 +02:00

477 lines
20 KiB
C#

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>
/// 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
/// throwaway 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, as whoever this device is signed in to that Spelunk as.
/// </summary>
/// <remarks>
/// <para>
/// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see
/// <see cref="SpelunkSignInForAsync"/>. 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.
/// </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 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;
}
}
/// <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 this device is signed in to that Spelunk, 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. 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.
/// </para>
/// <para>
/// <b>A link from a Spelunk this device is not signed in to opens the editor instead, and nothing
/// more.</b> 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.
/// </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 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);
}
/// <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 host a link makes: what the link says, signing in through Spelunk.</summary>
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,
},
};
/// <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)
{
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);
}
/// <summary>
/// Opens the editor on a new host from a link, for a link from a Spelunk this device is not signed in 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)
{
// 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);
}
}
/// <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.</summary>
/// <remarks>
/// 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.
/// </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;
/// <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;
EditorSpelunkSignInNote = string.Empty;
UpdateEditorSpelunkSignIn();
}
/// <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,
SignInThroughSpelunk = EditorSpelunkSignIn,
};
}
}