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;
}
}