Files
DodoSSH/tests/DodoSSH.Api.Tests/EndpointInventoryTests.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

223 lines
14 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Every route the server exposes, and the authorization decision behind each one.
/// </summary>
/// <remarks>
/// <para>
/// The inventory test ADR 0002 asked for. Endpoints are registered from an explicit type list in
/// <c>Setup/EndpointRegistration.cs</c> rather than found by scanning, which trades one failure mode for
/// another: nothing can appear by accident, but a type left off the list is a route that quietly does
/// not exist, with no compile error and — without this test — no failure either. Asserting the whole set
/// rather than a subset is the point. A new endpoint cannot ship until somebody writes down, here, who
/// is allowed to call it.
/// </para>
/// <para>
/// It is also the only coverage <c>/api/v1/meta</c> and <c>/.well-known/dodossh-configuration</c> have
/// in <em>this</em> assembly — <c>DodoSSH.SystemTests</c> does fetch both anonymously, but that suite
/// needs a Docker daemon and several containers, so it is not what a developer runs before pushing.
/// Both are anonymous only because they say so, and a client has nothing to authenticate with at the
/// point it reads them, so a 401 on either is unrecoverable rather than merely wrong.
/// </para>
/// <para>
/// What this does <em>not</em> cover: <see cref="Describe"/> reads only <see cref="IAuthorizeData.Policy"/>,
/// so a role, claim, scope or authentication-scheme requirement could be added or removed without
/// failing here. Nothing uses those today; the day something does, this table needs a column.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EndpointInventoryTests(ApiFixture fixture)
{
private static readonly string[] Expected =
[
// Anonymous by necessity: discovery has to work before a token exists.
"GET /api/v1/meta name=GetMeta tags= policies= anon=True",
"GET /.well-known/dodossh-configuration name=GetDodoSshConfiguration tags= policies= anon=True",
// Authenticated, not Enrolled: these three are how a caller discovers it must enroll, does so,
// and — for revocation — withdraws a lost machine even if its enrollment state is in doubt.
"GET /api/v1/me name=GetMe tags=Identity policies=Authenticated anon=False",
"POST /api/v1/me/enrollment name=Enroll tags=Identity policies=Authenticated anon=False",
"DELETE /api/v1/me/devices/{deviceId:guid} name=RevokeDevice tags=Identity policies=Authenticated anon=False",
// The one endpoint in the /me area that needs a key bundle to already exist.
"POST /api/v1/me/devices name=RegisterDevice tags=Identity policies=Authenticated,Enrolled anon=False",
// Enrolled: a caller with no identity key can neither write ciphertext anyone can read nor read
// what is there.
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
// vaults, and a caller who could not read one has nothing to be told about. It appears here as
// an ordinary route because that is what it is until the upgrade: the bearer token authorises
// 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.
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
// The other half of the same decision: the directory says what a key is, this is how a client
// checks that claim against a chain the server cannot rewrite without every other client
// noticing. Nothing in it is secret.
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
// they have set a vault up must still be able to see the team they are now in.
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Administering a team you are already in, and none of it moves key material — so Authenticated
// for the same reason the membership routes above are. Two of the three are gated harder inside
// the handler than this table can show: archiving and handing the team over check for the owner
// rather than for an admin, because an admin the owner promoted must not be able to take the
// team from them. See TeamAccess.IsOwner.
"PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",
// There are deliberately no invitation routes here, and their absence is the assertion. A team
// was once joinable by an address the server had never seen, claimed at sign-in from the token's
// email claim; membership is now only ever granted to an account somebody named. If three
// /invitations entries reappear in this list, that property has been given back.
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
// sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints.
// Authenticated, alone among the vault routes: renaming touches no key material, so refusing
// somebody who has not published an identity key would be refusing them for an unrelated
// reason. Gated on Admin inside the handler, which this table cannot see.
"PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False",
// Authenticated for the same reason as the rename it sits beside — deleting withdraws grants
// rather than wrapping anything, so it needs no key of the caller's — and gated on Admin inside
// the handler, which this table cannot see. It refuses a personal vault there too.
"DELETE /api/v1/vaults/{vaultId:guid} name=DeleteVault tags=Vaults policies=Authenticated anon=False",
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Gated on Share inside the handler as the two writes above are, and additionally on holding the
// current key — which no policy could express, since it is a row in vault_key_grant.
"POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY.
"ANY /healthz/live name= tags= policies= anon=True",
"ANY /healthz/ready name= tags= policies= anon=True",
"ANY /healthz/startup name= tags= policies= anon=True",
// Not ours. FastEndpoints maps this one itself, in every environment, with no way to opt out; it
// answers with the server's whole endpoint-name-to-route table. It stays registered and is
// short-circuited to 404 instead — see RouteTableIsNotReachable below. Listed so that a version
// bump which adds a second hidden route, or renames this one out from under the block, fails
// here rather than in production.
"GET _test_url_cache_ name= tags= policies= anon=False",
];
[Fact]
public void TheServerExposesExactlyTheEndpointsWeMeantTo()
{
// Forces the pipeline to be built. FastEndpoints registers its routes when the application is
// built, not when its services are, so resolving the data sources from an unstarted host finds
// the health checks and nothing else.
using var client = fixture.CreateClient();
var actual = fixture.Services.GetServices<EndpointDataSource>()
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.Select(Describe)
.Order(StringComparer.Ordinal)
.ToArray();
actual.ShouldBe([.. Expected.Order(StringComparer.Ordinal)]);
}
/// <remarks>
/// <para>
/// Distinct from the inventory above, which only proves the route is registered. This proves it does
/// not answer.
/// </para>
/// <para>
/// Every spelling routing accepts is asserted, not just the canonical one. Routing matches literal
/// segments case-insensitively and tolerates a trailing slash, so a block that compares the request
/// path with <c>StringComparison.Ordinal</c> passes a canonical-spelling test while leaving the
/// listing fully readable at <c>/_TEST_URL_CACHE_</c>. That is not hypothetical — it is what the
/// first version of this guard did, and a single-spelling test is what let it look correct.
/// </para>
/// </remarks>
[Theory]
[InlineData("/_test_url_cache_")]
[InlineData("/_TEST_URL_CACHE_")]
[InlineData("/_Test_Url_Cache_")]
[InlineData("/_test_url_cache_/")]
public async Task RouteTableIsNotReachable(string path)
{
// Authenticated on purpose: the deny-by-default policy already stops an anonymous caller, so a
// 404 for one would prove nothing about whether the listing is exposed.
var client = fixture.CreateClientFor(subject: $"route-table-{Guid.CreateVersion7()}");
var response = await client.GetAsync(
new Uri(path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
// A 404 with the listing in the body would satisfy the status assertion on its own.
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldNotContain("Endpoint", Case.Insensitive);
}
private static string Describe(RouteEndpoint endpoint)
{
var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods;
var verbs = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY";
var name = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? string.Empty;
var tags = string.Join(",", endpoint.Metadata.GetMetadata<ITagsMetadata>()?.Tags ?? []);
// FastEndpoints adds a synthetic "epPolicy:<full type name>" beside the named policies, and
// including it would couple this table to endpoint class names. Dropping it is not free: that
// policy is also where FastEndpoints folds Roles(), Claims() and Permissions(), so those become
// invisible here. Nothing calls them — the two named policies carry the whole authorization
// decision — and the class remarks say so, rather than this filter pretending it discards nothing.
var policies = string.Join(
",",
endpoint.Metadata.OfType<IAuthorizeData>()
.Select(data => data.Policy)
.Where(policy => !string.IsNullOrEmpty(policy))
.Where(policy => !policy!.StartsWith("epPolicy:", StringComparison.Ordinal)));
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
return $"{verbs} {endpoint.RoutePattern.RawText} name={name} tags={tags} policies={policies} anon={anonymous}";
}
}