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);