namespace DodoSSH.Contracts; /// /// The relay's routes, subprotocol and close codes. /// /// /// /// Two steps, per ADR 0004. is an ordinary authenticated request that decides /// everything — which host, which addresses, whether the caller may — and answers with a ticket. /// 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. /// /// /// 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 : 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. /// /// public static class RelayProtocol { /// The /api/v1/meta feature flag advertising that this server relays at all. public const string Feature = "relay"; /// Where a ticket is asked for, with a . public const string TicketsPath = "/api/v1/relay/tickets"; /// The path the relay socket is served from. /// /// A client dials rather than building this itself, /// because a deployment may publish the relay under a different host than the API. /// public const string ConnectPath = "/api/v1/relay/connect"; /// The only subprotocol this version speaks. public const string SubProtocol = "dodossh.relay.v1"; /// /// The prefix of the second subprotocol an upgrade offers, which carries the ticket. /// /// /// The header rather than the query string, because a URL is what proxies, access logs and traces /// keep. The server echoes alone and never the ticket. /// public const string TicketProtocolPrefix = "ticket."; /// /// Close code for a caller, or a node, already holding as many relay sessions as it may. /// /// /// The same number as , deliberately, for the /// same meaning. Retrying at once is what caused it. /// public const int TooManySessionsCloseCode = 4429; /// /// Close code for a target that did not accept the connection. /// /// /// 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 /// server could not reach the machine, which is a different problem with a different owner. /// public const int TargetUnreachableCloseCode = 4502; /// Close code for a session that carried no bytes for the idle timeout. public const int IdleTimeoutCloseCode = 4408; /// Close code for a session that reached the maximum lifetime this server allows. public const int DurationLimitCloseCode = 4409; } /// /// A request for permission to relay to a host. /// /// /// There is deliberately no address field. The server resolves the target from /// , 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. /// /// The host to reach. /// /// 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. /// public sealed record RelayTicketRequest( Guid HostId, Guid? PortForwardId); /// /// A short-lived, single-use ticket authorising exactly one relay connection. /// /// /// 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. /// /// /// Opaque token. Sent as the ticket.<token> element of the /// Sec-WebSocket-Protocol header, because constrained WebSocket clients cannot set /// arbitrary headers. /// /// Absolute URL of the relay endpoint. /// Required WebSocket subprotocol. /// Correlates the ticket with the resulting session record. /// Expiry, at most 30 seconds out. public sealed record RelayTicketResponse( string Ticket, Uri WebSocketUrl, string SubProtocol, Guid SessionId, DateTimeOffset ExpiresAt); /// Why a relay session ended. public enum RelaySessionCloseReason { /// Not a legal value. Unspecified = 0, /// The client closed the connection. ClientClosed = 1, /// The target closed the connection. TargetClosed = 2, /// No traffic within the idle timeout. IdleTimeout = 3, /// The maximum session duration was reached. DurationLimit = 4, /// The server is shutting down and drained the session. ServerShutdown = 5, /// The connection to the target failed. TargetUnreachable = 6, /// An error ended the session. Error = 7, } /// /// An audit record of one relay session. /// /// /// 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. /// /// The session. /// Host that was reached. /// Resolved target hostname. /// Resolved target port. /// When the session opened. /// When it closed, or null while still open. /// Bytes forwarded from client to target. /// Bytes forwarded from target to client. /// Why it ended. public sealed record RelaySessionSummary( Guid SessionId, Guid HostId, string TargetHost, int TargetPort, DateTimeOffset StartedAt, DateTimeOffset? EndedAt, long BytesClientToTarget, long BytesTargetToClient, RelaySessionCloseReason CloseReason);