using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Text; using DodoSSH.Api.Features.Events; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace DodoSSH.Api.Tests; /// /// The relay, over a real socket, through the real ticket, against a target the test owns. /// /// /// /// The network is the only thing replaced — see — and it is replaced /// under 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. /// /// /// 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. /// /// [Collection(ApiCollection.Name)] public sealed class RelayEndpointTests(ApiFixture fixture) { private static readonly DateTimeOffset Now = new(2026, 9, 28, 12, 0, 0, TimeSpan.Zero); /// How long a test waits for a frame. Generous, because it bounds a broken build, not a measurement. 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(); 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(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(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); } /// /// /// A session is authorised once, when its ticket is issued, and used to run on for as long as it carried /// bytes — removing somebody from a team left their shells into its machines open for up to the session's /// maximum lifetime. The announcement that tells their event socket to re-read its vaults now has the /// relay ask again too. /// /// /// Access is withdrawn here by deleting the vault rather than by a team removal, because what is under /// test is the relay hearing the announcement and asking the access model again — any way of making the /// answer change will do, and this one needs no team. The removal path announces through the same call; /// see TeamService.RemoveMemberAsync. /// /// [Fact] public async Task ASessionWhoseUserLosesAccess_IsClosedWithItsOwnCode() { using var target = LoopbackTarget.Echo(); var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.8.1"); var ticket = await IssueAsync(subject, hostId); using var timeout = Timeout(); using var socket = await ConnectAsync(ticket, timeout.Token); await RoundTripAsync(socket, "still here", timeout.Token); Guid userId; await using (var scope = fixture.CreateScope()) { var database = scope.ServiceProvider.GetRequiredService(); userId = (await database.Users.SingleAsync(u => u.Subject == subject, timeout.Token)).Id; var vaultId = (await database.Hosts.SingleAsync(h => h.Id == hostId, timeout.Token)).VaultId; var vault = await database.Vaults.SingleAsync(v => v.Id == vaultId, timeout.Token); vault.DeletedAtUtc = Now; await database.SaveChangesAsync(timeout.Token); scope.ServiceProvider.GetRequiredService().VaultAccessChanged(userId); } await ReadUntilCloseAsync(socket, timeout.Token); socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode); (await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason .ShouldBe(RelayCloseReason.AccessRevoked); } [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 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()).ShouldNotBeNull(); } private Task 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); } /// /// Reads until at least bytes have arrived, because message boundaries /// carry no meaning on the relay and an echo may come back split. /// private static async Task 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; } } } /// /// Waits for the audit row's second write, which lands after the close frame has gone out. /// private async Task WaitForClosedAuditAsync(Guid sessionId, CancellationToken cancellationToken) { while (true) { await using (var scope = fixture.CreateScope()) { var database = scope.ServiceProvider.GetRequiredService(); 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); /// A user, their personal vault, and one host in it — relayed when an address is given. 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(); 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 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; } /// A TCP server on loopback, standing in for the machine the relay reaches. private sealed class LoopbackTarget : IDisposable { private readonly TcpListener listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource stopping = new(); private LoopbackTarget(Func serve) { listener.Start(); _ = AcceptAsync(serve); } internal IPEndPoint Endpoint => (IPEndPoint)listener.LocalEndpoint; /// Writes back everything it reads, on every connection. 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); } }); /// Writes one line and closes, as a server does that refuses the client. 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 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. } } } }