Files
DodoSSH/src/DodoSSH.Client.Session/SpelunkLink.cs
T
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
2026-09-28 22:53:51 +02:00

210 lines
9.0 KiB
C#

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 connect to a host a link made before, add one for another server on a
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
/// </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;
}
}