using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way. /// /// /// /// Spelunk manages servers through an agent that runs on each of them and keeps a connection open to /// Spelunk. A host carrying one of these is never dialled: a connection to it asks that Spelunk for a /// ticket, opens a WebSocket, and Spelunk hands the bytes to the agent, which connects to the SSH server on /// the machine's own loopback. That reaches a server with no public SSH port at all — which is the case /// this exists for — and it is why nothing here names an address to dial. /// /// /// Inside the host's encrypted payload, and nowhere else. Unlike the relay, nothing about this has to /// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only /// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this. /// /// /// The API key is referenced, not embedded. names an ordinary /// keychain credential whose password is the key, for the reason /// gives: one Spelunk key is the key for every server in its organisation, and a copy per host is twenty /// places to rotate it and one to forget. It also means the key is encrypted, synced, merged and shown in /// the keychain exactly as a password is, with no item type of its own to build and keep in step. /// /// public sealed record SpelunkRoute { /// The longest API address stored. /// /// Far beyond any real address, and there for the reason /// is: this is rendered by every device that decrypts the host, and an unbounded string is the shape a /// corrupted payload takes. /// public const int MaxApiBaseLength = 512; /// /// The Spelunk API's base address, as writes it — for example /// https://api.spelunk.example.com. /// /// /// A string in its normalised form rather than a , because this is compared: two hosts /// on the same Spelunk must say so in the same bytes, or a link from Spelunk would not find the host /// already saved for it and the merge would see a change nobody made. /// public required string ApiBase { get; init; } /// The server's id in Spelunk. public required Guid ServerId { get; init; } /// The keychain credential whose password is the Spelunk API key. /// /// Required rather than nullable. A route with no key can never be opened, and a host saved that way /// would refuse every connection with a sentence about something the editor let through. The reference /// can still dangle — the credential may be deleted on another machine — and that is refused where it /// is noticed, at connect, exactly as a missing SSH credential is. /// public required Guid ApiKeyCredentialId { get; init; } /// Whether this is the route to the given server on the given Spelunk. /// A normalised API base, as writes it. /// The server's id in Spelunk. public bool IsFor(string apiBase, Guid serverId) => ServerId == serverId && string.Equals(ApiBase, apiBase, StringComparison.Ordinal); /// Checks that this route can be stored and opened. public bool TryValidate([NotNullWhen(false)] out string? reason) { if (!TryNormalizeApiBase(ApiBase, out var normalized, out reason)) { return false; } // Stored normalised or not at all, for the comparison the remarks on ApiBase depend on. if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal)) { reason = "The Spelunk API address is not in its normal form."; return false; } if (ServerId == Guid.Empty) { reason = "A host reached through Spelunk needs the id of its Spelunk server."; return false; } if (ApiKeyCredentialId == Guid.Empty) { reason = "A host reached through Spelunk needs a credential holding its Spelunk API key."; return false; } reason = null; return true; } /// /// Reads a Spelunk API address as a user or a link gives it, into the one form it is stored in. /// /// /// /// HTTPS, or plain HTTP to this machine only. The API key travels in a header on every tunnel, so /// an address that sends it in clear across a network is refused rather than stored. A Spelunk running /// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the /// feature can be tried against one. /// /// /// No user name, password, query or fragment: none of them means anything to the API, and the first two /// are the classic way to make an address read as one host while naming another. What is left is the /// scheme, the authority and any path prefix, lower-cased where URLs are case-insensitive and without a /// trailing slash — so the same Spelunk typed twice is stored the same way twice. /// /// public static bool TryNormalizeApiBase( string? text, [NotNullWhen(true)] out string? apiBase, [NotNullWhen(false)] out string? reason) { apiBase = null; var trimmed = text?.Trim() ?? string.Empty; if (trimmed.Length == 0) { reason = "A host reached through Spelunk needs the address of the Spelunk API."; return false; } if (trimmed.Length > MaxApiBaseLength || !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http") || uri.HostNameType is UriHostNameType.Unknown || string.IsNullOrEmpty(uri.Host)) { reason = "The Spelunk API address must be a web address, like https://api.spelunk.example.com."; return false; } if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback) { reason = "The Spelunk API address must use https, because the API key is sent with every connection."; return false; } if (uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0) { reason = "The Spelunk API address cannot carry a user name, a query or a fragment."; return false; } apiBase = uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); reason = null; return true; } }