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.
222 lines
8.7 KiB
C#
222 lines
8.7 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Net.WebSockets;
|
|
|
|
namespace DodoSSH.Client.Ssh.Tests;
|
|
|
|
/// <summary>
|
|
/// The relay bridge: a real SSH connection, through a real SOCKS5 handshake, over a real WebSocket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The relay server is stood in for by the other end of an in-process WebSocket pair that pipes to the
|
|
/// fixture's sshd — which is the whole of what the server's socket does once a ticket is spent, so the
|
|
/// bytes on the wire here are the bytes a relayed session carries. What is not here is the ticket, which
|
|
/// is the server's half and is tested there.
|
|
/// </para>
|
|
/// <para>
|
|
/// Three of these are the security properties the bridge has to keep, from
|
|
/// <c>docs/reaching-a-host-you-cannot-dial.md</c>: it binds loopback only, it accepts once, and a
|
|
/// connection that goes away takes the bridge — and so the relay session — with it. The failure each
|
|
/// guards against is silent: an open proxy, or a relay session left running behind a closed tab.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Collection(SshCollection.Name)]
|
|
public sealed class RelayBridgeTests(SshServerFixture fixture)
|
|
{
|
|
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
[Fact]
|
|
public async Task TheBridge_ListensOnLoopbackOnly()
|
|
{
|
|
var (client, _) = await PairAsync();
|
|
await using var bridge = RelayBridge.Start(client);
|
|
|
|
// Asserted on the address rather than trusted to the constructor: a bridge on every interface
|
|
// would be a proxy into the relay for anyone on the network, and nothing would report it.
|
|
bridge.Endpoint.Address.ShouldBe(IPAddress.Loopback);
|
|
bridge.Proxy.Port.ShouldBe(bridge.Endpoint.Port);
|
|
bridge.Proxy.Lifetime.ShouldBeSameAs(bridge);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AShell_ThroughTheBridge_ReachesTheHost_AndGoesWithTheConnection()
|
|
{
|
|
var knownHosts = await TrustedStoreAsync();
|
|
var (client, server) = await PairAsync();
|
|
var relaying = RelayToSshdAsync(server);
|
|
|
|
var bridge = RelayBridge.Start(client);
|
|
|
|
// The host as the request names it, so the pin is the host's — the bridge is a SOCKS proxy, and
|
|
// the CONNECT carries this name through it rather than 127.0.0.1.
|
|
var connection = await new SshNetConnectionFactory(knownHosts)
|
|
.ConnectAsync(Request(bridge.Proxy), progress: null, Token);
|
|
|
|
connection.IsConnected.ShouldBeTrue();
|
|
connection.HostKey.Host.ShouldBe(fixture.Host);
|
|
|
|
// The connection owns the bridge. Closing the one is what closes the other, and the relay hears it
|
|
// as a close rather than as a socket that stopped answering.
|
|
await connection.DisposeAsync();
|
|
|
|
await bridge.Completion.WaitAsync(Settles, Token);
|
|
(await relaying.WaitAsync(Settles, Token)).ShouldBe(WebSocketMessageType.Close);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheBridge_AcceptsOnce()
|
|
{
|
|
var (client, _) = await PairAsync();
|
|
await using var bridge = RelayBridge.Start(client);
|
|
|
|
using var first = new TcpClient();
|
|
await first.ConnectAsync(bridge.Endpoint, Token);
|
|
|
|
// A greeting answered means the first connection has been accepted, which is when the listener
|
|
// stops — so what the second attempt meets is a closed port, not a race with the backlog.
|
|
var stream = first.GetStream();
|
|
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
|
var reply = new byte[2];
|
|
await stream.ReadExactlyAsync(reply, Token);
|
|
reply.ShouldBe(new byte[] { 5, 0 });
|
|
|
|
using var second = new TcpClient();
|
|
await Should.ThrowAsync<SocketException>(second.ConnectAsync(bridge.Endpoint, Token).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARelayThatClosesWithAReason_FailsTheConnect_AndKeepsTheReason()
|
|
{
|
|
// What the server does when it cannot reach the host: accept the socket, then close it with 4502.
|
|
var (client, server) = await PairAsync();
|
|
await server.CloseOutputAsync((WebSocketCloseStatus)4502, "unreachable", Token);
|
|
|
|
var bridge = RelayBridge.Start(client);
|
|
|
|
var failure = await Should.ThrowAsync<Exception>(async () =>
|
|
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
|
|
.ConnectAsync(Request(bridge.Proxy), progress: null, Token));
|
|
|
|
// A connection error, not a question about a host key nobody presented.
|
|
failure.ShouldNotBeOfType<SshHostKeyUnknownException>();
|
|
|
|
bridge.CloseStatus.ShouldBe((WebSocketCloseStatus)4502);
|
|
|
|
// And the failed connect disposed the bridge it was handed, rather than leaving it listening.
|
|
await bridge.Completion.WaitAsync(Settles, Token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NobodyArriving_ClosesTheRelay()
|
|
{
|
|
var (client, server) = await PairAsync();
|
|
await using var bridge = RelayBridge.Start(client, TimeSpan.FromMilliseconds(200));
|
|
|
|
await bridge.Completion.WaitAsync(Settles, Token);
|
|
|
|
var received = await server.ReceiveAsync(new byte[16], Token);
|
|
received.MessageType.ShouldBe(WebSocketMessageType.Close);
|
|
}
|
|
|
|
/// <summary>Two ends of one WebSocket, over a loopback TCP connection this test owns.</summary>
|
|
private static async Task<(WebSocket Client, WebSocket Server)> PairAsync()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
|
|
var dialling = new TcpClient();
|
|
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, Token);
|
|
var accepted = await listener.AcceptTcpClientAsync(Token);
|
|
await connecting;
|
|
|
|
var client = WebSocket.CreateFromStream(
|
|
dialling.GetStream(),
|
|
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero });
|
|
|
|
var server = WebSocket.CreateFromStream(
|
|
accepted.GetStream(),
|
|
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero });
|
|
|
|
return (client, server);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The relay's half: bytes from the socket to sshd and back, until one side ends.
|
|
/// </summary>
|
|
/// <returns>The type of the last message the relay received — a close, when the client said goodbye.</returns>
|
|
private Task<WebSocketMessageType> RelayToSshdAsync(WebSocket server) => Task.Run(async () =>
|
|
{
|
|
using var target = new TcpClient();
|
|
await target.ConnectAsync(fixture.Host, fixture.Port, Token);
|
|
var stream = target.GetStream();
|
|
|
|
var down = Task.Run(async () =>
|
|
{
|
|
var buffer = new byte[32 * 1024];
|
|
int read;
|
|
|
|
try
|
|
{
|
|
while ((read = await stream.ReadAsync(buffer, Token)) > 0)
|
|
{
|
|
await server.SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, true, Token);
|
|
}
|
|
}
|
|
catch (Exception exception) when (exception is IOException or WebSocketException or ObjectDisposedException)
|
|
{
|
|
// The test's connection is over.
|
|
}
|
|
});
|
|
|
|
var inbound = new byte[32 * 1024];
|
|
|
|
while (true)
|
|
{
|
|
var received = await server.ReceiveAsync(inbound.AsMemory(), Token);
|
|
|
|
if (received.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
await server.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, Token);
|
|
target.Close();
|
|
await down;
|
|
|
|
return received.MessageType;
|
|
}
|
|
|
|
await stream.WriteAsync(inbound.AsMemory(0, received.Count), Token);
|
|
}
|
|
});
|
|
|
|
private SshConnectionRequest Request(SshLoopbackProxy proxy) =>
|
|
new(
|
|
fixture.Host,
|
|
fixture.Port,
|
|
SshServerFixture.Username,
|
|
new SshPasswordCredential(SshServerFixture.Password),
|
|
ConnectTimeout: null,
|
|
proxy);
|
|
|
|
/// <summary>A store that already trusts the container's host key, learned directly.</summary>
|
|
private async Task<InMemoryKnownHostStore> TrustedStoreAsync()
|
|
{
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
|
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
await new SshNetConnectionFactory(knownHosts).ConnectAsync(
|
|
new SshConnectionRequest(
|
|
fixture.Host,
|
|
fixture.Port,
|
|
SshServerFixture.Username,
|
|
new SshPasswordCredential(SshServerFixture.Password)),
|
|
progress: null,
|
|
Token));
|
|
|
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
|
|
|
return knownHosts;
|
|
}
|
|
}
|