Files
DodoSSH/src/DodoSSH.Contracts/ProblemCodes.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

178 lines
8.7 KiB
C#

namespace DodoSSH.Contracts;
/// <summary>
/// Stable machine-readable error codes returned in the <c>code</c> extension of an
/// RFC 9457 ProblemDetails response.
/// </summary>
/// <remarks>
/// These live in Contracts so the client switches on constants rather than parsing prose.
/// The values are part of the public contract: add freely, never rename or repurpose.
/// </remarks>
public static class ProblemCodes
{
/// <summary>The base URI that every problem <c>type</c> is formed under.</summary>
public const string TypeBaseUri = "https://dodossh.dev/problems/";
/// <summary>A push operation's <c>expectedVersion</c> did not match the stored row.</summary>
public const string VaultConflict = "vault-conflict";
/// <summary>The caller is authenticated but lacks the required permission.</summary>
public const string Forbidden = "forbidden";
/// <summary>The sync cursor was malformed, or failed its integrity tag.</summary>
public const string InvalidCursor = "invalid-cursor";
/// <summary>An <c>Idempotency-Key</c> was reused with a different request body.</summary>
public const string IdempotencyKeyReuse = "idempotency-key-reuse";
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
public const string EnrollmentRequired = "enrollment-required";
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
public const string AlreadyEnrolled = "already-enrolled";
/// <summary>
/// The enrollment request was structurally invalid: a bad key length, mismatched KDF
/// parameters, a statement that does not describe the caller, or a vault id already in use.
/// </summary>
public const string InvalidEnrollment = "invalid-enrollment";
/// <summary>
/// The identity-provider token did not bind the supplied keys: a bad signature, the wrong
/// subject or audience, an expired token, or a <c>nonce</c> that is not the statement's hash.
/// </summary>
public const string IdentityBindingInvalid = "identity-binding-invalid";
/// <summary>
/// A device registration was structurally invalid: a public key of the wrong length, a missing or
/// oversized wrap, or a blank name.
/// </summary>
/// <remarks>
/// Distinct from <see cref="InvalidEnrollment"/> even though the rules overlap, because the two are
/// different requests and a client showing "your enrollment was rejected" when somebody added a
/// fingerprint reader would be describing the wrong thing entirely.
/// </remarks>
public const string InvalidDeviceRegistration = "invalid-device-registration";
/// <summary>
/// The request body could not be read at all: malformed JSON, or a property the server does not
/// know.
/// </summary>
/// <remarks>
/// A contract mismatch rather than a rejected value. The request never reached a handler, so no
/// field-level detail is offered and none should be inferred from its absence. Compare the request
/// against the <c>DodoSSH.Contracts</c> assembly for the server version <c>GET /api/v1/meta</c>
/// reports.
/// </remarks>
public const string MalformedRequest = "malformed-request";
/// <summary>
/// This deployment does not push vault changes over a socket.
/// </summary>
/// <remarks>
/// Not a failure to recover from: synchronising on a timer is the supported behaviour and the
/// socket only ever made it early. A client that meets this stops dialling and keeps polling. See
/// ADR 0012.
/// </remarks>
public const string EventsUnavailable = "events-unavailable";
/// <summary>
/// This deployment does not relay.
/// </summary>
/// <remarks>
/// Its own code rather than <see cref="RelayTargetRejected"/>, because it is the one relay refusal
/// whose remedy is not about the host: nothing the user changes on this machine will make it work
/// here, and saying so is not an oracle for anything. <c>GET /api/v1/meta</c> says the same thing
/// without asking.
/// </remarks>
public const string RelayUnavailable = "relay-unavailable";
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
public const string RelayTargetRejected = "relay-target-rejected";
/// <summary>The relay ticket is expired, already used, or not valid for this node.</summary>
public const string RelayTicketInvalid = "relay-ticket-invalid";
/// <summary>A per-user or per-node relay session limit was reached.</summary>
public const string RelayLimitReached = "relay-limit-reached";
/// <summary>The client is older than the server's <c>minClientVersion</c>.</summary>
public const string ClientTooOld = "client-too-old";
/// <summary>A push batch exceeded the operation count or payload size cap.</summary>
public const string PushBatchTooLarge = "push-batch-too-large";
/// <summary>
/// A team create or membership change was structurally invalid: a blank name, a slug that is
/// not URL-safe, an unknown role, or an account that does not exist here.
/// </summary>
public const string InvalidTeam = "invalid-team";
/// <summary>
/// The requested slug is already in use.
/// </summary>
/// <remarks>
/// Its own code rather than folded into <see cref="InvalidTeam"/>, because it is the one create
/// failure the caller could not have predicted from their own input and the only one whose
/// remedy is "pick a different one" rather than "fix what you typed".
/// </remarks>
public const string TeamSlugTaken = "team-slug-taken";
/// <summary>
/// The change would leave a team with no owner.
/// </summary>
/// <remarks>
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
/// and the only route back would be an operator editing the database by hand. The way past it is
/// <c>POST /api/v1/teams/{teamId}/owner</c>, which moves ownership and the outgoing owner's
/// demotion in one transaction; a client that gets this code can offer that.
/// </remarks>
public const string LastTeamOwner = "last-team-owner";
/// <summary>
/// A team cannot be archived while it still owns vaults.
/// </summary>
/// <remarks>
/// Its own code because the remedy is neither "fix what you typed" nor "pick another value": it is to
/// deal with the vaults first, by deleting each of them — <c>DELETE /api/v1/vaults/{vaultId}</c> — which
/// asks about each one on the way. Archiving anyway would hide vaults from every member including the
/// ones holding keys to them, which is the same removal without anybody having been asked.
/// </remarks>
public const string TeamNotEmpty = "team-not-empty";
/// <summary>
/// An address given to <c>POST /api/v1/teams/{teamId}/members</c> has no account on this server.
/// </summary>
/// <remarks>
/// <para>
/// Its own code rather than folded into <see cref="InvalidTeam"/> because it is the one add failure
/// that is not about the request: the request was fine and named somebody who is not here. A client
/// that could not tell this apart from a rejected role would have to say "check what you typed"
/// about an address that was typed correctly.
/// </para>
/// <para>
/// <b>Nothing follows it.</b> This used to be the signal to invite the address instead; there are
/// no invitations, and a membership is only ever granted to an account somebody named, so the whole
/// of what a client can do with this is say that the person has to sign in here once. See
/// <see cref="AddTeamMemberRequest"/>.
/// </para>
/// <para>
/// It answers whether an address has an account here, which is a real disclosure and a bounded one:
/// only an admin or owner of the team the add names reaches it — the endpoint checks that first —
/// and it is the same fact the member list would show them a moment later.
/// </para>
/// </remarks>
public const string NoSuchAccount = "no-such-account";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
/// </summary>
/// <remarks>
/// Never a statement about the wrapped key's <em>contents</em>. The server cannot open it, so a
/// grant containing garbage is accepted here and surfaces at the recipient as a tag failure,
/// with the signature naming who issued it. See docs/crypto.md §6.
/// </remarks>
public const string InvalidVaultGrant = "invalid-vault-grant";
}