Public Access
The relay checkbox said it was not wired up, and the plan said the server half was built and shipped. It was not: there was ADR 0004, the contract records, RelayOptions and a CHECK constraint, and RelayOptions said in its own remarks that there was no endpoint and no dialler. So both halves are built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md. Server, in Features/Relay: - POST /api/v1/relay/tickets makes every decision: the host, Connect on its vault, the relay switched on, and the stored name resolved once and judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8, multicast and the metadata endpoints are refused with no configuration escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the operator's denials, allow-list and private-network switch. What passes goes into a Data Protection ticket, and nothing resolves the name again. Every target refusal is the same 403, so the endpoint cannot be used to map the operator's DNS; the real reason is logged. - GET /api/v1/relay/connect takes the ticket as a second subprotocol, checks its expiry against TimeProvider and spends it with a unique insert into relay_ticket_use before accepting the upgrade. After the upgrade a refusal is a close code: 4502 for a host the server could not reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain. - relay_session is the audit row, written from short-lived scopes. Client: - RelayBridge is a loopback SOCKS5 listener that accepts once and pipes to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so the connection disposes it on every way a session ends and a failed connect disposes it before throwing. - ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for the ticket, opens the socket, starts the bridge and turns every refusal into a sentence. - VaultViewModel and TransfersViewModel reach a relayed host through it, never fall back to dialling it directly, and replace SSH.NET's transport error with the relay's own reason when it gave one. Neither side ever cancels a WebSocket operation. Cancelling one aborts the socket, and an aborted socket sends no close frame, which is the only way the client learns the server could not reach the host. AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives. Three departures from ADR 0004, recorded in it: awaited copy loops rather than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket= fallback, and no device id in the ticket. Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a real login through the bridge, API client 30, contracts 25. Not run: the Android build (its change is copy only) and the Alpine layout oracle.
245 lines
9.6 KiB
C#
245 lines
9.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// A host set to connect through the server relay, from the Connect button to the request that reaches SSH.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The relay here is the real <see cref="ServerRelay"/> — 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The bridge's own behaviour against a real sshd is <c>RelayBridgeTests</c>, and the server's is
|
|
/// <c>RelayEndpointTests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
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<RelayBridge>();
|
|
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<RelayBridge>();
|
|
|
|
await request.Proxy.Lifetime.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>The protocols the last relay socket was opened offering.</summary>
|
|
private IReadOnlyList<string>? offeredProtocols;
|
|
|
|
/// <summary>The far end of the last relay socket, which a test may close as the server would.</summary>
|
|
private WebSocket? relayEnd;
|
|
|
|
private async Task<VaultViewModel> 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;
|
|
}
|
|
|
|
/// <summary>Opens both ends of a WebSocket over loopback, and keeps the far one.</summary>
|
|
private async Task<WebSocket> ConnectPairedAsync(
|
|
Uri url,
|
|
IReadOnlyList<string> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>The ticket half of the API, answering or refusing as a test arranges.</summary>
|
|
private sealed class FakeRelayApi : IRelayApi
|
|
{
|
|
internal const string Ticket = "dGlja2V0";
|
|
|
|
internal static readonly Uri SocketUrl = new("ws://relay.test" + RelayProtocol.ConnectPath);
|
|
|
|
internal List<Guid> HostIds { get; } = [];
|
|
|
|
internal DodoSshApiException? Refusal { get; init; }
|
|
|
|
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken)
|
|
{
|
|
HostIds.Add(hostId);
|
|
|
|
return Refusal is { } refusal
|
|
? Task.FromException<RelayTicketResponse>(refusal)
|
|
: Task.FromResult(new RelayTicketResponse(
|
|
Ticket,
|
|
SocketUrl,
|
|
RelayProtocol.SubProtocol,
|
|
Guid.CreateVersion7(),
|
|
TimeProvider.System.GetUtcNow().AddSeconds(30)));
|
|
}
|
|
}
|
|
}
|