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 the keychain item /// holding it, 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. /// That item is a , which also says the one address the key may go to, or — /// for a host set up before those existed — an ordinary credential whose password is the key. /// /// 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 item holding the Spelunk API key: a , or a credential whose /// password is the key. /// /// /// /// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so /// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose /// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage. /// /// /// 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 Spelunk signs the user in, so the host needs no SSH key, credential or password of its own. /// /// /// /// For each connection this client makes a throwaway key pair in memory and sends the public half with /// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on /// the machine whose only authorised key is that one, and says which account it logs in as. It is real /// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host, /// and the key is gone when the connection is. /// /// /// What the host says about logging in is then not asked. Its username, key, credential and /// typed-password choice — its own or a group's — are left alone rather than refused, because they are /// the host's answer for the day this is unticked, and refusing a host that carries them would make a /// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path /// never reads one for a host that has it. See VaultViewModel.TryBuildAuthentication. /// /// /// Written from , and only when true, so a route /// without it encodes exactly as it did before it existed. /// /// public bool SignInThroughSpelunk { 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 its Spelunk API key chosen."; 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; } }