Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link

A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
This commit is contained in:
jaap-jan committed 2026-09-28 21:25:04 +02:00
1 parent c8848ec1e3
commit 6e39807cc0
32 files changed
+3428 -85

No files matched your search

@@ -0,0 +1,61 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// The shell's half of a link from Spelunk: holding it until there is a vault to act on it.
/// </summary>
internal sealed partial class MainWindowViewModel
{
/// <summary>
/// Acts on a <c>dodossh://spelunk/connect</c> link now, or as soon as a vault is open.
/// </summary>
/// <remarks>
/// <para>
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
/// passed down the doorbell, or one the platform delivered to the running application. The link has
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never connects to a host the user has not
/// saved.
/// </para>
/// <para>
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
/// asked for the passphrase is the ordinary first half of this; losing the click at the second half would
/// make the button look broken. It is not kept across a restart.
/// </para>
/// </remarks>
internal void OpenLink(SpelunkLink link)
{
ArgumentNullException.ThrowIfNull(link);
pendingLink = link;
if (State is ShellState.Unlocked && Vault is not null)
{
DeliverPendingLink();
return;
}
if (State is ShellState.Locked)
{
StatusMessage = $"Unlock to open {link.Name} from Spelunk.";
}
}
/// <summary>Hands a waiting link to the open vault, on the Hosts screen where its answer appears.</summary>
private void DeliverPendingLink()
{
if (pendingLink is not { } link || Vault is not { } vault)
{
return;
}
pendingLink = null;
ShowScreen(ShellScreen.Hosts);
// Not awaited: a connect reports its own progress and failures on the vault's status line and in its
// tab, as a tap on the row does, and this is called from places that must not wait on a handshake.
_ = vault.OpenSpelunkLinkAsync(link);
}
}
@@ -360,6 +360,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly ISshReachabilityProbe? reachability;
/// <summary>What opens a Spelunk tunnel, handed to each vault this shell opens.</summary>
/// <remarks>
/// Held for the reason <see cref="reachability"/> is: the vault is built on unlock, not here. Null means
/// the real one, which is what both heads use; a test passes one so a Spelunk host's connect never leaves
/// the process.
/// </remarks>
private readonly ISpelunkTunnel? spelunk;
/// <summary>
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
/// </summary>
/// <remarks>
/// One, and the latest: a link is somebody pressing a button in Spelunk, and a second press while this
/// machine is still locked means the second server, not both. Delivered as soon as a vault opens, and
/// dropped if the application closes first — it is a request to do something now, not something to
/// remember.
/// </remarks>
private SpelunkLink? pendingLink;
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
private readonly Action<Action> post;
@@ -569,9 +588,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Action<Action>? post = null,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null,
ISshReachabilityProbe? reachability = null)
ISshReachabilityProbe? reachability = null,
ISpelunkTunnel? spelunk = null)
{
this.reachability = reachability;
this.spelunk = spelunk;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.paths = paths;
@@ -2886,7 +2907,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
copyToClipboard,
connectionLog,
visibility,
reachability);
reachability,
spelunk);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -2902,19 +2924,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the load, because the switches are built from the vaults the session admitted and the
// keyring is filled during it — before, and a machine with a team vault would come up with one
// switch until something else rebuilt them.
RebuildVaultToggles();
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
AfterTheFirstLoad(Vault);
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
@@ -2932,6 +2942,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync();
}
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
/// <remarks>
/// Split out of <see cref="AdoptAsync"/> for length rather than for reuse; the order is still the one that
/// method's remarks call load-bearing, and it is kept here as it was there.
/// </remarks>
private void AfterTheFirstLoad(VaultViewModel vault)
{
// After the load, because the switches are built from the vaults the session admitted and the
// keyring is filled during it — before, and a machine with a team vault would come up with one
// switch until something else rebuilt them.
RebuildVaultToggles();
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
// After the hosts are loaded, because what a link does depends on whether one of them is already
// the server it names.
DeliverPendingLink();
}
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
@@ -1816,7 +1816,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
/// <summary>
/// Opens the session on a host, through the relay when the host uses it, or says why not.
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
/// </summary>
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
private async Task<ISftpSession?> OpenHostSessionAsync(
@@ -1825,25 +1825,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
SshConnectionRequest request,
CancellationToken cancellationToken)
{
RelayRoute? relay = null;
IConnectionRoute? route = null;
try
{
// Through the relay for a host set to use it, exactly as a shell to it would be. The
// session owns the relay from here, on success and on failure alike.
relay = row.Host.RelayEnabled
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
: null;
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
// The session owns the route from here, on success and on failure alike.
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
return await sftp
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
.OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception)
when (relay?.Failure is { } failure
when (route?.Failure is { } failure
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
{
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
Status = failure;
return null;
}
@@ -0,0 +1,266 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
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>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]
private bool editorThroughSpelunk;
/// <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>The credentials the editor offers as the Spelunk API key.</summary>
/// <remarks>
/// 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 <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;
/// <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>
/// </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>
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
/// </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. A credential that has gone is refused in
/// words, exactly as a missing SSH credential is — see <see cref="TryBuildAuthentication"/>.
/// </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>
/// </remarks>
private async Task<IConnectionRoute> 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, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
/// </summary>
/// <returns>
/// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a
/// completed task otherwise.
/// </returns>
/// <remarks>
/// <para>
/// <b>A saved host for that server on that Spelunk is connected to</b>, exactly as a tap on its row
/// would — a password prompt if it asks for one, a host key question if it has not been seen.
/// </para>
/// <para>
/// <b>Otherwise the editor opens on a new host, and nothing more.</b> The link came from outside, and
/// anything on this machine can make one; it must never be able to make this application connect
/// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is
/// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and
/// the user chooses how to log in and which credential holds the key, and presses SAVE.
/// </para>
/// <para>
/// <b>The key is pre-chosen only when another host already sends it to the same Spelunk.</b> That is a
/// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a
/// Spelunk no host uses would be offering to send it to whatever address the link named.
/// </para>
/// </remarks>
internal Task OpenSpelunkLinkAsync(SpelunkLink link)
{
ArgumentNullException.ThrowIfNull(link);
if (Hosts.FirstOrDefault(row => row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true) is { } saved)
{
return ConnectToRowCommand.ExecuteAsync(saved);
}
// 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 Task.CompletedTask;
}
NewHost();
EditorLabel = link.Name;
EditorHostname = link.Hostname;
EditorPort = HostSecret.DefaultPort;
EditorThroughSpelunk = true;
EditorSpelunkApi = link.ApiBase;
EditorSpelunkServerId = link.ServerId.ToString();
var knownKey = Hosts
.Select(row => row.Host.Spelunk)
.FirstOrDefault(route => route is not null && string.Equals(route.ApiBase, link.ApiBase, StringComparison.Ordinal))
?.ApiKeyCredentialId;
BuildSpelunkKeyChoices(Credentials.Any(credential => credential.EntityId == knownKey) ? knownKey : null);
IsHostPaneOpen = true;
Status = EditorSelectedSpelunkKey is null
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in and the credential "
+ "holding your Spelunk API key, then save."
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in, then save.";
return Task.CompletedTask;
}
/// <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.
/// </remarks>
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
(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;
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>
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);
}
/// <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,
};
}
}
@@ -1469,7 +1469,8 @@ internal sealed partial class VaultViewModel(
Func<string, Task>? copyToClipboard = null,
ConnectionRecorder? connectionLog = null,
VaultVisibility? visibility = null,
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
ISshReachabilityProbe? reachability = null,
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
{
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
/// <remarks>
@@ -4385,6 +4386,7 @@ internal sealed partial class VaultViewModel(
// longer say what the user chose.
var authentication = EditorSelectedAuthentication;
var groupId = EditorSelectedGroup?.EntityId;
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
BuildTagChoices();
@@ -4403,6 +4405,8 @@ internal sealed partial class VaultViewModel(
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
grouped: EditorSelectedGroup?.EntityId is not null);
BuildSpelunkKeyChoices(spelunkKeyId);
}
if (IsEditingGroup)
@@ -7542,6 +7546,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
LoadEditorSpelunk(route: null);
editorTagIds = TagSet.Empty;
EditorNewTag = string.Empty;
BuildTagChoices();
@@ -7632,6 +7637,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
LoadEditorSpelunk(row.Host.Spelunk);
editorTagIds = row.Host.TagIds;
EditorNewTag = string.Empty;
BuildTagChoices();
@@ -10842,9 +10848,7 @@ internal sealed partial class VaultViewModel(
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
{
var bound = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
.Where(row => Uses(row, kind, entityId))
.Select(row => row.Label)
.ToArray();
@@ -11671,10 +11675,13 @@ internal sealed partial class VaultViewModel(
PhaseReporter reporter,
CancellationToken cancellationToken)
{
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
// probe does not take: it would dial the host directly — the route the user chose the relay because
// they cannot use — and report on that instead.
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
// A relay or Spelunk refusal already says exactly what happened, and a routed host is reached by a
// path the probe does not take: it would dial the host directly — the route the user chose the relay
// or Spelunk because they cannot use, or for a Spelunk host an address that exists only to name its
// key — and report on that instead.
if (reporter.Furthest != SshConnectionPhase.Reaching
|| exception is ServerRelayException or SpelunkTunnelException
|| target.Routed)
{
return exception.Message;
}
@@ -11840,11 +11847,11 @@ internal sealed partial class VaultViewModel(
/// this machine without touching the host.
/// </para>
/// <para>
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
/// Reached through <see cref="OpenRouteAsync"/>, which is what both transports call — file transfer
/// opens a connection of its own, and a relayed host needs the relay there too.
/// </para>
/// </remarks>
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
private async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
{
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
{
@@ -11857,17 +11864,18 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
/// Opens the session, through the relay or Spelunk when the host uses one, and puts the route's reason on
/// a failure.
/// </summary>
/// <remarks>
/// <para>
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
/// host key refusals are left alone: a key was presented, so the relay did its job.
/// A route that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
/// every lost transport — a sentence that reads exactly like the direct failure the route was chosen to
/// get around. When the route said why it closed, that sentence is the true one and replaces it. The
/// host key refusals are left alone: a key was presented, so the route did its job.
/// </para>
/// <para>
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
/// The route is disposed on every failure. The connection owns it once it is handed over and disposes
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
/// bridge listening until its own timeout; disposing twice is safe.
/// </para>
@@ -11880,28 +11888,30 @@ internal sealed partial class VaultViewModel(
{
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
// renderer's wait is the one step of a connect with no bound a user would recognise.
var relay = target.Relayed
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
var route = target.Row is { } row
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
: null;
try
{
return await workspace
.OpenSessionAsync(
relay is null ? request : request with { Proxy = relay.Proxy },
route is null ? request : request with { Proxy = route.Proxy },
TerminalSize.Default,
reporter,
cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception) when (relay is not null)
catch (Exception exception) when (route is not null)
{
await relay.DisposeAsync().ConfigureAwait(true);
await route.DisposeAsync().ConfigureAwait(true);
if (relay.Failure is { } failure
if (route.Failure is { } failure
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
{
throw new ServerRelayException(failure, exception);
throw route is SpelunkTunnelRoute
? new SpelunkTunnelException(failure, exception)
: new ServerRelayException(failure, exception);
}
throw;
@@ -12128,13 +12138,13 @@ internal sealed partial class VaultViewModel(
internal Guid? HostId => Row?.EntityId;
/// <summary>
/// Whether this is reached through the server relay rather than dialled.
/// Whether this is reached through the server relay or through Spelunk rather than dialled.
/// </summary>
/// <remarks>
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
/// reached with a key the item names, and a machine typed into the manual box has neither.
/// </remarks>
internal bool Relayed => Row?.Host.RelayEnabled is true;
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
}
/// <summary>
@@ -12343,6 +12353,10 @@ internal sealed partial class VaultViewModel(
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
// Null unless the box is ticked, whatever the fields below it still hold: unticking is how a host
// goes back to being dialled, and it must not keep a route the user just turned off.
Spelunk = BuildSpelunkRoute(),
// Only ever true or null, never false: the two would mean the same thing, and writing false would
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
//
@@ -13113,8 +13127,7 @@ internal sealed partial class VaultViewModel(
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
{
var names = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind && binding.EntityId == entityId)
.Where(row => Uses(row, kind, entityId))
.Select(row => row.Label)
.ToList();
@@ -13309,8 +13322,7 @@ internal sealed partial class VaultViewModel(
if (kind is { } resolvedKind && value is not null)
{
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
foreach (var row in Hosts.Where(row => Uses(row, resolvedKind, value.EntityId)))
{
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
}