Files
DodoSSH/src/DodoSSH.Api/Setup/EndpointRegistration.cs
T
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

166 lines
7.9 KiB
C#

using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
using FastEndpoints;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The single, explicit list of every endpoint.
/// </summary>
/// <remarks>
/// FastEndpoints can find endpoints by scanning assemblies. It is deliberately not asked to. Explicit
/// registration gives predictable startup, survives trimming, and makes every route greppable from one
/// file — and a route that silently disappears because an assembly was not scanned is a genuinely nasty
/// failure. Scanning is worse here than in general: under <c>WebApplicationFactory</c> the scan reaches
/// the test assembly too, so an endpoint written in a test would be registered into the host under test.
/// The cost is one line per endpoint, and a line forgotten is a route missing with no compile error —
/// which is what <c>EndpointInventoryTests</c> exists to catch.
/// </remarks>
internal static class EndpointRegistration
{
/// <summary>
/// The route template FastEndpoints maps whether or not it is wanted.
/// </summary>
/// <remarks>
/// The registered template, not a request path: it is matched against what routing selected rather
/// than against anything a caller typed. FastEndpoints registers it without a leading slash.
/// </remarks>
private const string RouteTableTemplate = "_test_url_cache_";
internal static IServiceCollection AddDodoEndpoints(this IServiceCollection services) =>
services.AddFastEndpoints(new List<Type>
{
typeof(GetMetaEndpoint),
typeof(GetDodoSshConfigurationEndpoint),
typeof(GetMeEndpoint),
typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint),
typeof(LookupDirectoryEndpoint),
typeof(ReadKeyLogEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
typeof(RelayTicketEndpoint),
typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
typeof(ArchiveTeamEndpoint),
typeof(TransferTeamOwnershipEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(RenameVaultEndpoint),
typeof(DeleteVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
typeof(RekeyVaultEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — per-item ACLs
// Audit, Admin
});
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
/// <remarks>
/// <para>
/// <c>UseFastEndpoints</c> maps that route unconditionally, in every environment, carrying neither a
/// policy nor <c>AllowAnonymous</c>. It answers with every endpoint class name and route template the
/// server knows, to back a test helper this repository does not use, and there is no switch to turn
/// it off. The deny-by-default policy means a caller has to be authenticated to read it, which is not
/// the same as it being nobody's business.
/// </para>
/// <para>
/// This asks routing which endpoint it selected rather than comparing the request path, because the
/// path cannot be compared correctly by hand: routing matches literal segments case-insensitively
/// and tolerates a trailing slash, so <c>/_TEST_URL_CACHE_</c> and <c>/_test_url_cache_/</c> reach
/// the same endpoint as the canonical spelling. A hand-written comparison that agrees with the
/// matcher on Monday is a bypass on Tuesday. <c>WebApplication</c> inserts <c>UseRouting</c> ahead of
/// every middleware registered here, so the selected endpoint is already available; short-circuiting
/// before <c>UseEndpoints</c> is what keeps the answer independent of registration order.
/// </para>
/// </remarks>
internal static WebApplication BlockFastEndpointsRouteTable(this WebApplication app)
{
app.Use(static async (context, next) =>
{
if (context.GetEndpoint() is RouteEndpoint selected
&& string.Equals(
selected.RoutePattern.RawText?.Trim('/'),
RouteTableTemplate,
StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context).ConfigureAwait(false);
});
return app;
}
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.UseFastEndpoints(config =>
{
// Every route is written out in full in its own Configure(). A global prefix would rewrite
// all of them at once, and /.well-known/ is not under /api at all.
config.Endpoints.RoutePrefix = null;
// FastEndpoints serialises through its own copy of the host's JsonOptions, taken implicitly
// at this point. Applied again explicitly because that copy is undocumented, and Setup/Json.cs
// records what silent JSON drift on this exact surface already cost once.
DodoSshJsonContext.ApplyTo(config.Serializer.Options);
// A body that will not deserialise is answered here, before any handler runs. The default
// body is not a problem document despite the media type it claims, and it names the failing
// .NET type; Problems.ForBindingFailure says the same thing in the shape everything else uses.
config.Errors.ProducesMetadataType = null;
config.Errors.ResponseBuilder =
static (_, context, statusCode) => Problems.ForBindingFailure(context, statusCode);
// Applied to every endpoint rather than endpoint by endpoint: the default is the dangerous
// one, so the safe choice has to be the one nobody can forget.
config.Endpoints.Configurator =
static endpoint => endpoint.RequestBinder(typeof(BodyOnlyRequestBinder<>));
});
return app;
}
}
/// <summary>
/// Binds a request DTO from the JSON body and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// FastEndpoints' default binder deserialises the body and then writes route values, query-string
/// parameters, headers, claims and cookies over the top, matching DTO properties by name. Minimal APIs
/// bound a body parameter from the body alone, so leaving the default in place would silently widen
/// every request: <c>?cursor=…</c> would override the cursor in a pull body, and — the reason this is
/// not merely untidy — <c>?identityProviderToken=…</c> would let an ID token be supplied in a URL,
/// where proxies, browser history and access logs all keep copies of it. This API takes some trouble to
/// keep tokens out of logs; see <c>IncludeErrorDetails = false</c> in <see cref="Auth"/>.
/// </para>
/// <para>
/// Route values are still read, deliberately and one at a time, with <c>Route&lt;T&gt;("name")</c> in
/// the handlers that need one. That reads the route directly rather than through the DTO, so it is
/// unaffected by this.
/// </para>
/// </remarks>
/// <typeparam name="TRequest">The request DTO being bound.</typeparam>
internal sealed class BodyOnlyRequestBinder<TRequest>()
: RequestBinder<TRequest>(BindingSource.JsonBody)
where TRequest : notnull;