using System.Collections.Concurrent; namespace LarpixVoice; public class Program { public static ConcurrentDictionary> Rooms = new (); // Per-IP rate limit for the unauthenticated /room/users endpoint: legit // clients poll roughly once per second while in a call, so the default of // 120/min per IP (ROOM_USERS_RATE_LIMIT) leaves ample headroom (incl. // several users behind one NAT) while making bulk room-enumeration scans // impractical. Fixed 60s window: (request count, window reset unix time). // Entries are pruned in the 1s loop below. public static ConcurrentDictionary UsersListLimits = new (); // Sesja bez ODBIERANEGO ruchu dłużej niż to = martwy TCP (zabita aplikacja, // utracony mapping NAT). Klient pinguje co 2 s - 15 s to 7+ przegapionych // pingów, więc fałszywe pozytywy przy chwilowych zaniżeniach sieci są // praktycznie niemożliwe, a ghost nie zaśmieca rosteru dłużej niż trzeba. public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(15); public static ulong TotalBytesSent = 0; public static ulong TotalBytesReceived = 0; private static ulong CurrentBandwidthKps = 0; // Zamiata sesje bez odebranego ruchu dłużej niż SessionIdleTimeout. Bez // tego zabita aplikacja (half-open TCP, brak Close frame) zostawała w // rosterze pokoju na zawsze: /room/users zwracało ghostów -> ghost kafelki // i "trwa rozmowa" u innych uczestników. TryRemove(KeyValuePair) jest // warunkowe po wartości - nie da się przypadkiem usunąć NOWEJ sesji, która // w międzyczasie nadpisała wpis tego samego userId (reconnect). private static void SweepStaleSessions() { DateTime cutoff = DateTime.UtcNow - SessionIdleTimeout; foreach (var room in Rooms) { foreach (var kvp in room.Value) { if (kvp.Value.LastSeenUtc >= cutoff) continue; if (!room.Value.TryRemove(new KeyValuePair(kvp.Key, kvp.Value))) continue; //Zamknięcie kolejki kończy task-pump; Abort wywala zawieszone //ReceiveAsync/SendAsync martwego socketu -> receive-loop wejdzie //we swój finally i posprząta bufor (guard session==currentSession //zapobiega podwójnemu sprzątaniu) kvp.Value.SendQueue.Writer.TryComplete(); try { kvp.Value.Socket.Abort(); } catch { } Console.WriteLine($"Voice: swept idle session room={room.Key} user={kvp.Key}"); } if (room.Value.IsEmpty) Rooms.TryRemove(room.Key, out _); } } public static void Main(string[] args) { Utils.LoadEnv(); Task.Run(async () => { while (true) { await Task.Delay(1000); ulong sentDelta = Interlocked.Read(ref TotalBytesSent); ulong receivedDelta = Interlocked.Read(ref TotalBytesReceived); Interlocked.Exchange(ref TotalBytesReceived, 0); Interlocked.Exchange(ref TotalBytesSent, 0); CurrentBandwidthKps = (sentDelta + receivedDelta); long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); foreach (var entry in UsersListLimits) { if (entry.Value.resetAt <= nowSec) UsersListLimits.TryRemove(entry.Key, out _); } SweepStaleSessions(); } }); var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.AddServerHeader = false; serverOptions.ListenAnyIP(Utils.PORT); }); var app = builder.Build(); app.UseWebSockets(); app.Use(async (HttpContext context, Func next) => { try { context.Response.StatusCode = 200; var path = context.Request.Path.Value; var url = $"{context.Request.Scheme}://{context.Request.Host}"; if (path == "" || path?.Length > 512) { context.Response.Redirect(url + "/"); return; } context.Response.Headers["Access-Control-Allow-Origin"] = "*";//(context.Request.Headers["Origin"]).ToString().Replace("http://", "https://"); context.Response.Headers["Access-Control-Allow-Methods"]= "GET, POST, PUT, DELETE, OPTIONS"; context.Response.Headers["Access-Control-Allow-Headers"]= "Content-Type, Authorization"; context.Response.Headers["Access-Control-Allow-Credentials"]= "true"; switch (path) { case "/ws": await Requests.Websocket(context); return; case "/load": await context.Response.WriteAsync(Interlocked.Read(ref CurrentBandwidthKps).ToString()); return; case "/room/users": string callerIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var window = UsersListLimits.AddOrUpdate(callerIp, (1, nowSec + 60), (_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt)); if (window.count > Utils.ROOM_USERS_RATE_LIMIT) { context.Response.StatusCode = 429; return; } string roomId = context.Request.Query["room"].ToString(); context.Response.ContentType = "application/json"; if (Rooms.TryGetValue(roomId, out var room)) { string[] users = room.Keys.Select(k => k.ToString()).ToArray(); await context.Response.WriteAsJsonAsync(users, JsonAppSerializerContext.Default.StringArray); } else { await context.Response.WriteAsJsonAsync(Array.Empty(), JsonAppSerializerContext.Default.StringArray); } return; default: await next(); return; } } catch (Exception) { } }); Console.WriteLine("Starting server at port: " + Utils.PORT); app.Run(); } }