using DodoSSH.Api.Setup; using Microsoft.Extensions.Options; namespace DodoSSH.Api.Features.Relay; /// /// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits. /// /// /// /// Per node, and so per user per node: a user behind a round-robin balancer with three nodes /// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means /// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a /// node — which a per-node count does exactly. /// /// /// A singleton, because a session outlives the request that opened it. It holds counts and nothing else; /// the sockets belong to the handlers that opened them. /// /// internal sealed class RelaySessionRegistry(IOptions options) { private readonly Lock gate = new(); private readonly Dictionary perUser = []; private readonly HashSet open = []; private int total; /// How many sessions are open on this node. internal int Count { get { lock (gate) { return total; } } } /// Whether a session for this user would be admitted now. /// /// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status /// before it has spent a ticket, but only decides, since the answer here can /// be stale by the time the socket arrives — possibly at another node. /// internal bool HasRoomFor(Guid userId) { var settings = options.Value; lock (gate) { return total < settings.MaxConcurrentSessionsTotal && perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser; } } /// Admits a session, or answers null when a limit is met. /// A lease that gives the place back when disposed. /// The sessions a user has open now, for re-checking their access. internal IReadOnlyList SessionsOf(Guid userId) { lock (gate) { return [.. open.Where(lease => lease.UserId == userId)]; } } internal Lease? TryAdmit(Guid userId, Guid vaultId) { var settings = options.Value; lock (gate) { var held = perUser.GetValueOrDefault(userId); if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser) { return null; } perUser[userId] = held + 1; total++; var lease = new Lease(this, userId, vaultId); open.Add(lease); return lease; } } private void Release(Lease lease, Guid userId) { lock (gate) { open.Remove(lease); total--; // Removed at zero rather than left behind, so the dictionary is the size of the users with a // session open and not of every user who ever had one. if (perUser[userId] <= 1) { perUser.Remove(userId); } else { perUser[userId]--; } } } /// One admitted session's place. Idempotent to dispose. internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable { private readonly CancellationTokenSource revoked = new(); private int released; /// Whose session this is. internal Guid UserId { get; } = userId; /// The vault the ticket's host is filed in, which is what the session's access hangs on. internal Guid VaultId { get; } = vaultId; /// Cancelled when the session's user loses access to . internal CancellationToken Revoked => revoked.Token; /// Ends the session, because its user can no longer reach the vault it was opened for. internal void Revoke() { try { revoked.Cancel(); } catch (ObjectDisposedException) { // Already over. } } /// public void Dispose() { if (Interlocked.Exchange(ref released, 1) == 0) { registry.Release(this, UserId); revoked.Dispose(); } } } }