Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s

This commit is contained in:
2026-08-04 16:38:42 +02:00
31 changed files with 3285 additions and 13 deletions
@@ -0,0 +1,533 @@
using System.Collections.Frozen;
using System.Globalization;
using System.Net.WebSockets;
using System.Security.Claims;
using System.Text.Json;
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using FastEndpoints;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// The socket that says "pull now" so a client does not have to wait for its timer.
/// </summary>
/// <remarks>
/// <para>
/// Everything this endpoint sends is a <em>notice</em>. It never carries an item, a payload or a
/// cursor: the client's answer to a notice is the delta pull it would have run on its own anyway, so
/// there is exactly one code path that applies a change and this is not it. See ADR 0012 for why
/// pushing the items themselves is refused.
/// </para>
/// <para>
/// The bearer token authorises the upgrade, unlike the relay's ticket in ADR 0004. The relay's socket
/// is a byte pipe whose whole authorization decision is made before it opens; this one is a view of
/// the caller's own vault list and has to keep answering "what may this account read" for as long as
/// it is held. Its two bounds on that — the token's own expiry, and a periodic re-resolve — are in
/// <see cref="MindAsync"/>.
/// </para>
/// </remarks>
internal sealed class VaultEventsEndpoint(
IServiceScopeFactory scopes,
VaultEventHub hub,
IOptions<EventsOptions> options,
TimeProvider clock,
IHostApplicationLifetime lifetime,
ILogger<VaultEventsEndpoint> logger)
: EndpointWithoutRequest
{
/// <summary>
/// The largest message this endpoint will read from a client.
/// </summary>
/// <remarks>
/// A client sends nothing but <c>ping</c>, so the cap is three orders of magnitude of headroom and
/// still small enough that a hostile client cannot make the server buffer anything worth having.
/// </remarks>
private const int MaxInboundFrameBytes = 4 * 1024;
/// <summary>How long to wait for the close handshake before dropping the socket.</summary>
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override void Configure()
{
Get(VaultEvents.Path);
// Enrolled, matching sync. A caller with no identity key holds no vault key either, so every
// notice this socket could send is about ciphertext they cannot read.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("VaultEvents")
.WithSummary("Pushes a notice when a vault the caller can read has changed.")
.WithTags("Events"));
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken ct)
{
if (await RefusedAsync().ConfigureAwait(false))
{
return;
}
var (userId, vaults) = await ResolveAccessAsync(ct).ConfigureAwait(false);
// Admitted before the upgrade so a refusal costs nothing, but answered *through* the socket
// rather than as an HTTP status: a constrained WebSocket client cannot read the status of a
// failed upgrade, and "you have too many open" is precisely the case where the client needs
// to know to back off rather than retry. Same reasoning as ADR 0004 on request headers.
var connection = hub.TryAdmit(userId, vaults);
try
{
using var socket = await HttpContext.WebSockets
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = VaultEvents.SubProtocol })
.ConfigureAwait(false);
if (connection is null)
{
await CloseAsync(
socket,
new Closure(
VaultEvents.TooManyConnectionsCloseCode, "Too many open event sockets."))
.ConfigureAwait(false);
return;
}
await PumpAsync(socket, connection, ct).ConfigureAwait(false);
}
finally
{
// Inside a try that starts *before* the upgrade, because an accept that throws — a client
// that abandoned the handshake — would otherwise leave an admitted connection in the hub
// for the life of the process, counting against this account's cap and taking a slot from
// the sockets that did open.
if (connection is not null)
{
hub.Remove(connection);
}
}
}
/// <summary>
/// Answers the requests that are not an event socket at all, as ordinary HTTP.
/// </summary>
/// <returns>Whether a response was sent and the handler should stop.</returns>
/// <remarks>
/// All three answers are problem documents rather than bare statuses, because each one has a
/// different remedy and a client that cannot tell them apart would retry the two that will never
/// succeed. Answered before the upgrade, so a caller that got the handshake wrong reads why in a
/// body rather than inferring it from a socket that closed.
/// </remarks>
private async Task<bool> RefusedAsync()
{
if (!options.Value.Enabled)
{
// 404 rather than 501: the feature is absent from this deployment, and /api/v1/meta does
// not advertise it. A client that dialled anyway keeps polling, which is correct.
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.EventsUnavailable,
"This server does not push vault changes. Synchronise on a timer instead; "
+ "GET /api/v1/meta lists the features it does offer."))
.ConfigureAwait(false);
return true;
}
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"This endpoint is a WebSocket. Send an upgrade request offering the "
+ $"'{VaultEvents.SubProtocol}' subprotocol."))
.ConfigureAwait(false);
return true;
}
// The subprotocol is this API's version negotiation for the socket, so an upgrade that does
// not offer it is refused rather than accepted and answered in a dialect the caller may not
// read. See VaultEvents.SubProtocol.
if (!HttpContext.WebSockets.WebSocketRequestedProtocols
.Contains(VaultEvents.SubProtocol, StringComparer.Ordinal))
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
$"This server speaks '{VaultEvents.SubProtocol}', which the upgrade request did "
+ "not offer."))
.ConfigureAwait(false);
return true;
}
return false;
}
/// <summary>
/// Reads who the caller is and which vaults they may follow, in a scope of its own.
/// </summary>
/// <remarks>
/// A fresh scope, disposed at once, rather than services injected into this endpoint — which is
/// the lesson ADR 0004 records paying for on the relay. This handler runs for as long as the
/// socket is open, so anything scoped it held would be a <c>DbContext</c> alive for hours, and a
/// few hundred of those exhaust the connection pool. The database is touched here and in
/// <see cref="RefreshAsync"/>, briefly, and nowhere else.
/// </remarks>
private async Task<(Guid UserId, FrozenSet<Guid> Vaults)> ResolveAccessAsync(
CancellationToken cancellationToken)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserContext>();
var vaultAccess = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
return (user.Id, await ReachableAsync(vaultAccess, user.Id, cancellationToken).ConfigureAwait(false));
}
private static async Task<FrozenSet<Guid>> ReachableAsync(
IVaultAccessService vaultAccess,
Guid userId,
CancellationToken cancellationToken)
{
var accessible = await vaultAccess.ListAsync(userId, cancellationToken).ConfigureAwait(false);
return accessible
.Where(access => access.Vault is not null
&& access.Permissions.HasFlag(PermissionFlags.Read))
.Select(access => access.Vault!.Id)
.ToFrozenSet();
}
/// <summary>Runs the socket until something ends it, then closes it politely.</summary>
/// <remarks>
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/> and then <em>all</em>
/// awaited before the close is written, because a close frame racing a notice frame is a protocol
/// violation that presents as a client dropping its connection for no visible reason.
/// </remarks>
private async Task PumpAsync(
WebSocket socket,
VaultEventConnection connection,
CancellationToken requestAborted)
{
var settings = options.Value;
using var pump = CancellationTokenSource.CreateLinkedTokenSource(
requestAborted, lifetime.ApplicationStopping);
var closure = new Closure(
(int)WebSocketCloseStatus.NormalClosure, string.Empty);
connection.TryEnqueue(new VaultEvent(
VaultEventKinds.Hello,
ServerTime: clock.GetUtcNow(),
HeartbeatSeconds: (int)settings.HeartbeatInterval.TotalSeconds,
VaultCount: connection.VaultCount));
var sending = SendAsync(socket, connection, pump.Token);
var receiving = ReceiveAsync(socket, connection, pump.Token);
var minding = MindAsync(connection, closure, pump.Token);
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
await pump.CancelAsync().ConfigureAwait(false);
// Nothing may still be mid-send when the close frame goes out.
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false);
if (lifetime.ApplicationStopping.IsCancellationRequested)
{
// 1001 "going away", so a client knows to reconnect immediately rather than treating a
// rolling deployment as a server that has broken.
closure.Set((int)WebSocketCloseStatus.EndpointUnavailable, "The server is shutting down.");
}
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
await CloseAsync(socket, closure).ConfigureAwait(false);
}
/// <summary>
/// Writes queued frames to the socket, one at a time.
/// </summary>
/// <remarks>
/// The <em>only</em> writer, which is what makes concurrent sends impossible without a lock: the
/// heartbeat and the pong both go into the same queue rather than to the socket. A WebSocket
/// permits one send at a time and faults permanently on a second, so this is not a tidiness
/// preference.
/// </remarks>
private async Task SendAsync(
WebSocket socket,
VaultEventConnection connection,
CancellationToken cancellationToken)
{
await foreach (var frame in connection.Outbound
.ReadAllAsync(cancellationToken)
.ConfigureAwait(false))
{
// Re-resolved before the notice is forwarded, not after: the client's answer to this frame
// is to re-read its vault list, and the point of a newly shared vault is that the *next*
// change to it produces a notice too. A socket that forwarded first would not follow the
// new vault until its next periodic refresh.
if (string.Equals(frame.Kind, VaultEventKinds.VaultsChanged, StringComparison.Ordinal))
{
await RefreshAsync(connection, cancellationToken).ConfigureAwait(false);
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
await socket
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Reads what the client sends, which in this version is heartbeats and a close.
/// </summary>
/// <remarks>
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
/// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
/// caller's access and nothing else.
/// </remarks>
private static async Task ReceiveAsync(
WebSocket socket,
VaultEventConnection connection,
CancellationToken cancellationToken)
{
var buffer = new byte[MaxInboundFrameBytes];
while (!cancellationToken.IsCancellationRequested)
{
var received = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
if (received.MessageType == WebSocketMessageType.Close)
{
return;
}
// Oversized, or split across frames. Nothing this protocol sends is either, so the client
// is broken or probing; ending the socket is cheaper than reassembling for it.
if (!received.EndOfMessage)
{
return;
}
// Binary is unused in v1 and skipped rather than refused, because ADR 0012 reserves it for
// shared-session data — an older server meeting a newer client must ignore those, not
// close on them.
if (received.MessageType != WebSocketMessageType.Text)
{
continue;
}
if (Kind(buffer.AsSpan(0, received.Count)) is VaultEventKinds.Ping)
{
connection.TryEnqueue(new VaultEvent(VaultEventKinds.Pong));
}
}
}
/// <summary>
/// Reads a frame's kind, or null if it is not one this server understands.
/// </summary>
/// <remarks>
/// A frame that will not parse is skipped rather than closing the socket. This is a control
/// channel whose failure mode is "the client polls instead", so tolerating a frame from a newer
/// client costs nothing and refusing one costs that client its push for the whole session.
/// </remarks>
private static string? Kind(ReadOnlySpan<byte> utf8)
{
try
{
return JsonSerializer.Deserialize(utf8, DodoSshJsonContext.Default.VaultEvent)?.Kind;
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Keeps the heartbeat going, the vault set current, and the socket inside its token's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// The deadline is the earlier of the access token's <c>exp</c> and a hard cap on how long any one
/// socket may live. Closing on expiry is what keeps a long-lived connection from outliving the
/// short-lived credential that authorised it; the client answers by reconnecting with a fresh
/// token, which is a sub-second gap in a channel that degrades to polling anyway.
/// </para>
/// <para>
/// The wait is the shorter of the heartbeat and the time left, so the deadline is met to within a
/// tick rather than to within a heartbeat.
/// </para>
/// </remarks>
private async Task MindAsync(
VaultEventConnection connection,
Closure closure,
CancellationToken cancellationToken)
{
var settings = options.Value;
var started = clock.GetUtcNow();
var deadline = TokenExpiry() is { } expiry && expiry < started + settings.MaxConnectionDuration
? (Expiry: expiry, ForToken: true)
: (Expiry: started + settings.MaxConnectionDuration, ForToken: false);
var refreshed = started;
while (!cancellationToken.IsCancellationRequested)
{
var now = clock.GetUtcNow();
var remaining = deadline.Expiry - now;
if (remaining <= TimeSpan.Zero)
{
closure.Set(
deadline.ForToken
? VaultEvents.TokenExpiredCloseCode
: (int)WebSocketCloseStatus.NormalClosure,
deadline.ForToken
? "The access token has expired. Reconnect with a fresh one."
: "This connection reached its maximum lifetime.");
return;
}
var wait = settings.HeartbeatInterval < remaining ? settings.HeartbeatInterval : remaining;
await Task.Delay(wait, clock, cancellationToken).ConfigureAwait(false);
now = clock.GetUtcNow();
if (now - refreshed >= settings.AccessRefreshInterval)
{
// The backstop for a grant withdrawn while this socket was open. What it bounds is
// metadata — that a vault changed — because that is all a notice carries and reading
// the vault still needs a key this server has never held. See ADR 0012.
await RefreshAsync(connection, cancellationToken).ConfigureAwait(false);
refreshed = now;
}
connection.TryEnqueue(new VaultEvent(VaultEventKinds.Ping, ServerTime: now));
}
}
/// <summary>Re-reads which vaults this socket may follow.</summary>
/// <remarks>
/// A failure is logged and swallowed. The alternative is dropping a working socket because one
/// database call timed out, which would trade an occasionally stale vault set for an outage.
/// </remarks>
private async Task RefreshAsync(VaultEventConnection connection, CancellationToken cancellationToken)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var vaultAccess = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
connection.Resubscribe(
await ReachableAsync(vaultAccess, connection.UserId, cancellationToken)
.ConfigureAwait(false));
}
catch (OperationCanceledException)
{
// The socket is closing.
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
EventsLog.AccessRefreshFailed(logger, connection.UserId, exception);
}
}
/// <remarks>
/// <c>MapInboundClaims</c> is off — see <see cref="Auth"/> — so the claim is spelled as the
/// provider issued it rather than as a WS-Federation URI. Null is treated as "no bound from the
/// token", which the bearer handler's <c>RequireExpirationTime</c> should make unreachable; the
/// lifetime cap covers it either way.
/// </remarks>
private DateTimeOffset? TokenExpiry() =>
long.TryParse(
HttpContext.User.FindFirstValue("exp"),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var seconds)
? DateTimeOffset.FromUnixTimeSeconds(seconds)
: null;
private static async Task CloseAsync(WebSocket socket, Closure closure)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
{
return;
}
using var timeout = new CancellationTokenSource(CloseTimeout);
try
{
await socket
.CloseOutputAsync((WebSocketCloseStatus)closure.Code, closure.Reason, timeout.Token)
.ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// The peer is already gone. There is nothing to tell it and nothing to recover.
}
}
/// <summary>
/// Awaits a pump loop, treating its cancellation and its socket faults as the ordinary end.
/// </summary>
/// <remarks>
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception
/// here is the expected shape of "this connection is over" rather than a fault to propagate — and
/// propagating it would skip the close frame the other side is waiting for.
/// </remarks>
private static async Task Settled(Task loop)
{
try
{
await loop.ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// Expected.
}
}
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
/// <remarks>
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a
/// memory barrier. Writes race only with each other, and any of them is a true answer.
/// </remarks>
private sealed class Closure(int code, string reason)
{
internal int Code { get; private set; } = code;
internal string Reason { get; private set; } = reason;
internal void Set(int code, string reason)
{
Code = code;
Reason = reason;
}
}
}
@@ -0,0 +1,68 @@
namespace DodoSSH.Api.Features.Events;
/// <summary>Source-generated log messages for the event socket.</summary>
/// <remarks>
/// Ids and counts only, as everywhere else. A notice carries no ciphertext to leak, but which vault
/// changed and when is still the metadata ADR 0001 asks be kept to what is diagnostically useful.
/// </remarks>
internal static partial class EventsLog
{
[LoggerMessage(
EventId = 2201,
Level = LogLevel.Debug,
Message = "Event socket opened for user {UserId} following {VaultCount} vault(s); "
+ "{ConnectionCount} open on this node.")]
internal static partial void ConnectionOpened(
ILogger logger,
Guid userId,
int vaultCount,
int connectionCount);
[LoggerMessage(
EventId = 2202,
Level = LogLevel.Debug,
Message = "Event socket closed for user {UserId}; {ConnectionCount} open on this node.")]
internal static partial void ConnectionClosed(ILogger logger, Guid userId, int connectionCount);
/// <remarks>
/// Information rather than Debug: a refused socket is a client that will poll for the rest of its
/// session, and an operator seeing these has a cap to raise.
/// </remarks>
[LoggerMessage(
EventId = 2203,
Level = LogLevel.Information,
Message = "Refused an event socket for user {UserId}: {Limit} is already reached.")]
internal static partial void ConnectionRefused(ILogger logger, Guid userId, string limit);
[LoggerMessage(
EventId = 2204,
Level = LogLevel.Debug,
Message = "Announced vault {VaultId} at sequence {Sequence} to {ConnectionCount} socket(s).")]
internal static partial void VaultChangePublished(
ILogger logger,
Guid vaultId,
long sequence,
int connectionCount);
[LoggerMessage(
EventId = 2205,
Level = LogLevel.Debug,
Message = "Announced a vault access change to {ConnectionCount} socket(s) of user {UserId}.")]
internal static partial void AccessChangePublished(ILogger logger, Guid userId, int connectionCount);
/// <remarks>
/// Warning, and it is worth being loud: the socket is still open and still delivering, but it is
/// delivering about a vault set that may be stale. Everything else here is routine.
/// </remarks>
[LoggerMessage(
EventId = 2206,
Level = LogLevel.Warning,
Message = "Could not re-resolve which vaults user {UserId}'s event socket may follow.")]
internal static partial void AccessRefreshFailed(ILogger logger, Guid userId, Exception exception);
[LoggerMessage(
EventId = 2207,
Level = LogLevel.Debug,
Message = "Closing user {UserId}'s event socket: {Reason}.")]
internal static partial void ClosingConnection(ILogger logger, Guid userId, string reason);
}
@@ -0,0 +1,242 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Threading.Channels;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// Tells connected clients that something they can read has moved.
/// </summary>
/// <remarks>
/// <para>
/// Every method is <c>void</c> and returns having queued, never having sent. That is the contract, not
/// an implementation detail: the callers are write paths that have just committed a transaction, and a
/// publish that could block on a slow socket would make one client's bad network everybody else's
/// latency. A notice that cannot be queued is dropped, which is safe because the client polls anyway.
/// See ADR 0012.
/// </para>
/// <para>
/// An interface because this is the seam a multi-node backplane implements — PostgreSQL
/// <c>LISTEN</c>/<c>NOTIFY</c> is the obvious one and needs no infrastructure this stack does not
/// already run. It is deliberately not implemented: fan-out today is in-process, so a deployment with
/// more than one API replica notices writes handled by other replicas on the polling interval rather
/// than at once. That is the behaviour before this feature existed, which is why it degrades rather
/// than breaks.
/// </para>
/// </remarks>
public interface IVaultEventPublisher
{
/// <summary>Announces that a vault's change log has reached <paramref name="sequence"/>.</summary>
/// <remarks>
/// Call <em>after</em> the transaction commits, and outside the per-vault advisory lock ADR 0003
/// takes. A notice sent from inside names a sequence no reader can see yet, and holds the vault's
/// write lock across a socket write.
/// </remarks>
void VaultChanged(Guid vaultId, long sequence);
/// <summary>Announces that the set of vaults an account can reach is no longer what it was.</summary>
/// <remarks>
/// Takes the <em>recipient</em>, not the actor. Sharing is something one account does to another's
/// list, and it is the other account that has to re-read.
/// </remarks>
void VaultAccessChanged(Guid userId);
}
/// <summary>
/// Every event socket this node is holding.
/// </summary>
/// <remarks>
/// <para>
/// Publishing walks the whole connection list and asks each one whether it cares, rather than keeping
/// an index from vault to subscribers. With a per-node connection cap in the hundreds and an event rate
/// bounded by how often people edit keychains, the walk is not measurable — and the index is not free:
/// a connection's vault set is re-resolved while it is live, so every re-subscription would have to
/// move it between buckets under a lock that publishing also takes. The simpler shape is the one whose
/// races are obvious.
/// </para>
/// <para>
/// A singleton, holding no scoped service and no database context. Connections outlive requests by
/// design and anything request-scoped they captured would outlive its scope with them.
/// </para>
/// </remarks>
internal sealed class VaultEventHub(
IOptions<EventsOptions> options,
TimeProvider clock,
ILogger<VaultEventHub> logger) : IVaultEventPublisher
{
private readonly ConcurrentDictionary<Guid, VaultEventConnection> connections = new();
/// <summary>
/// Serialises admission so the caps are caps rather than approximations.
/// </summary>
/// <remarks>
/// Counting and inserting under one lock, because the two done separately let N simultaneous
/// connects all read the same under-cap count and all insert. Contended only by connects, which
/// happen once per client per session; publishing never takes it.
/// </remarks>
private readonly Lock admission = new();
/// <summary>How many sockets this node is holding. Diagnostics and tests.</summary>
internal int Count => connections.Count;
/// <summary>
/// Admits a socket, or refuses it because a cap is already met.
/// </summary>
/// <returns>The connection, or null when a limit refused it.</returns>
internal VaultEventConnection? TryAdmit(Guid userId, FrozenSet<Guid> vaults)
{
var limits = options.Value;
lock (admission)
{
if (connections.Count >= limits.MaxConnectionsTotal)
{
EventsLog.ConnectionRefused(logger, userId, "the node limit");
return null;
}
var held = 0;
foreach (var existing in connections.Values)
{
if (existing.UserId == userId && ++held >= limits.MaxConnectionsPerUser)
{
EventsLog.ConnectionRefused(logger, userId, "the per-account limit");
return null;
}
}
var connection = new VaultEventConnection(userId, vaults, limits.OutboundQueueDepth);
// Cannot collide: the id is fresh and this is the only insert.
connections[connection.Id] = connection;
EventsLog.ConnectionOpened(logger, userId, vaults.Count, connections.Count);
return connection;
}
}
/// <summary>Forgets a socket that has closed.</summary>
internal void Remove(VaultEventConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
connections.TryRemove(connection.Id, out _);
connection.Complete();
EventsLog.ConnectionClosed(logger, connection.UserId, connections.Count);
}
/// <inheritdoc />
public void VaultChanged(Guid vaultId, long sequence)
{
var notice = new VaultEvent(
VaultEventKinds.VaultChanged,
VaultId: vaultId,
Sequence: sequence,
ServerTime: clock.GetUtcNow());
var delivered = 0;
foreach (var connection in connections.Values)
{
if (connection.IsSubscribedTo(vaultId) && connection.TryEnqueue(notice))
{
delivered++;
}
}
if (delivered > 0)
{
EventsLog.VaultChangePublished(logger, vaultId, sequence, delivered);
}
}
/// <inheritdoc />
public void VaultAccessChanged(Guid userId)
{
var notice = new VaultEvent(VaultEventKinds.VaultsChanged, ServerTime: clock.GetUtcNow());
var delivered = 0;
foreach (var connection in connections.Values)
{
if (connection.UserId == userId && connection.TryEnqueue(notice))
{
delivered++;
}
}
if (delivered > 0)
{
EventsLog.AccessChangePublished(logger, userId, delivered);
}
}
}
/// <summary>
/// One open socket, as the hub sees it.
/// </summary>
/// <remarks>
/// Deliberately knows nothing about WebSockets. The hub queues frames here and the endpoint's pump
/// takes them away, which is what keeps a publish from ever touching a socket — and what lets the
/// whole fan-out be tested without one.
/// </remarks>
internal sealed class VaultEventConnection
{
private readonly Channel<VaultEvent> outbound;
private FrozenSet<Guid> vaults;
internal VaultEventConnection(Guid userId, FrozenSet<Guid> vaults, int queueDepth)
{
Id = Guid.CreateVersion7();
UserId = userId;
this.vaults = vaults;
// DropOldest, and the choice is what makes a slow reader harmless. A notice says "vault X has
// moved to at least sequence N", so a newer one subsumes the one it displaces and the client's
// answer — pull that vault — is identical either way. The writer therefore never waits and
// TryWrite never fails, which is what lets the publish path be non-blocking and void.
outbound = Channel.CreateBounded<VaultEvent>(new BoundedChannelOptions(queueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
}
/// <summary>Identifies this connection within the hub. Never sent to a client.</summary>
internal Guid Id { get; }
/// <summary>The account that opened it.</summary>
internal Guid UserId { get; }
/// <summary>Frames waiting to be written to the socket.</summary>
internal ChannelReader<VaultEvent> Outbound => outbound.Reader;
/// <summary>How many vaults this socket currently follows.</summary>
internal int VaultCount => Volatile.Read(ref vaults).Count;
/// <summary>Whether a change to this vault concerns this socket.</summary>
internal bool IsSubscribedTo(Guid vaultId) => Volatile.Read(ref vaults).Contains(vaultId);
/// <summary>
/// Replaces what this socket follows, after its account's access was re-resolved.
/// </summary>
/// <remarks>
/// A whole-set swap of an immutable set rather than a mutation, so a publish walking the list
/// concurrently reads either the old set or the new one and never a half-built one. No lock: the
/// only writer is this connection's own pump.
/// </remarks>
internal void Resubscribe(FrozenSet<Guid> replacement) => Volatile.Write(ref vaults, replacement);
/// <summary>Queues a frame. Never blocks, and never fails — see the channel's full mode.</summary>
internal bool TryEnqueue(VaultEvent frame) => outbound.Writer.TryWrite(frame);
/// <summary>Signals that nothing more will be queued, which ends the pump's drain loop.</summary>
internal void Complete() => outbound.Writer.TryComplete();
}
@@ -19,6 +19,7 @@ namespace DodoSSH.Api.Features.Meta;
internal sealed class GetMetaEndpoint(
IOptions<SyncOptions> sync,
IOptions<RelayOptions> relay,
IOptions<EventsOptions> events,
IOptions<ServerOptions> server)
: EndpointWithoutRequest<Ok<MetaResponse>>
{
@@ -52,6 +53,14 @@ internal sealed class GetMetaEndpoint(
features.Add(RelayFeature);
}
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
// not an error — synchronising on a timer is the supported behaviour and the socket only makes
// it early — which is why this is a feature flag rather than a version bump. See ADR 0012.
if (events.Value.Enabled)
{
features.Add(VaultEvents.Feature);
}
return Task.FromResult(TypedResults.Ok(new MetaResponse(
ServerVersion: ServerVersion,
ApiVersions: [1],
@@ -1,4 +1,5 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
@@ -81,6 +82,7 @@ internal sealed class SyncPullEndpoint(
internal sealed class SyncPushEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
IVaultEventPublisher events,
SyncService sync)
: Endpoint<SyncPushRequest, Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>>
{
@@ -127,6 +129,8 @@ internal sealed class SyncPushEndpoint(
// single stale item cannot block everything else a client queued while offline.
var response = await sync.PushAsync(access.Vault!, user.Id, req, ct).ConfigureAwait(false);
Announce(access.Vault!.Id, response);
return TypedResults.Ok(response);
}
catch (PushBatchTooLargeException exception)
@@ -142,4 +146,41 @@ internal sealed class SyncPushEndpoint(
StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
}
}
/// <summary>
/// Tells every socket following this vault that it has moved.
/// </summary>
/// <remarks>
/// <para>
/// Here rather than inside <see cref="SyncService.PushAsync"/>, and that placement is the point:
/// the push has committed and released the per-vault advisory lock by the time this runs. Announced
/// from inside, it would name a sequence no reader could see yet and would hold the lock that
/// serialises writers across a fan-out. See ADR 0003 and ADR 0012.
/// </para>
/// <para>
/// The highest <em>applied</em> sequence, ignoring duplicates: a duplicate means an earlier push of
/// that operation already landed, and it was announced then. Nothing applied means nothing to say —
/// a batch of pure conflicts moved no vault, and announcing one anyway would have every client pull
/// for a change that is not there.
/// </para>
/// </remarks>
private void Announce(Guid vaultId, SyncPushResponse response)
{
var highest = 0L;
foreach (var result in response.Results)
{
if (result.Status == SyncOperationStatus.Applied
&& result.ChangeSequence is { } sequence
&& sequence > highest)
{
highest = sequence;
}
}
if (highest > 0)
{
events.VaultChanged(vaultId, highest);
}
}
}
@@ -1,4 +1,5 @@
using System.Globalization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -62,6 +63,7 @@ internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
internal sealed class TeamService(
DodoDbContext database,
TimeProvider clock,
IVaultEventPublisher events,
ILogger<TeamService> logger)
{
/// <summary>Longest acceptable slug. Matches the column.</summary>
@@ -548,6 +550,11 @@ internal sealed class TeamService(
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
// Membership is what the server will serve, so every vault this team owns has just appeared in
// the new member's list — before anybody wraps a key to them, which is a separate act and its
// own notice. Told at once rather than on their next pass. See ADR 0012.
events.VaultAccessChanged(target.Id);
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
}
@@ -744,6 +751,11 @@ internal sealed class TeamService(
}).ConfigureAwait(false);
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
// After the commit, so their client re-reads a list the server has already stopped serving
// those vaults from. Their open socket re-resolves as it forwards this, which is what stops it
// announcing changes to vaults they have just lost.
events.VaultAccessChanged(memberId);
}
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -27,6 +28,7 @@ namespace DodoSSH.Api.Features.Teams;
internal sealed class VaultGrantService(
DodoDbContext database,
TimeProvider clock,
IVaultEventPublisher events,
ILogger<VaultGrantService> logger)
{
/// <summary>
@@ -414,6 +416,11 @@ internal sealed class VaultGrantService(
TeamLog.GrantIssued(
logger, vault.Id, generation, request.RecipientUserId, actor.Id);
// The recipient, never the actor. This is the whole of what makes a shared vault arrive at
// once rather than on the recipient's next pass — and it is the case the README has had to
// apologise for since sharing shipped. See ADR 0012.
events.VaultAccessChanged(request.RecipientUserId);
}
/// <summary>
@@ -701,6 +708,11 @@ internal sealed class VaultGrantService(
TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);
// Told so their client stops showing a vault it can no longer open, rather than leaving it
// listed until the next pass. It does not reach what they already pulled — nothing can, see
// ADR 0001 — and the server-side effect is immediate regardless of whether this arrives.
events.VaultAccessChanged(recipientUserId);
return true;
}
+16
View File
@@ -1,4 +1,5 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
@@ -47,6 +48,14 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
// resolving to the same instance, for the reason the invitation claim above is: the endpoint needs the
// whole hub — admit, remove, count — while the write paths that announce a change need only the two
// methods that announce one, and should not gain a reference to connection management to get them.
builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
@@ -65,6 +74,13 @@ var app = builder.Build();
app.BlockFastEndpointsRouteTable();
// Before the authentication middleware, because the upgrade handshake has to survive it: the events
// endpoint answers an ordinary authenticated request that happens to become a socket, and without
// this the upgrade is never offered and the handler sees a plain GET. No allow-list of origins is
// configured, deliberately — every client here is a native application sending a bearer token, so
// there is no browser origin to trust and nothing a cross-site request could reach without one.
app.UseWebSockets();
app.UseAuthentication();
app.UseAuthorization();
+16
View File
@@ -38,6 +38,22 @@ internal static class Configuration
"Sync:DefaultPullLimit must not exceed Sync:MaxPullLimit.")
.ValidateOnStart();
services.AddOptions<EventsOptions>()
.BindConfiguration(EventsOptions.SectionName)
.ValidateDataAnnotations()
.Validate(
options => options.HeartbeatInterval > TimeSpan.Zero,
"Events:HeartbeatInterval must be greater than zero.")
.Validate(
options => options.AccessRefreshInterval > TimeSpan.Zero,
"Events:AccessRefreshInterval must be greater than zero.")
.Validate(
options => options.MaxConnectionDuration > options.AccessRefreshInterval,
"Events:MaxConnectionDuration must exceed Events:AccessRefreshInterval; a connection "
+ "that never lives long enough to re-read its own access has none of the bound that "
+ "setting exists to provide.")
.ValidateOnStart();
services.AddOptions<RelayOptions>()
.BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations()
+76
View File
@@ -159,6 +159,82 @@ public sealed class RelayOptions
public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>Realtime push settings. See ADR 0012.</summary>
/// <remarks>
/// Every one of these bounds a socket rather than a feature: with the whole thing off, or every cap
/// met, clients synchronise on their timer exactly as they did before this existed. That is what
/// makes it safe for an operator to turn any of them down.
/// </remarks>
public sealed class EventsOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Events";
/// <summary>
/// Whether this deployment pushes vault changes at all.
/// </summary>
/// <remarks>
/// On by default, unlike the relay: this needs no outbound network, no target resolution and no
/// new trust, and a deployment behind a proxy that will not upgrade should say so here rather than
/// have every client discover it by failing.
/// </remarks>
public bool Enabled { get; set; } = true;
/// <summary>Maximum concurrent sockets per node.</summary>
[Range(1, 100_000)]
public int MaxConnectionsTotal { get; set; } = 500;
/// <summary>
/// Maximum concurrent sockets per account.
/// </summary>
/// <remarks>
/// Per account rather than per device, because the server cannot see a device here. Eight is a
/// laptop, a desktop, a phone and room to reconnect before the old socket has been reaped.
/// </remarks>
[Range(1, 1000)]
public int MaxConnectionsPerUser { get; set; } = 8;
/// <summary>
/// How many notices may be queued for one socket before the oldest are dropped.
/// </summary>
/// <remarks>
/// A notice names a vault and a position, so a newer one subsumes the one it replaces. The depth
/// therefore buys smoothness over a brief stall and nothing else — losing the tail of a burst
/// costs a client nothing, because the newest notice still says to pull.
/// </remarks>
[Range(1, 10_000)]
public int OutboundQueueDepth { get; set; } = 64;
/// <summary>
/// How often the server pings an idle socket.
/// </summary>
/// <remarks>
/// Below the sixty seconds most reverse proxies idle out at, because a silent socket that a proxy
/// has quietly dropped is indistinguishable from a quiet one until something is sent down it.
/// </remarks>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// How often an open socket re-reads which vaults its account may follow.
/// </summary>
/// <remarks>
/// The backstop for a grant withdrawn mid-connection. Grants and membership changes publish
/// immediately, so this is what covers the paths that do not — and what bounds the window if one
/// is ever added without remembering to.
/// </remarks>
public TimeSpan AccessRefreshInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// The longest any one socket may live, regardless of its token.
/// </summary>
/// <remarks>
/// A socket normally ends at its access token's expiry, which is far shorter. This is the bound
/// for a provider that issues long-lived tokens, and it is what makes "no connection is older than
/// this" a property of the server rather than of the identity provider's configuration.
/// </remarks>
public TimeSpan MaxConnectionDuration { get; set; } = TimeSpan.FromHours(12);
}
/// <summary>Sync protocol limits.</summary>
public sealed class SyncOptions
{
@@ -1,3 +1,4 @@
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
@@ -43,6 +44,7 @@ internal static class EndpointRegistration
typeof(ReadKeyLogEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
+5
View File
@@ -26,6 +26,11 @@
"MaxConcurrentSessionsPerUser": 10,
"MaxConcurrentSessionsTotal": 200
},
"Events": {
"Enabled": true,
"MaxConnectionsTotal": 500,
"MaxConnectionsPerUser": 8
},
"Sync": {
"MaxOperationsPerPush": 500,
"MaxPayloadBytes": 8388608,
+552
View File
@@ -0,0 +1,552 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Channels;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api;
/// <summary>
/// A server's "pull now" notices, as everything above the transport needs them.
/// </summary>
/// <remarks>
/// <para>
/// A queue to read from rather than an event to subscribe to, and that shape is the point: the one
/// consumer is a synchronisation loop that already waits on a timer, so it can wait on this the same
/// way and keep every continuation on the thread it started from. An event would deliver on whichever
/// thread the socket happened to complete on, which in a user interface is the difference between
/// working and an intermittent rendering fault nobody can reproduce.
/// </para>
/// <para>
/// <b>Reading this is never how a change is applied.</b> A notice says which vault moved and nothing
/// else; the answer to it is the ordinary delta pull. See ADR 0012.
/// </para>
/// </remarks>
public interface IVaultEventStream : IDisposable
{
/// <summary>Whether a socket is currently established.</summary>
/// <remarks>
/// For the interface to say whether it is live, not for a caller to branch on before reading:
/// synchronising is correct whether or not this is true, because the timer is the fallback.
/// </remarks>
bool IsConnected { get; }
/// <summary>
/// Waits for the next notice.
/// </summary>
/// <remarks>
/// Connects on the first call and reconnects for as long as it is read, so a caller neither starts
/// nor restarts anything. A server that cannot be reached is not an error here — it is a wait that
/// has not finished — because the caller's alternative is the timer it is already running.
/// </remarks>
ValueTask<VaultEvent> ReadAsync(CancellationToken cancellationToken);
/// <summary>
/// Takes a notice if one is already waiting, without blocking.
/// </summary>
/// <returns>Whether there was one.</returns>
/// <remarks>
/// How a caller coalesces a burst. Five people saving at once produces five notices whose answer
/// is a single synchronisation pass, so the loop reads one, waits a moment, and swallows the rest
/// rather than running the same pull five times.
/// </remarks>
bool TryRead(out VaultEvent notice);
}
/// <summary>
/// A stream that never delivers anything.
/// </summary>
/// <remarks>
/// For a server that does not advertise the <c>events</c> feature, and for tests. Deliberately waits
/// for ever rather than completing: a caller selecting between this and a timer must fall through to
/// the timer, and a read that returned immediately would spin that loop as fast as the machine allows.
/// </remarks>
public sealed class IdleVaultEventStream : IVaultEventStream
{
/// <summary>The one instance. It holds nothing.</summary>
public static IdleVaultEventStream Instance { get; } = new();
/// <inheritdoc />
public bool IsConnected => false;
/// <inheritdoc />
public async ValueTask<VaultEvent> ReadAsync(CancellationToken cancellationToken)
{
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
// Unreachable: the delay above only ever ends by throwing.
return new VaultEvent(VaultEventKinds.Ping);
}
/// <inheritdoc />
public bool TryRead(out VaultEvent notice)
{
notice = new VaultEvent(VaultEventKinds.Ping);
return false;
}
/// <inheritdoc />
public void Dispose()
{
// Nothing is held.
}
}
/// <summary>Tuning for <see cref="VaultEventStream"/>.</summary>
/// <remarks>
/// Every value here bounds a reconnection rather than a feature. With the socket permanently
/// unavailable the client synchronises on its timer, so the cost of getting these wrong is latency,
/// never correctness.
/// </remarks>
public sealed record VaultEventStreamOptions
{
/// <summary>The defaults.</summary>
public static VaultEventStreamOptions Default { get; } = new();
/// <summary>How long to wait before the first reconnection attempt.</summary>
public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>The longest the backoff may grow to.</summary>
/// <remarks>
/// A minute, which is the polling interval: past that point reconnecting sooner buys nothing,
/// because the timer has already done the work the socket would have prompted.
/// </remarks>
public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromMinutes(1);
/// <summary>
/// How long a socket may be silent before it is presumed dead.
/// </summary>
/// <remarks>
/// The server pings on an interval it states in its <c>hello</c>, so silence past a multiple of
/// that means the connection is gone rather than idle — which is otherwise indistinguishable, and
/// is exactly what a reverse proxy that quietly drops idle sockets produces. Used only until a
/// <c>hello</c> arrives; after that the server's own figure is trusted.
/// </remarks>
public TimeSpan InitialSilenceTimeout { get; init; } = TimeSpan.FromSeconds(90);
/// <summary>How many notices may be waiting before the oldest are dropped.</summary>
/// <remarks>
/// Small on purpose. A notice means "pull that vault", so a newer one subsumes the one it
/// displaces; a backlog would only make the loop pull repeatedly for work it has already done.
/// </remarks>
public int QueueDepth { get; init; } = 32;
}
/// <summary>
/// Holds a socket to one server open, and hands over what it says.
/// </summary>
/// <remarks>
/// <para>
/// The whole class is a reconnection policy. A dropped socket is the ordinary case — laptops sleep,
/// proxies time out, tokens expire, servers are redeployed — so nothing here treats a failure as
/// exceptional: it backs off and dials again, for as long as somebody is reading.
/// </para>
/// <para>
/// It is safe to have no server at all. Every failure path ends in "wait, then try again", and the
/// caller's synchronisation timer runs regardless, which is what makes it correct for this class to
/// stay silent about problems rather than surface them.
/// </para>
/// </remarks>
public sealed class VaultEventStream : IVaultEventStream, IAsyncDisposable
{
private readonly Uri endpoint;
private readonly IAccessTokenProvider tokens;
private readonly TimeProvider clock;
private readonly VaultEventStreamOptions options;
private readonly Func<Uri, string, CancellationToken, Task<WebSocket>> connect;
private readonly Channel<VaultEvent> notices;
private readonly CancellationTokenSource closing = new();
private readonly Lock starting = new();
private Task? pump;
private bool disposed;
/// <summary>Creates a stream against one server.</summary>
/// <param name="serverUrl">The server's base URL, as an ordinary <c>http</c> or <c>https</c> address.</param>
/// <param name="tokens">Supplies a bearer token, refreshing it when it is due.</param>
/// <param name="clock">Time source, for the backoff and the silence timeout.</param>
/// <param name="options">Tuning, or null for the defaults.</param>
public VaultEventStream(
Uri serverUrl,
IAccessTokenProvider tokens,
TimeProvider clock,
VaultEventStreamOptions? options = null)
: this(serverUrl, tokens, clock, DialAsync, options)
{
}
/// <remarks>
/// The connector is injected so the suite can drive this against a test host's in-memory socket.
/// Reconnection is the entire behaviour of this class, and testing it against a real network would
/// mean testing it against the one thing that cannot be made to fail on demand.
/// </remarks>
internal VaultEventStream(
Uri serverUrl,
IAccessTokenProvider tokens,
TimeProvider clock,
Func<Uri, string, CancellationToken, Task<WebSocket>> connect,
VaultEventStreamOptions? options = null)
{
ArgumentNullException.ThrowIfNull(serverUrl);
ArgumentNullException.ThrowIfNull(tokens);
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(connect);
endpoint = EventsUrl(serverUrl);
this.tokens = tokens;
this.clock = clock;
this.connect = connect;
this.options = options ?? VaultEventStreamOptions.Default;
notices = Channel.CreateBounded<VaultEvent>(new BoundedChannelOptions(this.options.QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = true,
});
}
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <inheritdoc />
public ValueTask<VaultEvent> ReadAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
Start();
return notices.Reader.ReadAsync(cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// Does not start the connection, unlike <see cref="ReadAsync"/>: a caller draining a burst has
/// already read one notice, and "is there another right now" is not a reason to dial a server.
/// </remarks>
public bool TryRead(out VaultEvent notice) => notices.Reader.TryRead(out notice!);
/// <summary>
/// Ends the connection, without waiting for it to unwind.
/// </summary>
/// <remarks>
/// What a shell calls when a connection is dropped, from a synchronous path that must not block —
/// <c>IVaultServer</c> is <see cref="IDisposable"/>, and blocking on a socket teardown from the
/// user-interface thread is exactly the sync-over-async this repository bans. Cancelling is enough:
/// every loop reads the token, and the pump has nothing to flush.
/// </remarks>
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
closing.Cancel();
// The source is deliberately left undisposed. The pump may still be inside a linked token
// source derived from this one, and disposing a parent out from under a live child is how a
// clean shutdown becomes an ObjectDisposedException on a background thread. It holds no timer
// and no handle once cancelled; DisposeAsync is the path that cleans it up properly.
}
/// <summary>Ends the connection and waits for it to unwind.</summary>
/// <remarks>The deterministic form, for a caller that can await one — tests, mostly.</remarks>
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await closing.CancelAsync().ConfigureAwait(false);
if (pump is not null)
{
try
{
await pump.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// The point of the cancel above.
}
}
closing.Dispose();
}
/// <summary>
/// Turns a server's base URL into its event socket's.
/// </summary>
/// <remarks>
/// The path is replaced rather than appended, matching every other call in this client: request
/// paths here are absolute — <c>/api/v1/…</c> — so a deployment behind a path prefix is already
/// unsupported, and pretending otherwise in this one place would be a difference nobody could act
/// on.
/// </remarks>
private static Uri EventsUrl(Uri serverUrl) =>
new UriBuilder(serverUrl)
{
Scheme = string.Equals(serverUrl.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
? "wss"
: "ws",
Path = VaultEvents.Path,
Query = string.Empty,
Fragment = string.Empty,
}.Uri;
private static async Task<WebSocket> DialAsync(Uri url, string token, CancellationToken cancellationToken)
{
var socket = new ClientWebSocket();
try
{
socket.Options.AddSubProtocol(VaultEvents.SubProtocol);
// A header rather than the Sec-WebSocket-Protocol smuggling ADR 0004 needs for the relay:
// this client is a native application and can set one, and the token here is the ordinary
// bearer credential rather than a ticket.
socket.Options.SetRequestHeader("Authorization", $"Bearer {token}");
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
return socket;
}
catch
{
socket.Dispose();
throw;
}
}
private void Start()
{
if (pump is not null)
{
return;
}
lock (starting)
{
pump ??= Task.Run(() => RunAsync(closing.Token), closing.Token);
}
}
/// <summary>Connects, reads until it cannot, waits, and does it again.</summary>
private async Task RunAsync(CancellationToken cancellationToken)
{
var backoff = options.InitialBackoff;
while (!cancellationToken.IsCancellationRequested)
{
var outcome = await AttemptAsync(cancellationToken).ConfigureAwait(false);
// A socket that lived long enough to say hello proves the server is there and willing, so
// the next failure starts from the bottom again rather than inheriting the backoff that
// got us here. Without this a laptop that woke, connected, and then lost its network an
// hour later would wait a full minute before trying, having already proved it need not.
if (outcome == Outcome.Established)
{
backoff = options.InitialBackoff;
}
// The server said this token is spent, which the token provider can fix without waiting.
// Reconnecting at once is the whole reason that close code is distinct.
var wait = outcome == Outcome.TokenExpired ? TimeSpan.Zero : Jitter(backoff);
if (wait > TimeSpan.Zero)
{
try
{
await Task.Delay(wait, clock, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
backoff = backoff < options.MaxBackoff
? Shorter(backoff * 2, options.MaxBackoff)
: options.MaxBackoff;
}
}
}
/// <summary>One connection, from dial to close.</summary>
private async Task<Outcome> AttemptAsync(CancellationToken cancellationToken)
{
WebSocket? socket = null;
try
{
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
socket = await connect(endpoint, token, cancellationToken).ConfigureAwait(false);
IsConnected = true;
return await PumpAsync(socket, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return Outcome.Cancelled;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Every failure this can meet — no network, a refused upgrade, a server that has not been
// deployed with this feature, a token that cannot be refreshed — has the same remedy, and
// none of them is worth telling a user about. The synchronisation timer is still running.
return Outcome.Failed;
}
finally
{
IsConnected = false;
socket?.Dispose();
}
}
/// <summary>Reads frames until the socket ends or goes quiet.</summary>
private async Task<Outcome> PumpAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[8 * 1024];
var silence = options.InitialSilenceTimeout;
var established = false;
while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
// Rebuilt per frame rather than reset, because a linked source cannot be un-cancelled and
// the deadline is what detects a socket that has silently gone away.
using var deadline = new CancellationTokenSource(silence, clock);
using var quiet = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, deadline.Token);
WebSocketReceiveResult received;
try
{
received = await socket.ReceiveAsync(buffer, quiet.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Silent for longer than the server said it would be. The socket is gone in a way that
// only reconnecting can discover, which is what a proxy dropping an idle connection
// looks like from this end.
return Ended(established);
}
if (received.MessageType == WebSocketMessageType.Close)
{
return (int?)received.CloseStatus == VaultEvents.TokenExpiredCloseCode
? Outcome.TokenExpired
: Ended(established);
}
// Binary is reserved by ADR 0012 for shared-session data, and text that arrived in pieces
// is longer than anything this protocol defines. Skipped rather than fatal, so a newer
// server does not cost this client its push for the whole session.
if (received.MessageType != WebSocketMessageType.Text || !received.EndOfMessage)
{
continue;
}
if (Parse(buffer.AsSpan(0, received.Count)) is not { } frame)
{
continue;
}
established = true;
silence = await AbsorbAsync(socket, frame, silence, cancellationToken).ConfigureAwait(false);
}
return Ended(established);
}
/// <summary>Deals with one frame, and says how long the socket may now stay quiet.</summary>
private async Task<TimeSpan> AbsorbAsync(
WebSocket socket,
VaultEvent frame,
TimeSpan silence,
CancellationToken cancellationToken)
{
if (frame.HeartbeatSeconds is > 0 and var seconds)
{
// Three missed heartbeats. Two is within one paused thread of a false positive, and a
// false positive here costs a reconnection rather than anything a user sees.
silence = TimeSpan.FromSeconds(seconds * 3);
}
if (string.Equals(frame.Kind, VaultEventKinds.Ping, StringComparison.Ordinal))
{
await socket.SendAsync(
JsonSerializer.SerializeToUtf8Bytes(
new VaultEvent(VaultEventKinds.Pong), DodoSshJsonContext.Default.VaultEvent),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken)
.ConfigureAwait(false);
return silence;
}
// Everything else, including a kind this build has never heard of, goes to the reader — which
// is what makes the frame table extensible. An unrecognised kind is one the caller ignores;
// refusing it here would be this class deciding what a newer server may say.
notices.Writer.TryWrite(frame);
return silence;
}
private static Outcome Ended(bool established) =>
established ? Outcome.Established : Outcome.Failed;
private static VaultEvent? Parse(ReadOnlySpan<byte> utf8)
{
try
{
return JsonSerializer.Deserialize(utf8, DodoSshJsonContext.Default.VaultEvent);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Spreads reconnections out, so a server that restarts is not met by every client at once.
/// </summary>
/// <remarks>
/// <c>RandomNumberGenerator</c> because <c>System.Random</c> is banned repo-wide. Nothing here is
/// security-relevant — the ban exists so that nothing key-, token- or nonce-adjacent can reach for
/// the weak one by habit, and paying a few microseconds to keep that rule absolute is the cheaper
/// side of the trade.
/// </remarks>
private static TimeSpan Jitter(TimeSpan delay)
{
var milliseconds = (int)Math.Clamp(delay.TotalMilliseconds, 1, int.MaxValue / 2);
return TimeSpan.FromMilliseconds(
milliseconds + RandomNumberGenerator.GetInt32(0, Math.Max(1, milliseconds / 2)));
}
private static TimeSpan Shorter(TimeSpan left, TimeSpan right) => left < right ? left : right;
/// <summary>How one connection attempt ended.</summary>
private enum Outcome
{
/// <summary>Never got as far as a frame. Back off.</summary>
Failed,
/// <summary>Ran, and then ended. Back off, but from the bottom.</summary>
Established,
/// <summary>The server closed it because the token expired. Reconnect at once with a new one.</summary>
TokenExpired,
/// <summary>The stream is being disposed.</summary>
Cancelled,
}
}
+34 -3
View File
@@ -150,6 +150,17 @@ public interface IVaultServer : IDisposable
/// <summary>Vault key grants: who can open a vault, and who let them.</summary>
IVaultGrantApi Grants { get; }
/// <summary>
/// Notices that something changed, so a synchronisation need not wait for the timer.
/// </summary>
/// <remarks>
/// Always present, never null: a server that does not offer the feature — or a test standing in
/// for one — supplies <see cref="IdleVaultEventStream"/>, which simply never delivers. That keeps
/// every caller on one shape, because the correct behaviour without a socket is the behaviour
/// with a silent one: synchronise on the timer. See ADR 0012.
/// </remarks>
IVaultEventStream Events { get; }
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
IKeyBindingAuthorizer KeyBinding { get; }
@@ -197,7 +208,8 @@ public sealed class ServerConnection : IVaultServer
MetaResponse meta,
OidcClient oidc,
RefreshingAccessTokenProvider tokens,
DodoSshApiClient api)
DodoSshApiClient api,
TimeProvider clock)
{
ServerUrl = serverUrl;
this.http = http;
@@ -206,6 +218,14 @@ public sealed class ServerConnection : IVaultServer
Oidc = oidc;
this.tokens = tokens;
Api = api;
// Decided from what this server said it supports rather than attempted and allowed to fail,
// which is the same capability negotiation SyncOptions below does — see ADR 0002. A client
// that dialled anyway would reconnect against a 404 for the whole session, and would look
// from the outside exactly like one whose network was eating WebSockets.
Events = meta.Features.Contains(VaultEvents.Feature, StringComparer.Ordinal)
? new VaultEventStream(serverUrl, tokens, clock)
: IdleVaultEventStream.Instance;
}
/// <summary>The server this is connected to.</summary>
@@ -238,6 +258,9 @@ public sealed class ServerConnection : IVaultServer
/// <inheritdoc />
public IVaultGrantApi Grants => Api;
/// <inheritdoc />
public IVaultEventStream Events { get; }
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => Oidc;
@@ -311,7 +334,8 @@ public sealed class ServerConnection : IVaultServer
meta,
oidc,
refreshing,
new DodoSshApiClient(transport, refreshing));
new DodoSshApiClient(transport, refreshing),
clock);
}
catch
{
@@ -382,7 +406,8 @@ public sealed class ServerConnection : IVaultServer
meta,
oidc,
refreshing,
new DodoSshApiClient(transport, refreshing));
new DodoSshApiClient(transport, refreshing),
clock);
}
catch
{
@@ -400,6 +425,12 @@ public sealed class ServerConnection : IVaultServer
}
disposed = true;
// First, and without waiting: the socket's own loops read the token provider and the transport
// below, so tearing either down while it is still dialling would surface as a fault on a
// background thread at the moment a user signed out.
Events.Dispose();
tokens.Dispose();
http.Dispose();
}
@@ -10,6 +10,7 @@ using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Shell.ViewModels;
@@ -1066,13 +1067,29 @@ internal sealed partial class VaultViewModel(
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
{
/// <remarks>
/// <para>
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
/// </para>
/// <para>
/// Unchanged by the push channel, and deliberately so. The socket makes a pass <em>early</em>; this is
/// what makes one happen at all, for a client whose network eats WebSockets, whose server has the
/// feature off, or whose notice was dropped. See <see cref="WaitForWorkAsync"/> and ADR 0012.
/// </para>
/// </remarks>
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>How long a pushed notice waits, in case more are on their way.</summary>
/// <remarks>
/// A quarter of a second, which is below what anybody perceives and above the gap between the
/// notices one person's save produces — a host and its activity log entry are two items in one
/// push, and a colleague clearing a folder is a burst. Without it each notice would run its own
/// full pass, and the pass a burst deserves is one.
/// </remarks>
private static readonly TimeSpan NoticeDebounce = TimeSpan.FromMilliseconds(250);
/// <summary>How often the logs are pruned, at most.</summary>
/// <remarks>
/// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
@@ -4289,10 +4306,16 @@ internal sealed partial class VaultViewModel(
/// <para>
/// <b>This is the whole of how a shared vault arrives.</b> Sharing is two acts on two machines: the
/// person sharing wraps the vault key to the recipient, and the recipient's own client has to notice.
/// The recipient is handed nothing — there is no push channel — so without this the vault list stayed
/// exactly as it was cached at sign-in, and a vault shared with somebody appeared on their machine only
/// if they happened to sign in through the browser again. Everything else was already right, which is
/// why it looked like sharing was broken rather than like a list that was never re-read.
/// Without this the vault list stayed exactly as it was cached at sign-in, and a vault shared with
/// somebody appeared on their machine only if they happened to sign in through the browser again.
/// Everything else was already right, which is why it looked like sharing was broken rather than like a
/// list that was never re-read.
/// </para>
/// <para>
/// The server now says when this is worth doing — a <c>vaults.changed</c> notice wakes the pass, so the
/// vault turns up as it is shared rather than within the minute — but that only decides <em>when</em>.
/// This call is still what discovers the vault, on the notice and on every timed pass alike, because a
/// client with no socket has to arrive at the same place. See ADR 0012.
/// </para>
/// <para>
/// A failure is left to the caller, which treats it as the pass failing: the call is to the same server
@@ -4339,6 +4362,7 @@ internal sealed partial class VaultViewModel(
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(AutoSyncInterval);
var waits = new AutoSyncWaits();
try
{
@@ -4353,7 +4377,7 @@ internal sealed partial class VaultViewModel(
// user is doing something.
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
while (await WaitForWorkAsync(timer, waits, cancellationToken).ConfigureAwait(true))
{
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
@@ -4364,6 +4388,98 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// Waits for the timer to come round, or for the server to say there is something to fetch.
/// </summary>
/// <returns>Whether to run a pass. False means the loop is over.</returns>
/// <remarks>
/// <para>
/// The timer is unchanged and is still what guarantees a pass. The socket only makes one
/// <em>early</em>, which is why nothing here treats its absence as a problem: no connection, a
/// server without the feature, a network that eats WebSockets, or a notice dropped under
/// backpressure all leave a loop that behaves exactly as it did before this existed. See ADR 0012.
/// </para>
/// <para>
/// <b>Both waits are held across iterations, and that is load-bearing rather than an
/// optimisation.</b> <see cref="PeriodicTimer"/> permits only one outstanding
/// <c>WaitForNextTickAsync</c> and throws on a second, and an abandoned channel read stays
/// registered and consumes the next notice written — which would silently lose exactly the wake-up
/// this is for. Whichever wait did not win is kept and awaited again.
/// </para>
/// </remarks>
private async Task<bool> WaitForWorkAsync(
PeriodicTimer timer,
AutoSyncWaits waits,
CancellationToken cancellationToken)
{
// Re-read every time, because signing out and back in replaces the connection — and with it
// the stream. A read still pending against the old one is left to be cancelled with it.
var stream = connection()?.Events;
if (!ReferenceEquals(stream, waits.Watching))
{
waits.Watching = stream;
waits.Notice = null;
}
waits.Tick ??= timer.WaitForNextTickAsync(cancellationToken).AsTask();
waits.Notice ??= stream?.ReadAsync(cancellationToken).AsTask();
if (waits.Notice is null)
{
var only = waits.Tick;
waits.Tick = null;
return await only.ConfigureAwait(true);
}
var first = await Task.WhenAny(waits.Tick, waits.Notice).ConfigureAwait(true);
if (ReferenceEquals(first, waits.Tick))
{
var ticked = waits.Tick;
waits.Tick = null;
return await ticked.ConfigureAwait(true);
}
// Observed so a faulted read does not go unhandled, and so a stream that has been disposed
// ends this wait rather than being asked again.
await waits.Notice.ConfigureAwait(true);
waits.Notice = null;
// A burst — one person's save is two items, and a colleague tidying a folder is a dozen —
// deserves one pass rather than one each.
await Task.Delay(NoticeDebounce, cancellationToken).ConfigureAwait(true);
while (stream!.TryRead(out _))
{
// Swallowed on purpose. Every notice means the same thing, which is what the pass about to
// run already does; what they say about *which* vault is not read, because a pass syncs
// every vault this session can reach anyway.
}
return true;
}
/// <summary>The two waits the background loop keeps alive between passes.</summary>
/// <remarks>
/// A class rather than three locals because <see cref="WaitForWorkAsync"/> has to hand them back
/// changed, and a method that took three <c>ref</c> parameters could not be <c>async</c>. See that
/// method for why abandoning either of them is a defect rather than a tidiness question.
/// </remarks>
private sealed class AutoSyncWaits
{
/// <summary>The pending timer tick, or null when the last one has been consumed.</summary>
internal Task<bool>? Tick { get; set; }
/// <summary>The pending read from the server's push channel.</summary>
internal Task<VaultEvent>? Notice { get; set; }
/// <summary>The stream <see cref="Notice"/> was taken from, to notice a reconnection.</summary>
internal IVaultEventStream? Watching { get; set; }
}
/// <summary>Shows one kind of item, if nothing is being edited.</summary>
/// <remarks>
/// Takes the section rather than there being one command per kind, so a third kind is an enum member and
@@ -66,6 +66,11 @@ namespace DodoSSH.Contracts;
// Registered in its own right, not only as a member of the sync DTOs: the client's local
// cache seals this record under the LocalCacheKey and needs its type info directly.
[JsonSerializable(typeof(SyncPlaintextFields))]
// The event socket's only frame type. Registered although nothing else references it: frames are
// written straight onto a WebSocket rather than through a response body, so the resolver never
// infers it from an endpoint's signature the way it does for every DTO above.
[JsonSerializable(typeof(VaultEvent))]
[JsonSerializable(typeof(RelayTicketRequest))]
[JsonSerializable(typeof(RelayTicketResponse))]
[JsonSerializable(typeof(RelaySessionSummary))]
+134
View File
@@ -0,0 +1,134 @@
namespace DodoSSH.Contracts;
/// <summary>
/// The event socket's protocol constants.
/// </summary>
/// <remarks>
/// The version lives in the subprotocol name rather than in the URL, for the reason ADR 0002 gives
/// about the rest of this API: a client and a server that upgrade independently have to agree by
/// negotiating rather than by assuming, and a WebSocket handshake already has a field for exactly
/// that. A server that does not offer <see cref="SubProtocol"/> fails the handshake, which a client
/// can act on — rather than opening a socket that then speaks a dialect it cannot read.
/// </remarks>
public static class VaultEvents
{
/// <summary>The path the event socket is served from.</summary>
public const string Path = "/api/v1/events";
/// <summary>The only subprotocol this version speaks.</summary>
public const string SubProtocol = "dodossh.events.v1";
/// <summary>The <c>/api/v1/meta</c> feature flag advertising that this server pushes at all.</summary>
public const string Feature = "events";
/// <summary>
/// Close code for a socket whose access token has expired.
/// </summary>
/// <remarks>
/// In the 40004999 range, which the WebSocket specification reserves for applications. Its own
/// code because it is the one close a client should answer by reconnecting immediately with a
/// fresh token, rather than by backing off as it would for a server that went away.
/// </remarks>
public const int TokenExpiredCloseCode = 4401;
/// <summary>
/// Close code for a caller already holding as many sockets as it may.
/// </summary>
/// <remarks>
/// Distinguished from <see cref="TokenExpiredCloseCode"/> because the remedy is the opposite:
/// reconnecting at once is what caused it. A client that meets this backs off and keeps polling.
/// </remarks>
public const int TooManyConnectionsCloseCode = 4429;
}
/// <summary>
/// The kinds of event this socket carries.
/// </summary>
/// <remarks>
/// <para>
/// Constants rather than an <c>enum</c>, and that is a compatibility decision rather than a style
/// one. <c>DodoSshJsonContext</c> sets <c>UseStringEnumConverter</c>, which <em>throws</em> on a
/// value it does not know — so a newer server sending a kind an older client has never heard of
/// would not merely add an unreadable frame, it would break that client's socket. A string is
/// ignored instead, which is what makes this list extensible. <see cref="ProblemCodes"/> is the same
/// shape for the same reason.
/// </para>
/// <para>
/// <b>Anything a client cannot parse must be skipped, not treated as an error.</b> That rule is what
/// the shared-session frames of ADR 0012 will rely on when they arrive.
/// </para>
/// </remarks>
public static class VaultEventKinds
{
/// <summary>The server accepted the socket. Always the first frame.</summary>
public const string Hello = "hello";
/// <summary>
/// A vault has changes at or before <see cref="VaultEvent.Sequence"/>.
/// </summary>
/// <remarks>
/// Carries no ciphertext and no item identity — the client's answer is the delta pull it would
/// have run on its timer anyway. See ADR 0012 for why pushing the items themselves is refused.
/// </remarks>
public const string VaultChanged = "vault.changed";
/// <summary>
/// The set of vaults this account can reach is no longer what it was.
/// </summary>
/// <remarks>
/// A vault shared with the caller, or a grant withdrawn. Deliberately says nothing about
/// <em>which</em>: the client re-reads the list, which is the same call it already makes at the
/// start of every synchronisation pass.
/// </remarks>
public const string VaultsChanged = "vaults.changed";
/// <summary>Heartbeat. Whichever side receives one answers <see cref="Pong"/>.</summary>
public const string Ping = "ping";
/// <summary>The answer to a <see cref="Ping"/>.</summary>
public const string Pong = "pong";
}
/// <summary>
/// One frame on the event socket.
/// </summary>
/// <remarks>
/// <para>
/// One flat record for every kind, with the fields a given kind does not use left null, rather than
/// a polymorphic hierarchy. The set is small, the frames are tiny, and <c>System.Text.Json</c>
/// polymorphism would put a second discriminator mechanism next to the <see cref="Kind"/> string
/// that is already the discriminator. Nothing else in <c>DodoSSH.Contracts</c> is polymorphic.
/// </para>
/// <para>
/// <b>Nothing here is secret, by construction.</b> The server cannot read a vault's contents, so a
/// notice cannot describe them; what it does disclose — that a vault changed, and when — is the same
/// metadata ADR 0001 already accepts the server holding.
/// </para>
/// </remarks>
/// <param name="Kind">One of <see cref="VaultEventKinds"/>. An unrecognised kind must be ignored.</param>
/// <param name="VaultId">The vault a <see cref="VaultEventKinds.VaultChanged"/> is about.</param>
/// <param name="Sequence">
/// The change-log position that vault has reached. A hint for logging and for coalescing, not a
/// cursor: cursors are opaque and HMAC-tagged, and this is neither.
/// </param>
/// <param name="ServerTime">
/// The server's clock when the frame was written. The client already measures skew against
/// <c>SyncPullResponse.ServerTime</c>; this lets a socket that is quiet for other reasons keep that
/// measurement current.
/// </param>
/// <param name="HeartbeatSeconds">
/// How often the server will ping, sent with <see cref="VaultEventKinds.Hello"/>. The client uses it
/// to decide when silence means the connection is dead rather than idle.
/// </param>
/// <param name="VaultCount">
/// How many vaults this socket is subscribed to, sent with <see cref="VaultEventKinds.Hello"/>.
/// Diagnostic: a socket subscribed to nothing is a real state — an account with no vaults yet — and
/// is otherwise indistinguishable from one that is quietly broken.
/// </param>
public sealed record VaultEvent(
string Kind,
Guid? VaultId = null,
long? Sequence = null,
DateTimeOffset? ServerTime = null,
int? HeartbeatSeconds = null,
int? VaultCount = null);
+10
View File
@@ -66,6 +66,16 @@ public static class ProblemCodes
/// </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>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
public const string RelayTargetRejected = "relay-target-rejected";
@@ -2,6 +2,7 @@
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
const DodoSSH.Contracts.ProblemCodes.IdempotencyKeyReuse = "idempotency-key-reuse" -> string!
const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-invalid" -> string!
@@ -22,6 +23,16 @@ const DodoSSH.Contracts.ProblemCodes.TeamNotEmpty = "team-not-empty" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
const DodoSSH.Contracts.VaultEventKinds.Hello = "hello" -> string!
const DodoSSH.Contracts.VaultEventKinds.Ping = "ping" -> string!
const DodoSSH.Contracts.VaultEventKinds.Pong = "pong" -> string!
const DodoSSH.Contracts.VaultEventKinds.VaultChanged = "vault.changed" -> string!
const DodoSSH.Contracts.VaultEventKinds.VaultsChanged = "vaults.changed" -> string!
const DodoSSH.Contracts.VaultEvents.Feature = "events" -> string!
const DodoSSH.Contracts.VaultEvents.Path = "/api/v1/events" -> string!
const DodoSSH.Contracts.VaultEvents.SubProtocol = "dodossh.events.v1" -> string!
const DodoSSH.Contracts.VaultEvents.TokenExpiredCloseCode = 4401 -> int
const DodoSSH.Contracts.VaultEvents.TooManyConnectionsCloseCode = 4429 -> int
DodoSSH.Contracts.AddTeamMemberRequest
DodoSSH.Contracts.AddTeamMemberRequest.<Clone>$() -> DodoSSH.Contracts.AddTeamMemberRequest!
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role, string? Email = null) -> void
@@ -686,6 +697,25 @@ DodoSSH.Contracts.UpdateVaultRequest.Equals(DodoSSH.Contracts.UpdateVaultRequest
DodoSSH.Contracts.UpdateVaultRequest.Name.get -> string!
DodoSSH.Contracts.UpdateVaultRequest.Name.init -> void
DodoSSH.Contracts.UpdateVaultRequest.UpdateVaultRequest(string! Name) -> void
DodoSSH.Contracts.VaultEvent
DodoSSH.Contracts.VaultEvent.<Clone>$() -> DodoSSH.Contracts.VaultEvent!
DodoSSH.Contracts.VaultEvent.Deconstruct(out string! Kind, out System.Guid? VaultId, out long? Sequence, out System.DateTimeOffset? ServerTime, out int? HeartbeatSeconds, out int? VaultCount) -> void
DodoSSH.Contracts.VaultEvent.Equals(DodoSSH.Contracts.VaultEvent? other) -> bool
DodoSSH.Contracts.VaultEvent.HeartbeatSeconds.get -> int?
DodoSSH.Contracts.VaultEvent.HeartbeatSeconds.init -> void
DodoSSH.Contracts.VaultEvent.Kind.get -> string!
DodoSSH.Contracts.VaultEvent.Kind.init -> void
DodoSSH.Contracts.VaultEvent.Sequence.get -> long?
DodoSSH.Contracts.VaultEvent.Sequence.init -> void
DodoSSH.Contracts.VaultEvent.ServerTime.get -> System.DateTimeOffset?
DodoSSH.Contracts.VaultEvent.ServerTime.init -> void
DodoSSH.Contracts.VaultEvent.VaultCount.get -> int?
DodoSSH.Contracts.VaultEvent.VaultCount.init -> void
DodoSSH.Contracts.VaultEvent.VaultEvent(string! Kind, System.Guid? VaultId = null, long? Sequence = null, System.DateTimeOffset? ServerTime = null, int? HeartbeatSeconds = null, int? VaultCount = null) -> void
DodoSSH.Contracts.VaultEvent.VaultId.get -> System.Guid?
DodoSSH.Contracts.VaultEvent.VaultId.init -> void
DodoSSH.Contracts.VaultEventKinds
DodoSSH.Contracts.VaultEvents
DodoSSH.Contracts.VaultGrantsResponse
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
@@ -877,6 +907,9 @@ override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string!
override DodoSSH.Contracts.UpdateVaultRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.UpdateVaultRequest.GetHashCode() -> int
override DodoSSH.Contracts.UpdateVaultRequest.ToString() -> string!
override DodoSSH.Contracts.VaultEvent.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultEvent.GetHashCode() -> int
override DodoSSH.Contracts.VaultEvent.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
@@ -972,6 +1005,8 @@ static DodoSSH.Contracts.UpdateTeamRequest.operator !=(DodoSSH.Contracts.UpdateT
static DodoSSH.Contracts.UpdateTeamRequest.operator ==(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
static DodoSSH.Contracts.UpdateVaultRequest.operator !=(DodoSSH.Contracts.UpdateVaultRequest? left, DodoSSH.Contracts.UpdateVaultRequest? right) -> bool
static DodoSSH.Contracts.UpdateVaultRequest.operator ==(DodoSSH.Contracts.UpdateVaultRequest? left, DodoSSH.Contracts.UpdateVaultRequest? right) -> bool
static DodoSSH.Contracts.VaultEvent.operator !=(DodoSSH.Contracts.VaultEvent? left, DodoSSH.Contracts.VaultEvent? right) -> bool
static DodoSSH.Contracts.VaultEvent.operator ==(DodoSSH.Contracts.VaultEvent? left, DodoSSH.Contracts.VaultEvent? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool