Let a host marked for the relay actually be reached through it
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s

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.
This commit is contained in:
jaap-jan committed 2026-09-28 14:37:20 +02:00
1 parent 54bc142dce
commit b20f7cae04
51 files changed
+6455 -88

No files matched your search

+25 -1
View File
@@ -1,9 +1,11 @@
using System.Net.Http.Headers;
using System.Net.WebSockets;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Contracts;
using DodoSSH.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -70,7 +72,17 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
// The stub serves plaintext HTTP on a loopback port.
["DODOSSH_Oidc__RequireHttpsMetadata"] = "false",
["DODOSSH_Relay__Enabled"] = "false",
// On, so the relay's endpoints can be tested at all. Nothing dials a real network: the seam
// below answers every resolve and connect, and the address policy still judges what it says.
["DODOSSH_Relay__Enabled"] = "true",
["DODOSSH_Relay__WebSocketUrl"] = "ws://localhost" + RelayProtocol.ConnectPath,
// Small, so a test can reach the cap with two sockets rather than ten.
["DODOSSH_Relay__MaxConcurrentSessionsPerUser"] = "2",
// Short enough that a test can watch a session time out, long enough that no test which is
// moving bytes comes near it.
["DODOSSH_Relay__IdleTimeout"] = "00:00:03",
// Fixed so cursors stay valid for the lifetime of the test host.
["DODOSSH_Sync__CursorSigningKey"] = Convert.ToBase64String(new byte[32]),
@@ -80,8 +92,20 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
Environment.SetEnvironmentVariable(name, value);
}
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
}
/// <summary>
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
/// </summary>
/// <remarks>
/// Every address a test machine can reach by itself is loopback, which the relay refuses outright, so
/// a relay test needs a network that pretends. This one maps the addresses a test names onto loopback
/// listeners the test owns, and remembers what it was asked to dial.
/// </remarks>
internal FakeRelayNetwork RelayNetwork { get; } = new();
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
/// <remarks>
/// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
@@ -62,6 +62,13 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
// the handshake, unlike the relay's ticket. See ADR 0012.
"GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",
// The relay's two halves, and the split between them is the design. The ticket is Enrolled, as sync
// is, and every decision is made there with the full access model; the socket is anonymous because
// the ticket it carries is the whole of its authorization — single-use, thirty seconds, one host —
// and a constrained client cannot put a bearer token on an upgrade. See ADR 0004.
"POST /api/v1/relay/tickets name=IssueRelayTicket tags=Relay policies=Enrolled anon=False",
"GET /api/v1/relay/connect name=RelayConnect tags=Relay policies= anon=True",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
@@ -0,0 +1,68 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Features.Relay;
namespace DodoSSH.Api.Tests;
/// <summary>
/// A resolver and a dialler that reach only what a test has arranged.
/// </summary>
/// <remarks>
/// Names resolve to what <see cref="Resolve"/> says, and address literals to themselves, as the real
/// resolver does. An address dials the loopback endpoint <see cref="Route"/> mapped it to; anything
/// else is refused the way a closed port is, so a test that forgot to arrange a target sees the relay's
/// real "unreachable" path rather than a hang.
/// </remarks>
internal sealed class FakeRelayNetwork : IRelayNetwork
{
private readonly ConcurrentDictionary<string, IPAddress[]> names = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<IPEndPoint, IPEndPoint> routes = new();
private readonly ConcurrentQueue<IPEndPoint> dialled = new();
/// <summary>Every endpoint the relay asked to connect to, in order.</summary>
internal IReadOnlyCollection<IPEndPoint> Dialled => dialled;
internal void Resolve(string name, params IPAddress[] addresses) => names[name] = addresses;
internal void Route(IPEndPoint target, IPEndPoint loopback) => routes[target] = loopback;
/// <inheritdoc />
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken)
{
if (IPAddress.TryParse(host, out var literal))
{
return Task.FromResult<IPAddress[]>([literal]);
}
return names.TryGetValue(host, out var addresses)
? Task.FromResult(addresses)
: Task.FromException<IPAddress[]>(new SocketException((int)SocketError.HostNotFound));
}
/// <inheritdoc />
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
{
var endpoint = new IPEndPoint(address, port);
dialled.Enqueue(endpoint);
if (!routes.TryGetValue(endpoint, out var loopback))
{
throw new SocketException((int)SocketError.ConnectionRefused);
}
var client = new TcpClient();
try
{
await client.ConnectAsync(loopback, cancellationToken);
return client.GetStream();
}
catch
{
client.Dispose();
throw;
}
}
}
@@ -0,0 +1,125 @@
using System.Net;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Setup;
namespace DodoSSH.Api.Tests;
/// <summary>
/// ADR 0004 §3, address by address.
/// </summary>
/// <remarks>
/// The refused list is tested against the most permissive configuration this server accepts — every
/// network allowed, private ranges on — because "no configuration escape" is a claim about exactly that
/// case. A test that ran it only under the defaults would pass for a policy an operator could open up.
/// </remarks>
public sealed class RelayAddressPolicyTests
{
[Theory]
[InlineData("127.0.0.1")]
[InlineData("127.255.255.254")]
[InlineData("0.0.0.0")]
[InlineData("0.1.2.3")]
[InlineData("169.254.169.254")]
[InlineData("169.254.0.1")]
[InlineData("224.0.0.251")]
[InlineData("255.255.255.255")]
[InlineData("100.100.100.200")]
[InlineData("::")]
[InlineData("::1")]
[InlineData("fe80::1")]
[InlineData("ff02::1")]
[InlineData("fd00:ec2::254")]
// The same addresses, spelled as IPv6 — the bypass ADR 0004 names, and its NAT64 cousin.
[InlineData("::ffff:127.0.0.1")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("64:ff9b::a9fe:a9fe")]
[InlineData("64:ff9b::7f00:1")]
[InlineData("::127.0.0.1")]
public void TheRefusedList_HasNoConfigurationEscape(string address)
{
var wideOpen = new RelayOptions { AllowPrivateNetworks = true };
wideOpen.AllowedNetworks.Add("0.0.0.0/0");
wideOpen.AllowedNetworks.Add("::/0");
new RelayAddressPolicy(wideOpen).Permits(IPAddress.Parse(address)).ShouldBeFalse();
new RelayAddressPolicy(new RelayOptions()).Permits(IPAddress.Parse(address)).ShouldBeFalse();
}
[Fact]
public void ALinkLocalAddressWithAScope_IsStillLinkLocal()
{
var scoped = IPAddress.Parse("fe80::1%7");
new RelayAddressPolicy(new RelayOptions()).Permits(scoped).ShouldBeFalse();
}
[Theory]
[InlineData("10.0.0.5")]
[InlineData("172.16.4.4")]
[InlineData("192.168.1.10")]
[InlineData("100.101.102.103")] // A Tailnet address: carrier-grade NAT, beside the refused metadata one.
[InlineData("fd12:3456::1")]
[InlineData("::ffff:10.0.0.5")]
public void PrivateNetworks_ArePermittedByDefault_AndRefusedWhenSwitchedOff(string address)
{
new RelayAddressPolicy(new RelayOptions()).Permits(IPAddress.Parse(address)).ShouldBeTrue();
new RelayAddressPolicy(new RelayOptions { AllowPrivateNetworks = false })
.Permits(IPAddress.Parse(address))
.ShouldBeFalse();
}
[Theory]
[InlineData("203.0.113.10")]
[InlineData("2001:db8::10")]
public void PublicAddresses_ArePermitted(string address) =>
new RelayAddressPolicy(new RelayOptions { AllowPrivateNetworks = false })
.Permits(IPAddress.Parse(address))
.ShouldBeTrue();
[Fact]
public void AnAllowList_IsTheWholeAnswer_EvenWithPrivateNetworksOff()
{
var options = new RelayOptions { AllowPrivateNetworks = false };
options.AllowedNetworks.Add("10.20.0.0/16");
var policy = new RelayAddressPolicy(options);
policy.Permits(IPAddress.Parse("10.20.3.4")).ShouldBeTrue();
policy.Permits(IPAddress.Parse("10.21.3.4")).ShouldBeFalse();
policy.Permits(IPAddress.Parse("203.0.113.10")).ShouldBeFalse();
}
[Fact]
public void ADenial_IsAHoleInAnAllowance()
{
var options = new RelayOptions();
options.AllowedNetworks.Add("10.20.0.0/16");
options.DeniedNetworks.Add("10.20.5.0/24");
var policy = new RelayAddressPolicy(options);
policy.Permits(IPAddress.Parse("10.20.4.1")).ShouldBeTrue();
policy.Permits(IPAddress.Parse("10.20.5.1")).ShouldBeFalse();
}
[Theory]
[InlineData(25, false)]
[InlineData(465, false)]
[InlineData(587, false)]
[InlineData(0, false)]
[InlineData(65536, false)]
[InlineData(22, true)]
[InlineData(5432, true)]
public void Ports_RefuseSmtpAndNothingElse(int port, bool permitted) =>
RelayAddressPolicy.PermitsPort(port).ShouldBe(permitted);
[Theory]
[InlineData("10.0.0.0/8", true)]
[InlineData("fd00::/8", true)]
[InlineData("10.0.0.1/8", false)]
[InlineData("10.0.0.0", false)]
[InlineData("ten dot oh", false)]
public void NetworksAreReadStrictly(string entry, bool readable) =>
RelayAddressPolicy.AreNetworks([entry]).ShouldBe(readable);
}
@@ -0,0 +1,559 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DodoSSH.Api.Tests;
/// <summary>
/// The relay, over a real socket, through the real ticket, against a target the test owns.
/// </summary>
/// <remarks>
/// <para>
/// The network is the only thing replaced — see <see cref="FakeRelayNetwork"/> — and it is replaced
/// <em>under</em> the address policy, so every address these tests arrange is still judged by the real
/// rules. That is what lets the refusals here be meaningful: a loopback target is refused because the
/// policy refused it, not because the fake had nothing to route it to, and the tests say which by
/// asserting that nothing was dialled.
/// </para>
/// <para>
/// The refusals share one answer on purpose, and a test holds them to it. A distinct response for "that
/// name resolves internally, but no" is a scanner for the operator's DNS in the hands of anyone who can
/// edit a host.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class RelayEndpointTests(ApiFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 9, 28, 12, 0, 0, TimeSpan.Zero);
/// <summary>How long a test waits for a frame. Generous, because it bounds a broken build, not a measurement.</summary>
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
// ---- The ticket ----
[Fact]
public async Task ATicket_NamesTheSocketAndExpiresWithinThirtySeconds()
{
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.1", 22);
var ticket = await IssueAsync(subject, hostId);
ticket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
ticket.WebSocketUrl.AbsolutePath.ShouldBe(RelayProtocol.ConnectPath);
ticket.ExpiresAt.ShouldBeLessThanOrEqualTo(TimeProvider.System.GetUtcNow().AddSeconds(31));
// It has to stand in a Sec-WebSocket-Protocol list, so it must be an HTTP token: no padding, no
// slash, no plus — which is what base64url is for.
ticket.Ticket.ShouldAllBe(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_');
}
[Fact]
public async Task EveryTargetRefusal_IsTheSameAnswer()
{
var (subject, relayOff) = await SeedHostAsync(relay: null);
var (_, others) = await SeedRelayHostAsync("10.40.0.2", 22);
var (_, loopback) = await SeedRelayHostAsync("127.0.0.1", 22, subject);
var (_, smtp) = await SeedRelayHostAsync("10.40.0.3", 25, subject);
var (_, unresolvable) = await SeedRelayHostAsync("nowhere.invalid", 22, subject);
var client = fixture.CreateClientFor(subject);
var answers = new List<JsonProblem>();
foreach (var hostId in new[] { relayOff, others, loopback, smtp, unresolvable, Guid.CreateVersion7() })
{
var response = await client.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
answers.Add((await response.Content.ReadProblemAsync()).ShouldNotBeNull());
}
answers.ShouldAllBe(problem => problem.Code == ProblemCodes.RelayTargetRejected);
answers.Select(problem => problem.Detail).Distinct(StringComparer.Ordinal).Count().ShouldBe(1);
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")]
[InlineData("169.254.169.254")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("0.0.0.0")]
public async Task AnAddressThePolicyRefuses_IsNeverDialled(string address)
{
var (subject, hostId) = await SeedRelayHostAsync(address, 22);
var dialledBefore = fixture.RelayNetwork.Dialled.Count;
var response = await fixture.CreateClientFor(subject)
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
fixture.RelayNetwork.Dialled.Count.ShouldBe(dialledBefore);
}
[Fact]
public async Task ANameResolvingToABadAndAGoodAddress_IsDialledOnlyOnTheGoodOne()
{
// The rebinding shape: one answer the policy likes and one it must never reach. The ticket carries
// only what passed, so the socket has nothing else it could try.
using var target = LoopbackTarget.Echo();
var good = new IPEndPoint(IPAddress.Parse("10.40.1.1"), 22);
fixture.RelayNetwork.Resolve("mixed.test", IPAddress.Parse("169.254.169.254"), good.Address);
fixture.RelayNetwork.Route(good, target.Endpoint);
var (subject, hostId) = await SeedRelayHostAsync("mixed.test", 22);
using var timeout = Timeout();
using var socket = await ConnectAsync(await IssueAsync(subject, hostId), timeout.Token);
await RoundTripAsync(socket, "hello", timeout.Token);
fixture.RelayNetwork.Dialled.ShouldNotContain(endpoint => endpoint.Address.Equals(IPAddress.Parse("169.254.169.254")));
}
[Fact]
public async Task APortForward_IsRefused()
{
// Its port lives in ciphertext, so honouring one would mean taking the port from the client.
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.4", 22);
var response = await fixture.CreateClientFor(subject)
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, Guid.CreateVersion7()));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayTargetRejected);
}
// ---- The socket ----
[Fact]
public async Task BytesGoBothWays_AndTheSessionIsAudited()
{
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.2.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using (var socket = await ConnectAsync(ticket, timeout.Token))
{
socket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
await RoundTripAsync(socket, "SSH-2.0-test", timeout.Token);
await RoundTripAsync(socket, "a second message", timeout.Token);
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, timeout.Token);
}
var session = await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token);
session.HostId.ShouldBe(hostId);
session.TargetAddress.ShouldBe("10.40.2.1");
session.TargetPort.ShouldBe(22);
session.CloseReason.ShouldBe(RelayCloseReason.ClientClosed);
var sent = Encoding.UTF8.GetByteCount("SSH-2.0-test") + Encoding.UTF8.GetByteCount("a second message");
session.BytesClientToTarget.ShouldBe(sent);
session.BytesTargetToClient.ShouldBe(sent);
}
[Fact]
public async Task ATicket_IsSpentByItsFirstUse()
{
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.3.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var first = await ConnectAsync(ticket, timeout.Token);
var replay = await Should.ThrowAsync<InvalidOperationException>(ConnectAsync(ticket, timeout.Token));
replay.Message.ShouldContain("401");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-ticket")]
public async Task AnUpgradeWithoutAGenuineTicket_IsRefused(string? ticket)
{
var client = fixture.Server.CreateWebSocketClient();
client.SubProtocols.Add(RelayProtocol.SubProtocol);
if (ticket is not null)
{
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket);
}
var refused = await Should.ThrowAsync<InvalidOperationException>(client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
TestContext.Current.CancellationToken));
refused.Message.ShouldContain("401");
}
[Fact]
public async Task APlainGet_SaysItIsAWebSocket()
{
var response = await fixture.CreateClient().GetAsync(
new Uri(RelayProtocol.ConnectPath, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.MalformedRequest);
}
[Fact]
public async Task ATargetThatDoesNotAnswer_ClosesWithItsOwnCode()
{
// Routed nowhere, so the fake refuses the connect the way a closed port does. The user has to be
// told the *server* could not reach the machine, which is not what a direct failure means.
var (subject, hostId) = await SeedRelayHostAsync("10.40.4.1", 22);
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var socket = await ConnectAsync(ticket, timeout.Token);
await ReadUntilCloseAsync(socket, timeout.Token);
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TargetUnreachableCloseCode);
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
.ShouldBe(RelayCloseReason.TargetUnreachable);
}
[Fact]
public async Task ATargetThatHangsUp_EndsTheSession()
{
using var target = LoopbackTarget.SayThenHangUp("bye");
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.5.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var socket = await ConnectAsync(ticket, timeout.Token);
(await ReadTextAsync(socket, timeout.Token)).ShouldBe("bye");
await ReadUntilCloseAsync(socket, timeout.Token);
socket.CloseStatus.ShouldBe(WebSocketCloseStatus.NormalClosure);
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
.ShouldBe(RelayCloseReason.TargetClosed);
}
[Fact]
public async Task AnIdleSession_IsClosedWithTheIdleCode()
{
// The close code has to arrive, not merely the close. Cancelling a pending receive aborts a .NET
// WebSocket, and an aborted one sends nothing — so a relay that stopped its reader by cancelling
// it would pass a test that only checked the socket ended.
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.6.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var socket = await ConnectAsync(ticket, timeout.Token);
await ReadUntilCloseAsync(socket, timeout.Token);
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.IdleTimeoutCloseCode);
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
.ShouldBe(RelayCloseReason.IdleTimeout);
}
[Fact]
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
{
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.7.1");
// Three tickets while nothing is open, so the third reaches the socket and is refused there —
// the authoritative check, which the ticket endpoint's is only an early warning of.
var tickets = new[]
{
await IssueAsync(subject, hostId),
await IssueAsync(subject, hostId),
await IssueAsync(subject, hostId),
};
using var timeout = Timeout();
using var first = await ConnectAsync(tickets[0], timeout.Token);
using var second = await ConnectAsync(tickets[1], timeout.Token);
await RoundTripAsync(first, "one", timeout.Token);
await RoundTripAsync(second, "two", timeout.Token);
var early = await fixture.CreateClientFor(subject)
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
early.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
(await early.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayLimitReached);
using var third = await ConnectAsync(tickets[2], timeout.Token);
await ReadUntilCloseAsync(third, timeout.Token);
third.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TooManySessionsCloseCode);
}
// ---- Helpers ----
private static CancellationTokenSource Timeout()
{
var source = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
source.CancelAfter(FrameTimeout);
return source;
}
private static string NewSubject() => $"relay-{Guid.CreateVersion7():N}";
private async Task<RelayTicketResponse> IssueAsync(string subject, Guid hostId)
{
var response = await fixture.CreateClientFor(subject)
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
return (await response.Content.ReadContractAsync<RelayTicketResponse>()).ShouldNotBeNull();
}
private Task<WebSocket> ConnectAsync(RelayTicketResponse ticket, CancellationToken cancellationToken)
{
var client = fixture.Server.CreateWebSocketClient();
client.SubProtocols.Add(RelayProtocol.SubProtocol);
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket.Ticket);
return client.ConnectAsync(
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
cancellationToken);
}
private static async Task RoundTripAsync(WebSocket socket, string text, CancellationToken cancellationToken)
{
await socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Binary, true, cancellationToken);
(await ReadTextAsync(socket, cancellationToken, Encoding.UTF8.GetByteCount(text))).ShouldBe(text);
}
/// <summary>
/// Reads until at least <paramref name="expected"/> bytes have arrived, because message boundaries
/// carry no meaning on the relay and an echo may come back split.
/// </summary>
private static async Task<string> ReadTextAsync(
WebSocket socket,
CancellationToken cancellationToken,
int expected = 1)
{
var buffer = new byte[64 * 1024];
var received = 0;
while (received < expected)
{
var result = await socket.ReceiveAsync(buffer.AsMemory(received), cancellationToken);
result.MessageType.ShouldBe(WebSocketMessageType.Binary);
received += result.Count;
}
return Encoding.UTF8.GetString(buffer, 0, received);
}
private static async Task ReadUntilCloseAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[64 * 1024];
while (true)
{
var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return;
}
}
}
/// <summary>
/// Waits for the audit row's second write, which lands after the close frame has gone out.
/// </summary>
private async Task<RelaySession> WaitForClosedAuditAsync(Guid sessionId, CancellationToken cancellationToken)
{
while (true)
{
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var session = await database.RelaySessions
.AsNoTracking()
.SingleOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session?.EndedAtUtc is not null)
{
return session;
}
}
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
}
}
private async Task<(string Subject, Guid HostId)> SeedRoutedHostAsync(LoopbackTarget target, string address)
{
fixture.RelayNetwork.Route(new IPEndPoint(IPAddress.Parse(address), 22), target.Endpoint);
return await SeedRelayHostAsync(address, 22);
}
private Task<(string Subject, Guid HostId)> SeedRelayHostAsync(
string hostname,
int port,
string? subject = null) =>
SeedHostAsync((hostname, port), subject);
/// <summary>A user, their personal vault, and one host in it — relayed when an address is given.</summary>
private async Task<(string Subject, Guid HostId)> SeedHostAsync(
(string Hostname, int Port)? relay,
string? subject = null)
{
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var user = await FindOrAddUserAsync(database, subject);
var vault = await database.Vaults.FirstOrDefaultAsync(
v => v.OwnerUserId == user.Id,
TestContext.Current.CancellationToken)
?? database.Vaults.Add(new Vault
{
Id = Guid.CreateVersion7(),
Name = "Personal",
OwnerKind = VaultOwnerKind.Personal,
OwnerUserId = user.Id,
KeyGeneration = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
}).Entity;
var host = new SshHost
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
Payload = [1, 2, 3],
KeyGeneration = 1,
PayloadAadVersion = 1,
RelayEnabled = relay is not null,
Hostname = relay?.Hostname,
Port = relay?.Port,
Version = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
CreatedByUserId = user.Id,
UpdatedByUserId = user.Id,
};
database.Hosts.Add(host);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user.Subject, host.Id);
}
private async Task<UserAccount> FindOrAddUserAsync(DodoDbContext database, string? subject)
{
if (subject is not null
&& await database.Users.SingleOrDefaultAsync(
u => u.Subject == subject,
TestContext.Current.CancellationToken) is { } existing)
{
return existing;
}
var user = new UserAccount
{
Id = Guid.CreateVersion7(),
Issuer = fixture.IdentityProvider.Authority,
Subject = NewSubject(),
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
database.Users.Add(user);
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
return user;
}
/// <summary>A TCP server on loopback, standing in for the machine the relay reaches.</summary>
private sealed class LoopbackTarget : IDisposable
{
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource stopping = new();
private LoopbackTarget(Func<NetworkStream, CancellationToken, Task> serve)
{
listener.Start();
_ = AcceptAsync(serve);
}
internal IPEndPoint Endpoint => (IPEndPoint)listener.LocalEndpoint;
/// <summary>Writes back everything it reads, on every connection.</summary>
internal static LoopbackTarget Echo() => new(static async (stream, token) =>
{
var buffer = new byte[16 * 1024];
int read;
while ((read = await stream.ReadAsync(buffer, token)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, read), token);
}
});
/// <summary>Writes one line and closes, as a server does that refuses the client.</summary>
internal static LoopbackTarget SayThenHangUp(string text) => new(async (stream, token) =>
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), token));
public void Dispose()
{
stopping.Cancel();
listener.Stop();
stopping.Dispose();
}
private async Task AcceptAsync(Func<NetworkStream, CancellationToken, Task> serve)
{
try
{
while (!stopping.IsCancellationRequested)
{
var client = await listener.AcceptTcpClientAsync(stopping.Token);
_ = Task.Run(
async () =>
{
using (client)
{
try
{
await serve(client.GetStream(), stopping.Token);
}
catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException)
{
// The relay hung up, or the test is over.
}
}
},
CancellationToken.None);
}
}
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException)
{
// Stopped.
}
}
}
}
@@ -73,4 +73,20 @@ public sealed class TeamEnumAlignmentTests
((int)PermissionFlags.Read).ShouldBe(1 << 0);
((int)PermissionFlags.Write).ShouldBe(1 << 1);
}
/// <remarks>
/// Not a team enum, and here because this is where the wire-against-domain pairs live. The relay
/// writes its audit row's reason by value, so a renumbering would file every idle timeout as
/// something else.
/// </remarks>
[Fact]
public void EveryWireRelayCloseReason_HasADomainReasonWithTheSameValue()
{
Enum.GetValues<RelaySessionCloseReason>().Length.ShouldBe(Enum.GetValues<RelayCloseReason>().Length);
foreach (var reason in Enum.GetValues<RelaySessionCloseReason>())
{
((int)Enum.Parse<RelayCloseReason>(reason.ToString())).ShouldBe((int)reason);
}
}
}
@@ -222,6 +222,43 @@ public sealed class DodoSshApiClientTests : IDisposable
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
}
[Fact]
public async Task ARelayTicket_IsAskedForByHostAlone()
{
// The request names a host and nothing else. An address in it would be the open proxy ADR 0004
// exists to prevent, and the server would be right to ignore it — so it is not sent.
var hostId = Guid.CreateVersion7();
var issued = new RelayTicketResponse(
"dGlja2V0",
new Uri("wss://relay.example" + RelayProtocol.ConnectPath),
RelayProtocol.SubProtocol,
Guid.CreateVersion7(),
new DateTimeOffset(2026, 9, 28, 12, 0, 30, TimeSpan.Zero));
server.StubRelayTicket(issued);
var ticket = await Client().IssueRelayTicketAsync(hostId, TestContext.Current.CancellationToken);
ticket.ShouldBe(issued);
server.LastAuthorization(RelayProtocol.TicketsPath).ShouldBe("Bearer test-access-token");
var body = System.Text.Json.Nodes.JsonNode.Parse(server.LastBody(RelayProtocol.TicketsPath))!.AsObject();
body["hostId"]!.GetValue<Guid>().ShouldBe(hostId);
body.Where(property => property.Key is not ("hostId" or "portForwardId")).ShouldBeEmpty();
}
[Fact]
public async Task ARefusedRelayTicket_CarriesItsCode()
{
server.StubProblem(
RelayProtocol.TicketsPath, "POST", 403, ProblemCodes.RelayTargetRejected, "The relay will not connect.");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().IssueRelayTicketAsync(Guid.CreateVersion7(), TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.RelayTargetRejected);
}
private DodoSshApiClient Client() => new(http, new StubTokenProvider());
private static MeResponse UnenrolledMe() =>
@@ -51,6 +51,10 @@ internal sealed class StubServer : IDisposable
response, DodoSshJsonContext.Default.SyncPullResponse));
/// <summary>Stubs an RFC 9457 problem response.</summary>
internal void StubRelayTicket(RelayTicketResponse response) =>
StubJson(RelayProtocol.TicketsPath, "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.RelayTicketResponse));
internal void StubProblem(string path, string method, int statusCode, string code, string detail)
{
var problem = new JsonObject
@@ -68,6 +68,9 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// </remarks>
public IVaultEventStream Events => IdleVaultEventStream.Instance;
/// <inheritdoc />
public IServerRelay Relay => UnavailableServerRelay.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => new();
+15
View File
@@ -29,6 +29,16 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
/// <summary>Requests this factory was asked for, in order.</summary>
internal List<SshConnectionRequest> Requests { get; } = [];
/// <summary>
/// Runs against each shell request before it succeeds or fails, when set.
/// </summary>
/// <remarks>
/// How a test plays the part of SSH.NET arriving at a proxy: the relay tests use it to speak SOCKS to the
/// bridge the request names, so that what the relay then says reaches the bridge the way it would in a
/// real connect, before <see cref="Failure"/> is thrown.
/// </remarks>
internal Func<SshConnectionRequest, Task>? BeforeConnect { get; set; }
/// <summary>Every shell this factory has opened, in order.</summary>
/// <remarks>
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
@@ -93,6 +103,11 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
if (BeforeConnect is { } before)
{
await before(request).ConfigureAwait(false);
}
if (Failure is { } failure)
{
throw failure;
@@ -122,6 +122,12 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
/// <inheritdoc />
public IVaultEventStream Events => Notices;
/// <summary>
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
/// server that does not advertise the feature supplies.
/// </summary>
public IServerRelay Relay { get; set; } = UnavailableServerRelay.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => SyncOptions.Default;
@@ -0,0 +1,244 @@
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)));
}
}
}
@@ -0,0 +1,221 @@
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;
}
}