using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Api;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Tests;
///
/// A host set to connect through the server relay, from the Connect button to the request that reaches SSH.
///
///
///
/// The relay here is the real — ticket, subprotocols and bridge — over an
/// in-process WebSocket pair, with only the API call and the far end of the socket stood in for. What these
/// hold is the promise the checkbox makes and used not to keep: ticking it changes how the host is reached,
/// and a relay that cannot be used is a stated failure rather than a quiet direct dial.
///
///
/// The bridge's own behaviour against a real sshd is RelayBridgeTests, and the server's is
/// RelayEndpointTests.
///
///
public sealed partial class ShellFlowTests
{
[Fact]
public async Task ARelayedHost_IsDialledThroughTheBridge_AndStillAsItself()
{
var tickets = new FakeRelayApi();
server.Relay = new ServerRelay(tickets, ConnectPairedAsync);
var vault = await ReadyToConnectRelayedAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
tickets.HostIds.ShouldHaveSingleItem().ShouldBe(vault.SelectedHost.ShouldNotBeNull().EntityId);
// Through a loopback proxy — and still named as the host, because the name is what the host key is
// pinned under. The bridge carries the name in its CONNECT; it does not replace it.
var request = ssh.Requests.ShouldHaveSingleItem();
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType();
request.Host.ShouldBe("db.internal");
request.Port.ShouldBe(22);
// The ticket travels as the second subprotocol and never as anything else.
offeredProtocols.ShouldBe([RelayProtocol.SubProtocol, RelayProtocol.TicketProtocolPrefix + FakeRelayApi.Ticket]);
shell.IsTerminalShowing.ShouldBeTrue();
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ARelayedHost_OnAServerWithoutARelay_IsRefusedWithoutDialling()
{
// The fake server's default: a server that does not advertise the feature.
var vault = await ReadyToConnectRelayedAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
// No fallback to the direct address. The user ticked the relay because that path does not work, or
// goes somewhere they do not mean.
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("does not offer one");
}
[Theory]
[InlineData(ProblemCodes.RelayTargetRejected, "refused to relay")]
[InlineData(ProblemCodes.RelayLimitReached, "as many relay sessions")]
[InlineData(ProblemCodes.RelayUnavailable, "does not offer one")]
public async Task ARefusedTicket_IsExplained_AndNothingIsDialled(string code, string explanation)
{
server.Relay = new ServerRelay(
new FakeRelayApi { Refusal = new DodoSshApiException(HttpStatusCode.Forbidden, code, "no") },
ConnectPairedAsync);
var vault = await ReadyToConnectRelayedAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain(explanation);
}
[Fact]
public async Task ARelayThatCouldNotReachTheHost_SaysSo_InsteadOfTheTransportError()
{
// What the server does when it cannot dial the host: accept the socket, then close it with 4502.
server.Relay = new ServerRelay(new FakeRelayApi(), async (url, protocols, cancellationToken) =>
{
var socket = await ConnectPairedAsync(url, protocols, cancellationToken);
await relayEnd!.CloseOutputAsync(
(WebSocketCloseStatus)RelayProtocol.TargetUnreachableCloseCode,
"unreachable",
cancellationToken);
return socket;
});
// SSH.NET arriving at the bridge and then losing its stream, which is how a relay that hung up looks
// from inside the connect.
ssh.BeforeConnect = request => ThroughSocksToEndOfStreamAsync(request.Proxy.ShouldNotBeNull().Port);
ssh.Failure = new InvalidOperationException("An established connection was aborted by the server.");
var vault = await ReadyToConnectRelayedAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("could not reach this host");
vault.Status.ShouldNotContain("aborted");
}
[Fact]
public async Task ARelayedHostsFileTransfer_GoesThroughTheRelayToo()
{
// Its own connection, so it needs its own relay — or the transfers screen would fail for exactly the
// hosts the relay exists for.
server.Relay = new ServerRelay(new FakeRelayApi(), ConnectPairedAsync);
var vault = await ReadyToConnectRelayedAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
var request = ssh.SftpRequests.ShouldHaveSingleItem();
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType();
await request.Proxy.Lifetime.DisposeAsync();
}
/// The protocols the last relay socket was opened offering.
private IReadOnlyList? offeredProtocols;
/// The far end of the last relay socket, which a test may close as the server would.
private WebSocket? relayEnd;
private async Task ReadyToConnectRelayedAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "behind-the-server";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// A relayed host needs a port of its own; the server stores it and dials it.
vault.EditorPort = 22;
vault.EditorRelayEnabled = true;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single(row => row.Host.RelayEnabled);
return vault;
}
/// Opens both ends of a WebSocket over loopback, and keeps the far one.
private async Task ConnectPairedAsync(
Uri url,
IReadOnlyList protocols,
CancellationToken cancellationToken)
{
url.ShouldBe(FakeRelayApi.SocketUrl);
offeredProtocols = protocols;
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var dialling = new TcpClient();
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken);
var accepted = await listener.AcceptTcpClientAsync(cancellationToken);
await connecting;
relayEnd = WebSocket.CreateFromStream(
accepted.GetStream(),
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero });
return WebSocket.CreateFromStream(
dialling.GetStream(),
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero });
}
///
/// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which
/// it does once the relay has closed, so the relay's reason is recorded by the time this returns.
///
private static async Task ThroughSocksToEndOfStreamAsync(int port)
{
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, Token);
var stream = client.GetStream();
var name = Encoding.ASCII.GetBytes("db.internal");
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
await stream.ReadExactlyAsync(new byte[2], Token);
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
await stream.ReadExactlyAsync(new byte[10], Token);
var rest = new byte[256];
while (await stream.ReadAsync(rest, Token) > 0)
{
}
}
/// The ticket half of the API, answering or refusing as a test arranges.
private sealed class FakeRelayApi : IRelayApi
{
internal const string Ticket = "dGlja2V0";
internal static readonly Uri SocketUrl = new("ws://relay.test" + RelayProtocol.ConnectPath);
internal List HostIds { get; } = [];
internal DodoSshApiException? Refusal { get; init; }
public Task IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken)
{
HostIds.Add(hostId);
return Refusal is { } refusal
? Task.FromException(refusal)
: Task.FromResult(new RelayTicketResponse(
Ticket,
SocketUrl,
RelayProtocol.SubProtocol,
Guid.CreateVersion7(),
TimeProvider.System.GetUtcNow().AddSeconds(30)));
}
}
}