Public Access
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.
175 lines
7.6 KiB
C#
175 lines
7.6 KiB
C#
namespace DodoSSH.Contracts;
|
|
|
|
/// <summary>
|
|
/// The relay's routes, subprotocol and close codes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two steps, per ADR 0004. <see cref="TicketsPath"/> is an ordinary authenticated request that decides
|
|
/// everything — which host, which addresses, whether the caller may — and answers with a ticket.
|
|
/// <see cref="ConnectPath"/> is a WebSocket that carries no API authority at all: it accepts the ticket
|
|
/// and nothing else, and pipes raw bytes to the addresses the ticket names. There is no framing of the
|
|
/// relay's own inside the socket. SSH is already a byte stream, so every binary message is simply the
|
|
/// next bytes of it, in order, and message boundaries mean nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// A refusal the client could not have avoided by asking differently — a limit, a target that did not
|
|
/// answer — is sent as a close code after the upgrade rather than as an HTTP status before it, for the
|
|
/// same reason as <see cref="VaultEvents"/>: a constrained WebSocket client cannot read the status of a
|
|
/// failed upgrade, and these are exactly the cases where the user needs to be told something specific.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class RelayProtocol
|
|
{
|
|
/// <summary>The <c>/api/v1/meta</c> feature flag advertising that this server relays at all.</summary>
|
|
public const string Feature = "relay";
|
|
|
|
/// <summary>Where a ticket is asked for, with a <see cref="RelayTicketRequest"/>.</summary>
|
|
public const string TicketsPath = "/api/v1/relay/tickets";
|
|
|
|
/// <summary>The path the relay socket is served from.</summary>
|
|
/// <remarks>
|
|
/// A client dials <see cref="RelayTicketResponse.WebSocketUrl"/> rather than building this itself,
|
|
/// because a deployment may publish the relay under a different host than the API.
|
|
/// </remarks>
|
|
public const string ConnectPath = "/api/v1/relay/connect";
|
|
|
|
/// <summary>The only subprotocol this version speaks.</summary>
|
|
public const string SubProtocol = "dodossh.relay.v1";
|
|
|
|
/// <summary>
|
|
/// The prefix of the second subprotocol an upgrade offers, which carries the ticket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The header rather than the query string, because a URL is what proxies, access logs and traces
|
|
/// keep. The server echoes <see cref="SubProtocol"/> alone and never the ticket.
|
|
/// </remarks>
|
|
public const string TicketProtocolPrefix = "ticket.";
|
|
|
|
/// <summary>
|
|
/// Close code for a caller, or a node, already holding as many relay sessions as it may.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same number as <see cref="VaultEvents.TooManyConnectionsCloseCode"/>, deliberately, for the
|
|
/// same meaning. Retrying at once is what caused it.
|
|
/// </remarks>
|
|
public const int TooManySessionsCloseCode = 4429;
|
|
|
|
/// <summary>
|
|
/// Close code for a target that did not accept the connection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The one failure a user needs told in so many words, because from the laptop it looks exactly like
|
|
/// the direct connection that failed and sent them to the relay in the first place. It says that the
|
|
/// <em>server</em> could not reach the machine, which is a different problem with a different owner.
|
|
/// </remarks>
|
|
public const int TargetUnreachableCloseCode = 4502;
|
|
|
|
/// <summary>Close code for a session that carried no bytes for the idle timeout.</summary>
|
|
public const int IdleTimeoutCloseCode = 4408;
|
|
|
|
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
|
|
public const int DurationLimitCloseCode = 4409;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// A request for permission to relay to a host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// There is deliberately no address field. The server resolves the target from
|
|
/// <see cref="HostId"/>, which must belong to a vault the caller holds Connect on and must have
|
|
/// relay enabled. Accepting a client-supplied address would turn the relay into an
|
|
/// authenticated open TCP proxy into the operator's network; see ADR 0004.
|
|
/// </remarks>
|
|
/// <param name="HostId">The host to reach.</param>
|
|
/// <param name="PortForwardId">
|
|
/// A configured port forward to use instead of the host's own SSH port. Reserved, and refused when
|
|
/// set: a port forward lives inside the host's encrypted payload, so the server has no plaintext port
|
|
/// to resolve it to, and taking one from the client is the open proxy this record exists to prevent.
|
|
/// Serving it needs a plaintext concession of its own, argued the way ADR 0004 argues the host's.
|
|
/// </param>
|
|
public sealed record RelayTicketRequest(
|
|
Guid HostId,
|
|
Guid? PortForwardId);
|
|
|
|
/// <summary>
|
|
/// A short-lived, single-use ticket authorising exactly one relay connection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The ticket grants no API access at all, which is why the WebSocket endpoint can accept it
|
|
/// alone. It is also the extraction seam: a standalone relay process needs only the data
|
|
/// protection key ring and the replay-guard table, no authorization code.
|
|
/// </remarks>
|
|
/// <param name="Ticket">
|
|
/// Opaque token. Sent as the <c>ticket.<token></c> element of the
|
|
/// <c>Sec-WebSocket-Protocol</c> header, because constrained WebSocket clients cannot set
|
|
/// arbitrary headers.
|
|
/// </param>
|
|
/// <param name="WebSocketUrl">Absolute URL of the relay endpoint.</param>
|
|
/// <param name="SubProtocol">Required WebSocket subprotocol.</param>
|
|
/// <param name="SessionId">Correlates the ticket with the resulting session record.</param>
|
|
/// <param name="ExpiresAt">Expiry, at most 30 seconds out.</param>
|
|
public sealed record RelayTicketResponse(
|
|
string Ticket,
|
|
Uri WebSocketUrl,
|
|
string SubProtocol,
|
|
Guid SessionId,
|
|
DateTimeOffset ExpiresAt);
|
|
|
|
/// <summary>Why a relay session ended.</summary>
|
|
public enum RelaySessionCloseReason
|
|
{
|
|
/// <summary>Not a legal value.</summary>
|
|
Unspecified = 0,
|
|
|
|
/// <summary>The client closed the connection.</summary>
|
|
ClientClosed = 1,
|
|
|
|
/// <summary>The target closed the connection.</summary>
|
|
TargetClosed = 2,
|
|
|
|
/// <summary>No traffic within the idle timeout.</summary>
|
|
IdleTimeout = 3,
|
|
|
|
/// <summary>The maximum session duration was reached.</summary>
|
|
DurationLimit = 4,
|
|
|
|
/// <summary>The server is shutting down and drained the session.</summary>
|
|
ServerShutdown = 5,
|
|
|
|
/// <summary>The connection to the target failed.</summary>
|
|
TargetUnreachable = 6,
|
|
|
|
/// <summary>An error ended the session.</summary>
|
|
Error = 7,
|
|
}
|
|
|
|
/// <summary>
|
|
/// An audit record of one relay session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Byte counts and duration are recorded; content never is. The relay forwards SSH ciphertext,
|
|
/// so session recording is impossible in this mode by construction — the compliance strength
|
|
/// and the limitation are the same fact.
|
|
/// </remarks>
|
|
/// <param name="SessionId">The session.</param>
|
|
/// <param name="HostId">Host that was reached.</param>
|
|
/// <param name="TargetHost">Resolved target hostname.</param>
|
|
/// <param name="TargetPort">Resolved target port.</param>
|
|
/// <param name="StartedAt">When the session opened.</param>
|
|
/// <param name="EndedAt">When it closed, or null while still open.</param>
|
|
/// <param name="BytesClientToTarget">Bytes forwarded from client to target.</param>
|
|
/// <param name="BytesTargetToClient">Bytes forwarded from target to client.</param>
|
|
/// <param name="CloseReason">Why it ended.</param>
|
|
public sealed record RelaySessionSummary(
|
|
Guid SessionId,
|
|
Guid HostId,
|
|
string TargetHost,
|
|
int TargetPort,
|
|
DateTimeOffset StartedAt,
|
|
DateTimeOffset? EndedAt,
|
|
long BytesClientToTarget,
|
|
long BytesTargetToClient,
|
|
RelaySessionCloseReason CloseReason);
|