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