using System.Net;
using System.Net.WebSockets;
using DodoSSH.Client.Api;
using DodoSSH.Client.Ssh;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session;
///
/// Reaches a host through the server, for a host this machine cannot dial itself.
///
///
/// Always present on , like : a server that does
/// not relay supplies , whose answer is a sentence saying so. That keeps
/// the connect path on one shape, and it means the refusal a user reads is the same whether the server said
/// no before being asked or when it was.
///
public interface IServerRelay
{
/// Whether this server advertises a relay at all.
bool IsOffered { get; }
///
/// Opens a relay to one host, ready for a connection to be made through it.
///
///
/// 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 relay could not be opened; the message says why.
Task OpenAsync(Guid hostId, CancellationToken cancellationToken);
}
///
/// An open relay, one connection long.
///
///
/// Handing to a connection hands the bridge over with it, so the connection's own
/// disposal is what closes the relay. is for a caller that ends up not
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
///
public sealed class RelayRoute : IConnectionRoute
{
private readonly RelayBridge bridge;
internal RelayRoute(RelayBridge bridge) => this.bridge = bridge;
/// The loopback proxy to connect through, which owns the relay from then on.
public SshLoopbackProxy Proxy => bridge.Proxy;
///
/// What the relay said when it closed, in words a user can act on; null when it has not closed, or
/// closed with nothing to say.
///
///
/// For a connect that failed. SSH.NET reports a relay that hung up as a stream that ended — which reads
/// exactly like the direct connection that failed and sent the user to the relay — so the relay's own
/// reason, when it gave one, replaces it.
///
public string? Failure => bridge.CloseStatus is { } status ? Describe((int)status) : null;
///
public ValueTask DisposeAsync() => bridge.DisposeAsync();
private static string? Describe(int code) => code switch
{
RelayProtocol.TargetUnreachableCloseCode =>
"The server relay could not reach this host. The server dials it on your behalf, so the "
+ "address has to be one the server can reach — check it from the server's side.",
RelayProtocol.TooManySessionsCloseCode =>
"The server relay has as many sessions open as it allows. Close one and try again.",
(int)WebSocketCloseStatus.EndpointUnavailable =>
"The server relay is restarting. Try again in a moment.",
RelayProtocol.IdleTimeoutCloseCode => "The server relay closed the connection for being idle.",
RelayProtocol.DurationLimitCloseCode =>
"The server relay closed the connection: it reached the longest session the server allows.",
_ => null,
};
}
/// A relay that could not be opened, with the reason in its message.
///
/// Its own type so the connect path can tell a relay refusal from a connection failure. The first has an
/// exact reason already, and running the reachability probe over it would dial the direct address — the one
/// the user chose the relay because they cannot reach — and append a finding about the wrong path.
///
public sealed class ServerRelayException : Exception
{
/// A refusal with nothing underneath it.
public ServerRelayException(string message)
: base(message)
{
}
/// A refusal and what caused it.
public ServerRelayException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// The relay of a server that does not offer one.
public sealed class UnavailableServerRelay : IServerRelay
{
private UnavailableServerRelay()
{
}
/// The one instance.
public static UnavailableServerRelay Instance { get; } = new();
///
public bool IsOffered => false;
///
public Task OpenAsync(Guid hostId, CancellationToken cancellationToken) =>
Task.FromException(new ServerRelayException(ServerRelay.NotOffered));
}
///
/// The relay of a server that offers one: a ticket from the API, a socket, and a bridge.
///
///
/// See ADR 0004 for the protocol and docs/reaching-a-host-you-cannot-dial.md for why the bridge
/// speaks SOCKS5. Every way this can fail is turned into a sentence here, because the codes are the
/// server's and the one place that should know what each means is the one that asked.
///
public sealed class ServerRelay : IServerRelay
{
/// What a user is told when the server they are signed in to does not relay.
internal const string NotOffered =
"This host is set to connect through the server relay, and this server does not offer one. "
+ "Untick “Connect through the server relay” in the host's settings to dial it directly.";
private readonly IRelayApi api;
private readonly Func, CancellationToken, Task> connect;
/// A relay reached through this API client.
public ServerRelay(IRelayApi api)
: this(api, DialAsync)
{
}
/// A relay whose socket is opened by , for tests.
internal ServerRelay(
IRelayApi api,
Func, CancellationToken, Task> connect)
{
this.api = api;
this.connect = connect;
}
///
public bool IsOffered => true;
///
public async Task OpenAsync(Guid hostId, CancellationToken cancellationToken)
{
RelayTicketResponse ticket;
try
{
ticket = await api.IssueRelayTicketAsync(hostId, cancellationToken).ConfigureAwait(false);
}
catch (DodoSshApiException exception)
{
throw new ServerRelayException(Describe(exception), exception);
}
catch (HttpRequestException exception)
{
throw new ServerRelayException(
$"The server could not be reached to open the relay: {exception.Message}",
exception);
}
// The ticket as a second subprotocol, which is how ADR 0004 carries it: a header every WebSocket
// client can set. The server echoes the first alone.
var socket = await connect(
ticket.WebSocketUrl,
[RelayProtocol.SubProtocol, RelayProtocol.TicketProtocolPrefix + ticket.Ticket],
cancellationToken)
.ConfigureAwait(false);
return new RelayRoute(RelayBridge.Start(socket));
}
private static string Describe(DodoSshApiException exception) => exception.Code switch
{
ProblemCodes.RelayUnavailable => NotOffered,
// Deliberately unspecific on the server's side, so that it cannot be used to map the operator's
// network. The likeliest causes from here are the two this machine can check.
ProblemCodes.RelayTargetRejected =>
"The server refused to relay to this host. Check that the change turning the relay on has "
+ "synchronised, and that the address is one the server is allowed to reach; the server's log "
+ "records which.",
ProblemCodes.RelayLimitReached =>
"You have as many relay sessions open as the server allows. Close one and try again.",
_ => $"The server would not open the relay: {exception.Message}",
};
/// Opens the relay socket, and translates a refused upgrade into its reason.
///
/// The status of a refused upgrade is collected, which a constrained client could not do and this one
/// can: a 401 is a ticket the relay would not take — spent, expired, or issued by a node whose key ring
/// this one does not share — and asking again is the remedy.
///
private static async Task DialAsync(
Uri url,
IReadOnlyList 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 ServerRelayException(
status == HttpStatusCode.Unauthorized
? "The server relay refused its ticket. Try connecting again."
: $"The server relay could not be reached: {exception.Message}",
exception);
}
catch
{
socket.Dispose();
throw;
}
if (!string.Equals(socket.SubProtocol, RelayProtocol.SubProtocol, StringComparison.Ordinal))
{
socket.Abort();
socket.Dispose();
throw new ServerRelayException(
"The server relay answered in a protocol this app does not speak. Update the app or the server.");
}
return socket;
}
}