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

+207
View File
@@ -0,0 +1,207 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Session;
/// <summary>
/// A <c>dodossh://spelunk/connect</c> link from Spelunk's web interface, read and checked.
/// </summary>
/// <remarks>
/// <para>
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
/// <c>dodossh://spelunk/connect?api=…&amp;server=…&amp;name=…&amp;host=…</c>. It says which Spelunk and which
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
/// supply somebody else's.
/// </para>
/// <para>
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
/// and the most it can ever do is select a host the user already saved, or open the editor on a new one
/// that the user then has to finish and save. It never connects to anything the user has not saved.
/// </para>
/// </remarks>
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
/// <param name="ServerId">The server's id in Spelunk.</param>
/// <param name="Name">What Spelunk calls the server, cleaned for display.</param>
/// <param name="Host">The server's public address as Spelunk knows it, or null when it gave none.</param>
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
{
/// <summary>The URL scheme DodoSSH registers.</summary>
public const string Scheme = "dodossh";
/// <summary>The longest link read at all. Anything longer is refused before it is parsed.</summary>
/// <remarks>
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
/// </remarks>
public const int MaxLength = 4096;
/// <summary>The longest name kept. Longer is cut, not refused: it is only a label.</summary>
public const int MaxNameLength = 100;
private const string Authority = "spelunk";
private const string Path = "/connect";
/// <summary>
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
/// from its id when there is none.
/// </summary>
/// <remarks>
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
/// stable for this server and unlike any other. The id is both; the address is also what the user would
/// recognise, and a key already trusted for a direct connection to that address is found again.
/// </remarks>
public string Hostname => Host ?? $"spelunk-{ServerId}";
/// <summary>Whether a string looks like it is meant to be one of these, before checking it is one.</summary>
/// <remarks>
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
/// checks from something that was never a link.
/// </remarks>
public static bool LooksLikeOne(string? text) =>
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
/// <summary>Reads a link, or refuses it.</summary>
/// <remarks>
/// <para>
/// Strict on everything that decides where anything goes: the scheme, <c>spelunk</c> as the authority
/// with no user, password or port, <c>/connect</c> as the path, no fragment, each known parameter at
/// most once, an API address <see cref="SpelunkRoute.TryNormalizeApiBase"/> accepts, and a server id
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
/// one without every existing DodoSSH ignoring its links outright.
/// </para>
/// <para>
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
/// cut. A missing name falls back to the host, then to a plain one.
/// </para>
/// </remarks>
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
{
link = null;
if (text is null
|| text.Length > MaxLength
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|| uri.UserInfo.Length > 0
|| !uri.IsDefaultPort
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|| uri.Fragment.Length > 0
|| !TryReadQuery(QueryOf(text), out var parameters)
|| !parameters.TryGetValue("api", out var api)
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|| !parameters.TryGetValue("server", out var server)
|| !Guid.TryParseExact(server, "D", out var serverId)
|| serverId == Guid.Empty)
{
return false;
}
string? host = null;
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
{
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
{
return false;
}
host = trimmed;
}
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
link = new SpelunkLink(apiBase, serverId, name, host);
return true;
}
/// <summary>The link written out again, in the one form <see cref="TryParse"/> reads back to the same value.</summary>
/// <remarks>
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
/// than whatever the browser handed over.
/// </remarks>
public override string ToString()
{
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
.Append(Uri.EscapeDataString(ApiBase))
.Append("&server=")
.Append(ServerId.ToString("D"))
.Append("&name=")
.Append(Uri.EscapeDataString(Name));
if (Host is not null)
{
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
}
return builder.ToString();
}
/// <summary>
/// Splits a query into its known parameters, refusing one that names any of them twice.
/// </summary>
/// <remarks>
/// By hand rather than through a web utility, because this project has none and because the rule that
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. <c>+</c> is a
/// space, as a browser's form encoding writes it.
/// </remarks>
private static bool TryReadQuery(string query, out Dictionary<string, string> parameters)
{
parameters = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var equals = pair.IndexOf('=', StringComparison.Ordinal);
var key = equals < 0 ? pair : pair[..equals];
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
if (key is not ("api" or "server" or "name" or "host"))
{
continue;
}
string decoded;
try
{
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
}
catch (UriFormatException)
{
return false;
}
if (!parameters.TryAdd(key, decoded))
{
return false;
}
}
return true;
}
/// <summary>The query exactly as it was written, rather than as <see cref="Uri"/> re-escapes it.</summary>
/// <remarks>
/// <see cref="Uri"/> has no rules for a scheme it does not know, and how much of the query it unescapes
/// for one is its business. Taking the text after the first <c>?</c> means every parameter is decoded
/// exactly once, here.
/// </remarks>
private static string QueryOf(string text) =>
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
private static string? Clean(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
}
}