using System.Diagnostics.CodeAnalysis; using System.Text; using DodoSSH.Client.Domain; namespace DodoSSH.Client.Session; /// /// A dodossh://spelunk/connect link from Spelunk's web interface, read and checked. /// /// /// /// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page: /// dodossh://spelunk/connect?api=…&server=…&name=…&host=…. It says which Spelunk and which /// server, and what to call it. It never carries a credential, 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. /// /// /// Anything on the machine can hand this application a link — 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 VaultViewModel.OpenSpelunkLinkAsync. /// /// /// The Spelunk API's base address, normalised as stores it. /// The server's id in Spelunk. /// What Spelunk calls the server, cleaned for display. /// The server's public address as Spelunk knows it, or null when it gave none. public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host) { /// The URL scheme DodoSSH registers. public const string Scheme = "dodossh"; /// The longest link read at all. Anything longer is refused before it is parsed. /// /// 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. /// public const int MaxLength = 4096; /// The longest name kept. Longer is cut, not refused: it is only a label. public const int MaxNameLength = 100; private const string Authority = "spelunk"; private const string Path = "/connect"; /// /// 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. /// /// /// 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. /// public string Hostname => Host ?? $"spelunk-{ServerId}"; /// Whether a string looks like it is meant to be one of these, before checking it is one. /// /// 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. /// public static bool LooksLikeOne(string? text) => text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase); /// Reads a link, or refuses it. /// /// /// Strict on everything that decides where anything goes: the scheme, spelunk as the authority /// with no user, password or port, /connect as the path, no fragment, each known parameter at /// most once, an API address 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. /// /// /// 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. /// /// 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; } /// The link written out again, in the one form reads back to the same value. /// /// What a second launch sends the running instance, so the instance parses a link this build wrote rather /// than whatever the browser handed over. /// 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(); } /// /// Splits a query into its known parameters, refusing one that names any of them twice. /// /// /// 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. + is a /// space, as a browser's form encoding writes it. /// private static bool TryReadQuery(string query, out Dictionary parameters) { parameters = new Dictionary(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; } /// The query exactly as it was written, rather than as re-escapes it. /// /// 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 ? means every parameter is decoded /// exactly once, here. /// 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; } }