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 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. internal Lease? TryAdmit(Guid userId) { 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++; return new Lease(this, userId); } } private void Release(Guid userId) { lock (gate) { 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) : IDisposable { private int released; /// public void Dispose() { if (Interlocked.Exchange(ref released, 1) == 0) { registry.Release(userId); } } } }