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