Files
DodoSSH/tests/DodoSSH.Client.Api.Tests/StubServer.cs
jaap-jan b20f7cae04
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
Let a host marked for the relay actually be reached through it
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.
2026-09-28 14:37:20 +02:00

138 lines
5.4 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Contracts;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace DodoSSH.Client.Api.Tests;
/// <summary>A stand-in DodoSSH server.</summary>
/// <remarks>
/// Responses are built from the real contract types and the real source-generated serialiser, so the
/// client is reading exactly the shape a live server produces rather than hand-written JSON that
/// happens to satisfy it.
/// </remarks>
internal sealed class StubServer : IDisposable
{
/// <remarks>
/// Bound to loopback explicitly. WireMock's default listens on every interface, which makes Windows
/// Firewall prompt the first time each test executable runs — and the prompt is per binary path, so a
/// new worktree or configuration asks again. Port 0 still picks a free port and reports it on
/// <see cref="WireMockServer.Url"/>.
/// </remarks>
private readonly WireMockServer server = WireMockServer.Start(
new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
/// <summary>Requests received, so tests can assert on what was sent.</summary>
internal IReadOnlyList<WireMock.Logging.ILogEntry> Requests => server.LogEntries.ToList();
internal void StubMe(MeResponse response) =>
StubJson("/api/v1/me", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MeResponse));
internal void StubEnrollment(EnrollmentResponse response) =>
StubJson("/api/v1/me/enrollment", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.EnrollmentResponse));
internal void StubMeta(MetaResponse response) =>
StubJson("/api/v1/meta", "GET", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.MetaResponse));
internal void StubPush(Guid vaultId, SyncPushResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/push", "POST", 200, JsonSerializer.Serialize(
response, DodoSshJsonContext.Default.SyncPushResponse));
internal void StubPull(Guid vaultId, SyncPullResponse response) =>
StubJson($"/api/v1/vaults/{vaultId}/sync/pull", "POST", 200, JsonSerializer.Serialize(
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
{
["type"] = ProblemCodes.TypeBaseUri + code,
["title"] = "Request failed",
["status"] = statusCode,
["detail"] = detail,
["code"] = code,
};
StubJson(path, method, statusCode, problem.ToJsonString());
}
/// <summary>Stubs a non-JSON error, as a reverse proxy in front of a dead server would return.</summary>
internal void StubGatewayError(string path, string method) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(502)
.WithHeader("Content-Type", "text/html")
.WithBody("<html><body><h1>502 Bad Gateway</h1></body></html>"));
/// <summary>The body of the last request to a path.</summary>
internal string LastBody(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
throw new InvalidOperationException($"Nothing was sent to {path}.");
}
return entries[^1].RequestMessage?.Body ?? string.Empty;
}
/// <summary>The Authorization header of the last request to a path.</summary>
internal string? LastAuthorization(string path)
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
return null;
}
var headers = entries[^1].RequestMessage?.Headers;
return headers is not null && headers.TryGetValue("Authorization", out var values)
? values.FirstOrDefault()
: null;
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
}
private void StubJson(string path, string method, int statusCode, string body) =>
server
.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create()
.WithStatusCode(statusCode)
.WithHeader("Content-Type", "application/json")
.WithBody(body));
}
/// <summary>Hands out a fixed token, so tests can assert it reached the wire.</summary>
internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider
{
/// <inheritdoc />
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(token);
}