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