Public Access
Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
This commit is contained in:
1 parent
c8848ec1e3
commit
6e39807cc0
32 files changed
+3428
-85
No files matched your search
@@ -0,0 +1,37 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A way to a host that is not a dial: one connection long, handed to SSH.NET as a loopback proxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things answer to this — the server relay (<see cref="RelayRoute"/>) and a Spelunk tunnel
|
||||
/// (<see cref="SpelunkTunnelRoute"/>) — and both are the same shape underneath: a WebSocket carrying the
|
||||
/// SSH bytes, and a <see cref="RelayBridge"/> that turns it into an address SSH.NET can be given. What the
|
||||
/// connect path needs from either is exactly that address, the reason the far end gave if it hung up, and a
|
||||
/// way to let go of it when the connect never got as far as handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handing <see cref="Proxy"/> to a connection hands the bridge over with it, so the connection's own
|
||||
/// disposal is what closes the route. <see cref="IAsyncDisposable.DisposeAsync"/> is for a caller that ends
|
||||
/// up not connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IConnectionRoute : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The loopback proxy to connect through, which owns the route from then on.</summary>
|
||||
SshLoopbackProxy Proxy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// What the far end said when it closed, in words a user can act on; null when it has not closed, or
|
||||
/// closed with nothing to say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For a connect that failed. SSH.NET reports a route that hung up as a stream that ended, which reads
|
||||
/// exactly like the direct connection the route was chosen to get around — so the route's own reason,
|
||||
/// when it gave one, replaces it.
|
||||
/// </remarks>
|
||||
string? Failure { get; }
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public interface IServerRelay
|
||||
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
||||
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||
/// </remarks>
|
||||
public sealed class RelayRoute : IAsyncDisposable
|
||||
public sealed class RelayRoute : IConnectionRoute
|
||||
{
|
||||
private readonly RelayBridge bridge;
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>dodossh://spelunk/connect</c> link from Spelunk's web interface, read and checked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
||||
/// <c>dodossh://spelunk/connect?api=…&server=…&name=…&host=…</c>. It says which Spelunk and which
|
||||
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
|
||||
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
|
||||
/// supply somebody else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
||||
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
||||
/// and the most it can ever do is select a host the user already saved, or open the editor on a new one
|
||||
/// that the user then has to finish and save. It never connects to anything the user has not saved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||
/// <param name="ServerId">The server's id in Spelunk.</param>
|
||||
/// <param name="Name">What Spelunk calls the server, cleaned for display.</param>
|
||||
/// <param name="Host">The server's public address as Spelunk knows it, or null when it gave none.</param>
|
||||
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
|
||||
{
|
||||
/// <summary>The URL scheme DodoSSH registers.</summary>
|
||||
public const string Scheme = "dodossh";
|
||||
|
||||
/// <summary>The longest link read at all. Anything longer is refused before it is parsed.</summary>
|
||||
/// <remarks>
|
||||
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
|
||||
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
|
||||
/// </remarks>
|
||||
public const int MaxLength = 4096;
|
||||
|
||||
/// <summary>The longest name kept. Longer is cut, not refused: it is only a label.</summary>
|
||||
public const int MaxNameLength = 100;
|
||||
|
||||
private const string Authority = "spelunk";
|
||||
private const string Path = "/connect";
|
||||
|
||||
/// <summary>
|
||||
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
|
||||
/// from its id when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
|
||||
/// stable for this server and unlike any other. The id is both; the address is also what the user would
|
||||
/// recognise, and a key already trusted for a direct connection to that address is found again.
|
||||
/// </remarks>
|
||||
public string Hostname => Host ?? $"spelunk-{ServerId}";
|
||||
|
||||
/// <summary>Whether a string looks like it is meant to be one of these, before checking it is one.</summary>
|
||||
/// <remarks>
|
||||
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
|
||||
/// checks from something that was never a link.
|
||||
/// </remarks>
|
||||
public static bool LooksLikeOne(string? text) =>
|
||||
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reads a link, or refuses it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Strict on everything that decides where anything goes: the scheme, <c>spelunk</c> as the authority
|
||||
/// with no user, password or port, <c>/connect</c> as the path, no fragment, each known parameter at
|
||||
/// most once, an API address <see cref="SpelunkRoute.TryNormalizeApiBase"/> accepts, and a server id
|
||||
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
|
||||
/// one without every existing DodoSSH ignoring its links outright.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
|
||||
/// cut. A missing name falls back to the host, then to a plain one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
|
||||
{
|
||||
link = null;
|
||||
|
||||
if (text is null
|
||||
|| text.Length > MaxLength
|
||||
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|
||||
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|
||||
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|
||||
|| uri.UserInfo.Length > 0
|
||||
|| !uri.IsDefaultPort
|
||||
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|
||||
|| uri.Fragment.Length > 0
|
||||
|| !TryReadQuery(QueryOf(text), out var parameters)
|
||||
|| !parameters.TryGetValue("api", out var api)
|
||||
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|
||||
|| !parameters.TryGetValue("server", out var server)
|
||||
|| !Guid.TryParseExact(server, "D", out var serverId)
|
||||
|| serverId == Guid.Empty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? host = null;
|
||||
|
||||
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
|
||||
{
|
||||
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
host = trimmed;
|
||||
}
|
||||
|
||||
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
|
||||
|
||||
link = new SpelunkLink(apiBase, serverId, name, host);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The link written out again, in the one form <see cref="TryParse"/> reads back to the same value.</summary>
|
||||
/// <remarks>
|
||||
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
|
||||
/// than whatever the browser handed over.
|
||||
/// </remarks>
|
||||
public override string ToString()
|
||||
{
|
||||
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
|
||||
.Append(Uri.EscapeDataString(ApiBase))
|
||||
.Append("&server=")
|
||||
.Append(ServerId.ToString("D"))
|
||||
.Append("&name=")
|
||||
.Append(Uri.EscapeDataString(Name));
|
||||
|
||||
if (Host is not null)
|
||||
{
|
||||
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a query into its known parameters, refusing one that names any of them twice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By hand rather than through a web utility, because this project has none and because the rule that
|
||||
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. <c>+</c> is a
|
||||
/// space, as a browser's form encoding writes it.
|
||||
/// </remarks>
|
||||
private static bool TryReadQuery(string query, out Dictionary<string, string> parameters)
|
||||
{
|
||||
parameters = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equals = pair.IndexOf('=', StringComparison.Ordinal);
|
||||
var key = equals < 0 ? pair : pair[..equals];
|
||||
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
|
||||
|
||||
if (key is not ("api" or "server" or "name" or "host"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string decoded;
|
||||
|
||||
try
|
||||
{
|
||||
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!parameters.TryAdd(key, decoded))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The query exactly as it was written, rather than as <see cref="Uri"/> re-escapes it.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Uri"/> has no rules for a scheme it does not know, and how much of the query it unescapes
|
||||
/// for one is its business. Taking the text after the first <c>?</c> means every parameter is decoded
|
||||
/// exactly once, here.
|
||||
/// </remarks>
|
||||
private static string QueryOf(string text) =>
|
||||
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
|
||||
|
||||
private static string? Clean(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
|
||||
|
||||
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's tunnel: the routes, the subprotocol and the close codes, as Spelunk defines them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same two steps as the server relay's, and on purpose, because the bridge on this side is the relay's
|
||||
/// and it only has to be told where to go. An authenticated POST decides everything — whose key, which
|
||||
/// server, whether the key may — and answers with a ticket; the WebSocket that spends the ticket carries no
|
||||
/// authority of its own, and after the upgrade every binary message is simply the next bytes of the SSH
|
||||
/// stream, both ways, with nothing else in the socket at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is Spelunk's contract, not DodoSSH's, which is why it lives here and not in
|
||||
/// <c>DodoSSH.Contracts</c>: nothing on the DodoSSH server takes part in a Spelunk tunnel.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkTunnelProtocol
|
||||
{
|
||||
/// <summary>The only subprotocol this version speaks.</summary>
|
||||
public const string SubProtocol = "spelunk.tunnel.v1";
|
||||
|
||||
/// <summary>The prefix of the second subprotocol an upgrade offers, which carries the ticket.</summary>
|
||||
/// <remarks>
|
||||
/// A header rather than the query string, for the reason the relay gives: a URL is what proxies, access
|
||||
/// logs and traces keep. Spelunk echoes <see cref="SubProtocol"/> alone.
|
||||
/// </remarks>
|
||||
public const string TicketProtocolPrefix = "ticket.";
|
||||
|
||||
/// <summary>
|
||||
/// Close code for an agent that could not connect to the port on its own machine — or that is too old to
|
||||
/// know what a tunnel is, in which case the reason says <see cref="UnknownStreamKind"/>.
|
||||
/// </summary>
|
||||
public const int TargetUnreachableCloseCode = 4502;
|
||||
|
||||
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
||||
public const int AgentDisconnectedCloseCode = 4503;
|
||||
|
||||
/// <summary>What an agent that predates tunnels says, inside a <see cref="TargetUnreachableCloseCode"/>.</summary>
|
||||
internal const string UnknownStreamKind = "Unknown stream kind";
|
||||
|
||||
/// <summary>Where a ticket for one server is asked for.</summary>
|
||||
internal static string TicketsPath(Guid serverId) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/servers/{serverId}/tunnel-tickets");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens SSH connections to Spelunk-managed servers through Spelunk.
|
||||
/// </summary>
|
||||
public interface ISpelunkTunnel
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens a tunnel to one server, ready for a connection to be made through it.
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="port">The port the agent is to connect to on the server's own loopback.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
|
||||
/// use it and never ahead of one. The key is a parameter rather than a field of some request record
|
||||
/// because a record prints its fields, and a key has no business in anything that might be logged.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||
Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open Spelunk tunnel, one connection long.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same ownership as <see cref="RelayRoute"/>, because it is the same bridge: see
|
||||
/// <see cref="IConnectionRoute"/>.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnelRoute : IConnectionRoute
|
||||
{
|
||||
/// <summary>The longest stretch of Spelunk's own close reason that is repeated to the user.</summary>
|
||||
private const int MaxReasonLength = 200;
|
||||
|
||||
private readonly RelayBridge bridge;
|
||||
private readonly int port;
|
||||
|
||||
internal SpelunkTunnelRoute(RelayBridge bridge, int port)
|
||||
{
|
||||
this.bridge = bridge;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
|
||||
/// account of that is the accurate one.
|
||||
/// </remarks>
|
||||
public string? Failure =>
|
||||
bridge.CloseStatus is { } status ? Describe((int)status, bridge.CloseDescription, port) : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||
|
||||
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
|
||||
internal static string? Describe(int code, string? reason, int port) => code switch
|
||||
{
|
||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
||||
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
||||
"The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
||||
|
||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"The Spelunk agent could not connect to port {port} on the server itself")
|
||||
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||
+ ". Check that the SSH server is running and listening on that port.",
|
||||
|
||||
SpelunkTunnelProtocol.AgentDisconnectedCloseCode =>
|
||||
"The Spelunk agent on this server disconnected from Spelunk. It may be restarting; try again in a "
|
||||
+ "moment.",
|
||||
|
||||
(int)WebSocketCloseStatus.EndpointUnavailable => "Spelunk is restarting. Try again in a moment.",
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's own words, cut to one line of printable text, or null when there are none worth showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shown because they are specific — "connection refused" and "no route to host" are different problems
|
||||
/// on the server — and trimmed because they are another program's output going onto a status line.
|
||||
/// </remarks>
|
||||
internal static string? Readable(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(Math.Min(text.Length, MaxReasonLength));
|
||||
|
||||
foreach (var character in text.Trim())
|
||||
{
|
||||
if (builder.Length == MaxReasonLength)
|
||||
{
|
||||
builder.Append('…');
|
||||
break;
|
||||
}
|
||||
|
||||
builder.Append(char.IsControl(character) ? ' ' : character);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A Spelunk tunnel that could not be opened, with the reason in its message.</summary>
|
||||
/// <remarks>
|
||||
/// Its own type for the reason <see cref="ServerRelayException"/> is one: the connect path must be able to
|
||||
/// tell a refusal, which already says exactly what happened, from a connection failure it might explain by
|
||||
/// probing — and probing a Spelunk host would dial an address that exists only to name its key.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnelException : Exception
|
||||
{
|
||||
/// <summary>A refusal with nothing underneath it.</summary>
|
||||
public SpelunkTunnelException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A refusal and what caused it.</summary>
|
||||
public SpelunkTunnelException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A refusal Spelunk answered with a status.</summary>
|
||||
public SpelunkTunnelException(string message, HttpStatusCode statusCode)
|
||||
: base(message) =>
|
||||
StatusCode = statusCode;
|
||||
|
||||
/// <summary>What Spelunk answered with, or null where the refusal came from somewhere else.</summary>
|
||||
/// <remarks>Kept for tests and logs; the screen reads the message.</remarks>
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk tunnel: a ticket from the API, a socket, and the relay's bridge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every way this can fail is turned into a sentence here, as <see cref="ServerRelay"/> does for the relay,
|
||||
/// because the status codes are Spelunk's and the one place that should know what each means is the one
|
||||
/// that asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing from Spelunk's answer is trusted to go anywhere else.</b> The socket is opened on the API's
|
||||
/// own scheme, host and port, with only the path taken from the answer — and only a path that is one — so a
|
||||
/// ticket response cannot send this client, or the ticket, to a different machine.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
{
|
||||
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
|
||||
private const int MaxTicketLength = 2048;
|
||||
|
||||
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||
private const int MaxProblemBytes = 16 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// One transport for the process, as the platform advises for <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
|
||||
/// watching "Connecting…" should hear that Spelunk is not answering while they still care.
|
||||
/// </remarks>
|
||||
private static readonly HttpClient SharedHttp = new(
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect;
|
||||
|
||||
/// <summary>A tunnel over the process's shared transport, which is how the application builds one.</summary>
|
||||
public SpelunkTunnel()
|
||||
: this(SharedHttp, DialAsync)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A tunnel whose requests and socket are supplied, for tests.</summary>
|
||||
internal SpelunkTunnel(
|
||||
HttpClient http,
|
||||
Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect)
|
||||
{
|
||||
this.http = http;
|
||||
this.connect = connect;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||
{
|
||||
throw new SpelunkTunnelException(reason);
|
||||
}
|
||||
|
||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var socket = await connect(
|
||||
SocketUrl(normalized, ticket.Path!),
|
||||
[SpelunkTunnelProtocol.SubProtocol, SpelunkTunnelProtocol.TicketProtocolPrefix + ticket.Ticket],
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the tunnel socket is: the API's own origin, on the WebSocket scheme, at the path Spelunk named.
|
||||
/// </summary>
|
||||
internal static Uri SocketUrl(string apiBase, string path)
|
||||
{
|
||||
var secure = apiBase.StartsWith("https://", StringComparison.Ordinal);
|
||||
var rest = apiBase[(secure ? "https".Length : "http".Length)..];
|
||||
|
||||
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the stored key and refuses one that could not be a header value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
|
||||
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
|
||||
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
||||
/// </remarks>
|
||||
private static string Sanitise(string apiKey)
|
||||
{
|
||||
var trimmed = apiKey?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
|
||||
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
||||
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||
new SpelunkTicketRequestDocument { Port = port },
|
||||
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"Spelunk could not be reached at {apiBase}: {exception.Message}",
|
||||
exception);
|
||||
}
|
||||
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"Spelunk at {apiBase} did not answer in time. Try again in a moment.",
|
||||
exception);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var problem = await ReadProblemAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
|
||||
}
|
||||
|
||||
return await ReadTicketAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
|
||||
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SpelunkTicketDocument? ticket;
|
||||
|
||||
try
|
||||
{
|
||||
ticket = await response.Content
|
||||
.ReadFromJsonAsync(SpelunkTunnelJsonContext.Default.SpelunkTicketDocument, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new SpelunkTunnelException(Unintelligible, exception);
|
||||
}
|
||||
|
||||
// The ticket goes into a header as part of a subprotocol name, which may hold only token characters —
|
||||
// base64url is all of those, and anything else would fail inside the WebSocket client instead. The
|
||||
// path is appended to the API's own origin, so it has to be a path: "//elsewhere" or "@elsewhere"
|
||||
// would turn it into a different host.
|
||||
if (ticket is not { Ticket: { Length: > 0 and <= MaxTicketLength } value, Path: { } path }
|
||||
|| !value.All(IsBase64UrlCharacter)
|
||||
|| !path.StartsWith('/')
|
||||
|| path.StartsWith("//", StringComparison.Ordinal)
|
||||
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| (ticket.Subprotocol is { } named
|
||||
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new SpelunkTunnelException(Unintelligible);
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private const string Unintelligible =
|
||||
"Spelunk answered the tunnel request in a way this app does not understand. Update DodoSSH, or check "
|
||||
+ "that the API address is Spelunk's.";
|
||||
|
||||
private static bool IsBase64UrlCharacter(char character) =>
|
||||
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
|
||||
|
||||
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
|
||||
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
||||
{
|
||||
var detail = SpelunkTunnelRoute.Readable(problem?.Detail);
|
||||
|
||||
return status switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized =>
|
||||
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
||||
+ "Spelunk and paste it into the credential this host uses for it.",
|
||||
|
||||
// The detail is the useful half here: it says which role the key has and which it needs.
|
||||
HttpStatusCode.Forbidden =>
|
||||
"The Spelunk API key is not allowed to open a terminal on this server"
|
||||
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
|
||||
|
||||
HttpStatusCode.NotFound =>
|
||||
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
|
||||
+ "the key belongs to a different organisation.",
|
||||
|
||||
HttpStatusCode.ServiceUnavailable =>
|
||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
||||
+ "agent is running on the server, then try again.",
|
||||
|
||||
_ => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Spelunk would not open the tunnel (HTTP {(int)status})")
|
||||
+ ((detail ?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said ? $": {said}" : "."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
|
||||
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return body.Length is 0 or > MaxProblemBytes
|
||||
? null
|
||||
: JsonSerializer.Deserialize(body, SpelunkTunnelJsonContext.Default.SpelunkProblemDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens the tunnel socket, and translates a refused upgrade into its reason.</summary>
|
||||
/// <remarks>
|
||||
/// The status of a refused upgrade is collected, as the relay's dialler does. A 401 is a ticket Spelunk
|
||||
/// would not take — spent, or expired on the way — and asking again is the remedy; a 503 is the agent
|
||||
/// going away in the moment between the ticket and the socket.
|
||||
/// </remarks>
|
||||
private static async Task<WebSocket> DialAsync(
|
||||
Uri url,
|
||||
IReadOnlyList<string> protocols,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new ClientWebSocket();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var protocol in protocols)
|
||||
{
|
||||
socket.Options.AddSubProtocol(protocol);
|
||||
}
|
||||
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
|
||||
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
var status = socket.HttpStatusCode;
|
||||
socket.Dispose();
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
status switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => "Spelunk refused the tunnel ticket. Try connecting again.",
|
||||
HttpStatusCode.ServiceUnavailable => Describe(status, problem: null),
|
||||
_ => $"The Spelunk tunnel could not be opened: {exception.Message}",
|
||||
},
|
||||
exception);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!string.Equals(socket.SubProtocol, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||
{
|
||||
socket.Abort();
|
||||
socket.Dispose();
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
"Spelunk answered the tunnel in a protocol this app does not speak. Update DodoSSH or Spelunk.");
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The body of a ticket request.</summary>
|
||||
internal sealed class SpelunkTicketRequestDocument
|
||||
{
|
||||
public int Port { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||
internal sealed class SpelunkTicketDocument
|
||||
{
|
||||
public string? Ticket { get; set; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public string? Path { get; set; }
|
||||
|
||||
public string? Subprotocol { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The two fields of an RFC 7807 problem this client repeats.</summary>
|
||||
internal sealed class SpelunkProblemDocument
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
||||
[JsonSerializable(typeof(SpelunkTicketDocument))]
|
||||
[JsonSerializable(typeof(SpelunkProblemDocument))]
|
||||
internal sealed partial class SpelunkTunnelJsonContext : JsonSerializerContext;
|
||||
Reference in new issue
Block a user