using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// How a host is reached through Spelunk: which Spelunk, and which of its servers. /// /// /// /// 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, as whoever is /// signed in to that Spelunk on this device. So the concession ADR 0004 argues for the relay's address is /// not made for this. /// /// /// No credential travels with the host. Each device signs in to a Spelunk once, through the browser, /// and keeps what that gives it to itself (see SpelunkSignIns): the same host opens on every device /// whose person is signed in to its Spelunk, as that person, in whichever of their organisations the server /// is in. Nothing about who may connect is synced. /// /// 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; } /// /// 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 this device's sign-in to Spelunk 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; } 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 sign-in's token 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 your sign-in 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; } }