Public Access
The second M1 gate. This assembly, not the OpenAPI document, is the client's contract, so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes a build error rather than a runtime deserialisation failure on someone's laptop. Contract surface: - EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns needed to recompute AAD, since AAD is derived from the row rather than transmitted. - Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so one stale item cannot block a whole offline queue; a Conflict returns the server's row for client-side three-way merge, because the server cannot merge ciphertext. - Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider signs over the public keys and this server cannot fabricate a key for a user who never enrolled. - Meta and .well-known configuration: capability negotiation instead of URL versioning, which is what a self-hosted product needs when client and server upgrade independently. - SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side where names can be decrypted, so the server never needs a searchable title. Two design problems found by writing the tests rather than assuming: - Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context silently discards every source-generated setting. JsonSerializerDefaults.Web replaces NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is meant — invisible until two implementations disagree. Callers now use ResponseOptions or StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift. - StrictRequestOptions had a static-initialisation cycle: it read the generated Default property from the same type's initialiser and got null. Now lazy. Requests reject unmapped members so a client typo is a 400; responses tolerate them so an older client can read a newer server. Enums cross the wire as strings, so reordering one cannot silently reinterpret stored data. Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a public member per serialisable type, which would have added hundreds of mechanical entries and drowned the ones describing the actual wire contract. And disabled MA0048's one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer open two files to understand one endpoint. Verified: 0 warnings, 95 tests pass, format clean.
100 lines
3.7 KiB
C#
100 lines
3.7 KiB
C#
namespace DodoSSH.Contracts;
|
|
|
|
/// <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. Resolved server-side the
|
|
/// same way.
|
|
/// </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);
|