From 197b3ce986361a7364acd8b3e6082d160329fa36 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Wed, 18 Mar 2026 22:41:10 +0200 Subject: [PATCH 1/6] feat(MMS): add WebSocket endpoints and restructure service layer - Swapped to join sessions - Add WebSocket endpoint and connection management - Reorganize lobby/matchmaking services into subfolders - Update bootstrap, contracts, and endpoint routing - Remove obsolete top-level service files (moved to subfolders) --- MMS/Bootstrap/HttpsCertificateConfigurator.cs | 108 ++++ MMS/Bootstrap/ProgramState.cs | 18 + MMS/Bootstrap/ServiceCollectionExtensions.cs | 202 ++++++ MMS/Bootstrap/WebApplicationExtensions.cs | 37 ++ MMS/Contracts/Requests.cs | 46 ++ MMS/Contracts/Responses.cs | 93 +++ .../EndpointRouteBuilderExtensions.cs | 26 + MMS/Features/Health/HealthEndpoints.cs | 28 + MMS/Features/Lobby/LobbyEndpointHandlers.cs | 284 +++++++++ MMS/Features/Lobby/LobbyEndpoints.cs | 56 ++ .../MatchmakingVersionValidation.cs | 25 + MMS/Features/WebSockets/WebSocketEndpoints.cs | 301 +++++++++ MMS/Http/EndpointBuilder.cs | 138 ++++ MMS/Models/{ => Lobby}/Lobby.cs | 16 +- .../DiscoveryTokenMetadata.cs | 14 +- MMS/Models/Matchmaking/JoinSession.cs | 43 ++ MMS/Models/MatchmakingProtocol.cs | 21 + MMS/Program.cs | 597 +----------------- MMS/Services/Lobby/LobbyCleanupService.cs | 34 + MMS/Services/{ => Lobby}/LobbyNameService.cs | 7 +- MMS/Services/Lobby/LobbyService.cs | 238 +++++++ MMS/Services/LobbyCleanupService.cs | 17 - MMS/Services/LobbyService.cs | 264 -------- .../Matchmaking/JoinSessionCoordinator.cs | 375 +++++++++++ .../Matchmaking/JoinSessionMessenger.cs | 195 ++++++ .../Matchmaking/JoinSessionService.cs | 73 +++ MMS/Services/Matchmaking/JoinSessionStore.cs | 140 ++++ MMS/Services/Network/UdpDiscoveryService.cs | 94 +++ MMS/Services/Network/WebSocketManager.cs | 26 + MMS/Services/UdpDiscoveryService.cs | 114 ---- MMS/Services/Utility/TokenGenerator.cs | 55 ++ 31 files changed, 2687 insertions(+), 998 deletions(-) create mode 100644 MMS/Bootstrap/HttpsCertificateConfigurator.cs create mode 100644 MMS/Bootstrap/ProgramState.cs create mode 100644 MMS/Bootstrap/ServiceCollectionExtensions.cs create mode 100644 MMS/Bootstrap/WebApplicationExtensions.cs create mode 100644 MMS/Contracts/Requests.cs create mode 100644 MMS/Contracts/Responses.cs create mode 100644 MMS/Features/EndpointRouteBuilderExtensions.cs create mode 100644 MMS/Features/Health/HealthEndpoints.cs create mode 100644 MMS/Features/Lobby/LobbyEndpointHandlers.cs create mode 100644 MMS/Features/Lobby/LobbyEndpoints.cs create mode 100644 MMS/Features/Matchmaking/MatchmakingVersionValidation.cs create mode 100644 MMS/Features/WebSockets/WebSocketEndpoints.cs create mode 100644 MMS/Http/EndpointBuilder.cs rename MMS/Models/{ => Lobby}/Lobby.cs (83%) rename MMS/Models/{ => Matchmaking}/DiscoveryTokenMetadata.cs (65%) create mode 100644 MMS/Models/Matchmaking/JoinSession.cs create mode 100644 MMS/Models/MatchmakingProtocol.cs create mode 100644 MMS/Services/Lobby/LobbyCleanupService.cs rename MMS/Services/{ => Lobby}/LobbyNameService.cs (95%) create mode 100644 MMS/Services/Lobby/LobbyService.cs delete mode 100644 MMS/Services/LobbyCleanupService.cs delete mode 100644 MMS/Services/LobbyService.cs create mode 100644 MMS/Services/Matchmaking/JoinSessionCoordinator.cs create mode 100644 MMS/Services/Matchmaking/JoinSessionMessenger.cs create mode 100644 MMS/Services/Matchmaking/JoinSessionService.cs create mode 100644 MMS/Services/Matchmaking/JoinSessionStore.cs create mode 100644 MMS/Services/Network/UdpDiscoveryService.cs create mode 100644 MMS/Services/Network/WebSocketManager.cs delete mode 100644 MMS/Services/UdpDiscoveryService.cs create mode 100644 MMS/Services/Utility/TokenGenerator.cs diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs new file mode 100644 index 0000000..240f02c --- /dev/null +++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace MMS.Bootstrap; + +/// +/// Configures Kestrel HTTPS bindings from PEM certificate files in the working directory. +/// +internal static class HttpsCertificateConfigurator { + private const string CertFile = "cert.pem"; + private const string KeyFile = "key.pem"; + + private static readonly ILogger Logger; + + static HttpsCertificateConfigurator() { + using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(o => { + o.SingleLine = true; + o.IncludeScopes = false; + o.TimestampFormat = "HH:mm:ss "; + } + ) + ); + Logger = loggerFactory.CreateLogger(nameof(HttpsCertificateConfigurator)); + } + + /// + /// Reads cert.pem and key.pem from the working directory and configures + /// Kestrel to terminate TLS with that certificate on port 5000. + /// + /// The web application builder to configure. + /// + /// if the certificate was loaded and Kestrel was configured; + /// if either file is missing, unreadable, or malformed. + /// + public static bool TryConfigure(WebApplicationBuilder builder) { + if (!TryReadPemFiles(out var pem, out var key)) + return false; + + if (!TryCreateCertificate(pem, key, out var certificate)) + return false; + + builder.WebHost.ConfigureKestrel(server => + server.ListenAnyIP(5000, listen => listen.UseHttps(certificate!)) + ); + + return true; + } + + /// + /// Reads the PEM certificate and key files from the working directory. + /// + /// The contents of cert.pem if successful. + /// The contents of key.pem if successful. + /// + /// if both files were read successfully; otherwise . + /// + private static bool TryReadPemFiles(out string pem, out string key) { + pem = key = string.Empty; + + if (!File.Exists(CertFile)) { + Logger.LogError("Certificate file '{File}' does not exist", CertFile); + return false; + } + + if (!File.Exists(KeyFile)) { + Logger.LogError("Key file '{File}' does not exist", KeyFile); + return false; + } + + try { + pem = File.ReadAllText(CertFile); + key = File.ReadAllText(KeyFile); + return true; + } catch (Exception e) { + Logger.LogError(e, "Could not read '{CertFile}' or '{KeyFile}'", CertFile, KeyFile); + return false; + } + } + + /// + /// Attempts to construct an from PEM-encoded certificate + /// and key material. + /// + /// The PEM-encoded certificate. + /// The PEM-encoded private key. + /// The resulting certificate if successful; otherwise . + /// + /// if the certificate was created successfully otherwise . + /// + private static bool TryCreateCertificate(string pem, string key, out X509Certificate2? certificate) { + certificate = null; + try { + using var ephemeralCertificate = X509Certificate2.CreateFromPem(pem, key); + var pkcs12 = ephemeralCertificate.Export(X509ContentType.Pkcs12); + certificate = X509CertificateLoader.LoadPkcs12( + pkcs12, + password: (string?) null, + X509KeyStorageFlags.PersistKeySet | + X509KeyStorageFlags.MachineKeySet | + X509KeyStorageFlags.Exportable + ); + return true; + } catch (CryptographicException e) { + Logger.LogError(e, "Could not create certificate from PEM files"); + return false; + } + } +} diff --git a/MMS/Bootstrap/ProgramState.cs b/MMS/Bootstrap/ProgramState.cs new file mode 100644 index 0000000..2cee46f --- /dev/null +++ b/MMS/Bootstrap/ProgramState.cs @@ -0,0 +1,18 @@ +namespace MMS.Bootstrap; + +/// +/// Stores runtime application state that needs to be shared across startup helpers and endpoint mappings. +/// +internal static class ProgramState { + /// + /// Gets or sets a value indicating whether the application is running in a development environment. + /// + public static bool IsDevelopment { get; internal set; } + + /// + /// Gets or sets the application-level logger after the host has been built. + /// + public static ILogger Logger { get; internal set; } = null!; + + public static int DiscoveryPort => 5001; +} diff --git a/MMS/Bootstrap/ServiceCollectionExtensions.cs b/MMS/Bootstrap/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..934cc1d --- /dev/null +++ b/MMS/Bootstrap/ServiceCollectionExtensions.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using MMS.Services.Lobby; +using MMS.Services.Matchmaking; +using MMS.Services.Network; +using static MMS.Contracts.Responses; + +namespace MMS.Bootstrap; + +/// +/// Extension methods for registering MMS services and infrastructure concerns. +/// +internal static class ServiceCollectionExtensions { + /// + /// Registers MMS application services and hosted background services. + /// + /// The service collection being configured. + public static void AddMmsCoreServices(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + } + + /// + /// Registers logging, forwarded headers, HTTP logging, and rate limiting for MMS. + /// + /// The service collection being configured. + /// The application configuration, used to bind infrastructure settings such as forwarded header options. + /// Whether the app is running in development. + public static void AddMmsInfrastructure( + this IServiceCollection services, + IConfiguration configuration, + bool isDevelopment + ) { + services.AddMmsLogging(isDevelopment); + services.AddMmsForwardedHeaders(configuration); + services.AddMmsRateLimiting(); + } + + /// + /// Configures structured console logging. + /// Enables HTTP request logging when running in development. + /// + /// The service collection being configured. + /// Whether the app is running in development. + private static void AddMmsLogging(this IServiceCollection services, bool isDevelopment) { + services.AddLogging(builder => { + builder.ClearProviders(); + builder.AddSimpleConsole(options => { + options.SingleLine = true; + options.IncludeScopes = false; + options.TimestampFormat = "HH:mm:ss "; + } + ); + } + ); + + if (isDevelopment) + services.AddHttpLogging(_ => { }); + } + + /// + /// Configures forwarded header processing for reverse proxy support. + /// Enables forwarding of X-Forwarded-For, X-Forwarded-Host, + /// and X-Forwarded-Proto headers. + /// + /// The service collection being configured. + /// + /// The application configuration. Reads ForwardedHeaders:KnownProxies as an array of IP address strings + /// and ForwardedHeaders:KnownNetworks as an array of CIDR notation strings to populate + /// and respectively. + /// + private static void AddMmsForwardedHeaders(this IServiceCollection services, IConfiguration configuration) { + services.Configure(options => { + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedHost | + ForwardedHeaders.XForwardedProto; + + foreach (var proxy in configuration.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) { + if (IPAddress.TryParse(proxy, out var address)) + options.KnownProxies.Add(address); + } + + foreach (var network in configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? + []) { + if (!TryParseNetwork(network, out var ipNetwork)) + continue; + + options.KnownNetworks.Add(ipNetwork); + } + } + ); + } + + /// + /// Attempts to parse a CIDR notation string into an . + /// + /// The CIDR string to parse, expected in the format address/prefixLength (e.g. 192.168.1.0/24). + /// + /// When this method returns , contains the parsed ; + /// otherwise, the default value. + /// + /// + /// if was successfully parsed; otherwise, . + /// + private static bool TryParseNetwork(string value, out Microsoft.AspNetCore.HttpOverrides.IPNetwork network) { + network = null!; + + var slashIndex = value.IndexOf('/'); + if (slashIndex <= 0 || slashIndex >= value.Length - 1) + return false; + + var prefixText = value[(slashIndex + 1)..]; + if (!IPAddress.TryParse(value[..slashIndex], out var prefix) || + !int.TryParse(prefixText, out var prefixLength)) { + return false; + } + + network = new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, prefixLength); + return true; + } + + /// + /// Registers IP-based fixed-window rate limiting policies for all MMS endpoints. + /// Rejected requests receive a 429 Too Many Requests response. + /// + /// + /// Policies: + /// + /// create5 requests per 30 seconds. + /// search10 requests per 10 seconds. + /// join5 requests per 30 seconds. + /// + /// + /// The service collection being configured. + private static void AddMmsRateLimiting(this IServiceCollection services) { + services.AddRateLimiter(options => { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, token) => { + await context.HttpContext.Response.WriteAsJsonAsync( + new ErrorResponse("Too many requests. Please try again later."), + cancellationToken: token + ); + }; + + options.AddFixedWindowPolicy("create", permitLimit: 5, windowSeconds: 30); + options.AddFixedWindowPolicy("search", permitLimit: 10, windowSeconds: 10); + options.AddFixedWindowPolicy("join", permitLimit: 5, windowSeconds: 30); + } + ); + } + + /// + /// Adds a named IP-keyed fixed-window rate limiter policy to the rate limiter options. + /// + /// The rate limiter options to configure. + /// The name used to reference this policy on endpoints. + /// Maximum number of requests allowed per window. + /// Duration of the rate limit window in seconds. + private static void AddFixedWindowPolicy( + this RateLimiterOptions options, + string policyName, + int permitLimit, + int windowSeconds + ) { + options.AddPolicy( + policyName, + context => RateLimitPartition.GetFixedWindowLimiter( + partitionKey: GetRateLimitPartitionKey(context), + factory: _ => new FixedWindowRateLimiterOptions { + PermitLimit = permitLimit, + Window = TimeSpan.FromSeconds(windowSeconds), + QueueLimit = 0 + } + ) + ); + } + + /// + /// Determines the rate limit partition key for an HTTP request. + /// Uses the first IP address from the X-Forwarded-For header when present, + /// falling back to , then . + /// + /// The current HTTP context. + /// A string key identifying the client for rate limiting purposes. + private static string GetRateLimitPartitionKey(HttpContext context) { + var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(); + if (!string.IsNullOrWhiteSpace(forwardedFor)) + return forwardedFor.Split(',')[0].Trim(); + + return context.Connection.RemoteIpAddress?.ToString() + ?? context.Connection.Id; + } +} diff --git a/MMS/Bootstrap/WebApplicationExtensions.cs b/MMS/Bootstrap/WebApplicationExtensions.cs new file mode 100644 index 0000000..305cf4f --- /dev/null +++ b/MMS/Bootstrap/WebApplicationExtensions.cs @@ -0,0 +1,37 @@ +namespace MMS.Bootstrap; + +/// +/// Extension methods for configuring the MMS middleware pipeline. +/// +internal static class WebApplicationExtensions +{ + /// + /// Applies the MMS middleware pipeline and binds the listener URL. + /// + /// The web application to configure. + /// Whether the app is running in development. + /// The same web application for chaining. + public static void UseMmsPipeline(this WebApplication app, bool isDevelopment) + { + if (isDevelopment) + app.UseHttpLogging(); + else + app.UseExceptionHandler("/error"); + + app.UseForwardedHeaders(); + app.UseRateLimiter(); + app.UseWebSockets(); + app.Urls.Add(isDevelopment ? "http://0.0.0.0:5000" : "https://0.0.0.0:5000"); + } + + /// + /// Configures HTTPS for MMS when not running in development. + /// + /// The web application builder to configure. + /// Whether the app is running in development. + /// + /// when startup can continue; otherwise . + /// + public static bool TryConfigureMmsHttps(this WebApplicationBuilder builder, bool isDevelopment) => + isDevelopment || HttpsCertificateConfigurator.TryConfigure(builder); +} diff --git a/MMS/Contracts/Requests.cs b/MMS/Contracts/Requests.cs new file mode 100644 index 0000000..45bddb1 --- /dev/null +++ b/MMS/Contracts/Requests.cs @@ -0,0 +1,46 @@ +using JetBrains.Annotations; + +namespace MMS.Contracts; + +/// +/// Request DTOs accepted by the MMS HTTP API. +/// +internal static class Requests +{ + /// + /// Request payload for lobby creation. + /// + /// Host IP address (matchmaking only, optional - defaults to connection IP). + /// Host UDP port (matchmaking only). + /// Steam lobby ID (Steam only). + /// "steam" or "matchmaking" (default: "matchmaking"). + /// Host LAN address for same-network fast-path discovery. + /// Whether the lobby appears in public browser listings (default: ). + /// Client matchmaking protocol version for compatibility checks. + [UsedImplicitly] + internal record CreateLobbyRequest( + string? HostIp, + int? HostPort, + string? ConnectionData, + string? LobbyType, + string? HostLanIp, + bool? IsPublic, + int? MatchmakingVersion + ); + + /// + /// Request payload for a lobby join attempt. + /// + /// Client IP override (optional - defaults to the connection's remote IP). + /// Client UDP port for NAT hole-punching. + /// Client matchmaking protocol version for compatibility checks. + [UsedImplicitly] + internal record JoinLobbyRequest(string? ClientIp, int ClientPort, int? MatchmakingVersion); + + /// + /// Request payload for a lobby heartbeat. + /// + /// Number of remote players currently connected to the host. + [UsedImplicitly] + internal record HeartbeatRequest(int ConnectedPlayers); +} diff --git a/MMS/Contracts/Responses.cs b/MMS/Contracts/Responses.cs new file mode 100644 index 0000000..ef61911 --- /dev/null +++ b/MMS/Contracts/Responses.cs @@ -0,0 +1,93 @@ +using JetBrains.Annotations; + +namespace MMS.Contracts; + +/// +/// Response DTOs returned by the MMS HTTP API. +/// +internal static class Responses +{ + /// + /// Response payload returned by the health check endpoint. + /// + /// The service name. + /// The current matchmaking protocol version. + /// Static health status string (e.g. "healthy"). + [UsedImplicitly] + internal record HealthResponse(string Service, int Version, string Status); + + /// + /// Response payload returned when a lobby is created. + /// + /// Connection identifier (IP:Port or Steam lobby ID). + /// Secret token required for host operations (heartbeat, close). + /// Display name assigned to the lobby. + /// Human-readable invite code (e.g. ABC123). + /// Token the host sends via UDP so MMS can map its external port. + [UsedImplicitly] + internal record CreateLobbyResponse( + string ConnectionData, + string HostToken, + string LobbyName, + string LobbyCode, + string? HostDiscoveryToken + ); + + /// + /// Response payload returned when listing lobbies. + /// + /// Connection identifier (IP:Port or Steam lobby ID). + /// Display name. + /// "steam" or "matchmaking". + /// Human-readable invite code. + [UsedImplicitly] + internal record LobbyResponse( + string ConnectionData, + string Name, + string LobbyType, + string LobbyCode + ); + + /// + /// Response payload returned for join attempts. + /// + /// Host connection data (IP:Port or Steam lobby ID). + /// "steam" or "matchmaking". + /// Client public IP as observed by MMS. + /// Client public port as observed by MMS. + /// Host LAN address returned when client and host share a network. + /// Token the client sends via UDP so MMS can map its external port. + /// Identifier for the WebSocket rendezvous session. + [UsedImplicitly] + internal record JoinResponse( + string ConnectionData, + string LobbyType, + string ClientIp, + int ClientPort, + string? LanConnectionData, + string? ClientDiscoveryToken, + string? JoinId + ); + + /// + /// Response payload returned when a discovery token has resolved to an external port. + /// + /// The external UDP port discovered for the token sender. + [UsedImplicitly] + internal record DiscoveryResponse(int ExternalPort); + + /// + /// Response payload used for API errors. + /// + /// Human-readable error description. + /// Optional machine-readable error code (e.g. "update_required"). + [UsedImplicitly] + internal record ErrorResponse(string Error, string? ErrorCode = null); + + /// + /// Response payload used for simple status responses. + /// + /// Short status string (e.g. "alive", "pending"). + [UsedImplicitly] + internal record StatusResponse(string Status); +} diff --git a/MMS/Features/EndpointRouteBuilderExtensions.cs b/MMS/Features/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..e7dfd18 --- /dev/null +++ b/MMS/Features/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,26 @@ +using MMS.Features.Health; +using MMS.Features.Lobby; +using MMS.Features.WebSockets; + +namespace MMS.Features; + +/// +/// Composes all MMS endpoint groups onto the web application. +/// +internal static class EndpointRouteBuilderExtensions +{ + /// + /// Maps all HTTP and WebSocket endpoints exposed by MMS. + /// + /// The web application to map endpoints onto. + public static void MapMmsEndpoints(this WebApplication app) + { + var lobby = app.MapGroup("/lobby"); + var webSockets = app.MapGroup("/ws"); + var joinWebSockets = webSockets.MapGroup("/join"); + + app.MapHealthEndpoints(); + app.MapLobbyEndpoints(lobby); + WebSocketEndpoints.MapWebSocketEndpoints(webSockets, joinWebSockets); + } +} diff --git a/MMS/Features/Health/HealthEndpoints.cs b/MMS/Features/Health/HealthEndpoints.cs new file mode 100644 index 0000000..c0bfac5 --- /dev/null +++ b/MMS/Features/Health/HealthEndpoints.cs @@ -0,0 +1,28 @@ +using MMS.Contracts; +using MMS.Models; +using MMS.Http; + +namespace MMS.Features.Health; + +/// +/// Maps health and monitoring endpoints. +/// +internal static class HealthEndpoints { + /// + /// Maps health-related MMS endpoints. + /// + /// The web application to map endpoints onto. + public static void MapHealthEndpoints(this WebApplication app) { + app.Endpoint() + .Get("/health") + .Handler(HealthCheck) + .WithName("HealthCheck") + .Build(); + } + + /// + /// Returns the service name, current matchmaking protocol version, and a static health status. + /// + private static IResult HealthCheck() => + Results.Ok(new Responses.HealthResponse("MMS", MatchmakingProtocol.CurrentVersion, "healthy")); +} diff --git a/MMS/Features/Lobby/LobbyEndpointHandlers.cs b/MMS/Features/Lobby/LobbyEndpointHandlers.cs new file mode 100644 index 0000000..d2fad17 --- /dev/null +++ b/MMS/Features/Lobby/LobbyEndpointHandlers.cs @@ -0,0 +1,284 @@ +using System.Net; +using Microsoft.AspNetCore.Http.HttpResults; +using MMS.Bootstrap; +using MMS.Features.Matchmaking; +using MMS.Models; +using MMS.Services.Lobby; +using MMS.Services.Matchmaking; +using static MMS.Contracts.Requests; +using static MMS.Contracts.Responses; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Features.Lobby; + +/// +/// Contains handler and validation logic for lobby-oriented MMS endpoints. +/// +internal static partial class LobbyEndpoints { + /// + /// Returns all lobbies, optionally filtered by type. + /// + private static IResult GetLobbies(LobbyService lobbyService, string? type = null) { + var lobbies = lobbyService.GetLobbies(type) + .Select(l => new LobbyResponse( + l.AdvertisedConnectionData, + l.LobbyName, + l.LobbyType, + l.LobbyCode + ) + ); + return TypedResults.Ok(lobbies); + } + + /// + /// Creates a new lobby (Steam or Matchmaking). + /// + private static IResult CreateLobby( + CreateLobbyRequest request, + LobbyService lobbyService, + LobbyNameService lobbyNameService, + HttpContext context + ) { + var lobbyType = request.LobbyType ?? "matchmaking"; + + if (!string.Equals(lobbyType, "steam", StringComparison.OrdinalIgnoreCase) && + !MatchmakingVersionValidation.Validate(request.MatchmakingVersion)) + return MatchmakingOutdatedResult(); + + if (!TryResolveConnectionData(request, lobbyType, context, out var connectionData, out var error)) + return error!; + + var lobbyName = lobbyNameService.GenerateLobbyName(); + var lobby = lobbyService.CreateLobby( + connectionData, + lobbyName, + lobbyType, + request.HostLanIp, + request.IsPublic ?? true + ); + + ProgramState.Logger.LogInformation( + "[LOBBY] Created: '{LobbyName}' [{LobbyType}] ({Visibility}) -> {ConnectionData} (Code: {LobbyCode})", + lobby.LobbyName, + lobby.LobbyType, + lobby.IsPublic ? "Public" : "Private", + RedactInProduction(lobby.AdvertisedConnectionData), + lobby.LobbyCode + ); + + return TypedResults.Created( + $"/lobby/{lobby.LobbyCode}", + new CreateLobbyResponse( + lobby.AdvertisedConnectionData, + lobby.HostToken, + lobby.LobbyName, + lobby.LobbyCode, + lobby.HostDiscoveryToken + ) + ); + } + + /// + /// Returns the externally discovered port for a discovery token when available. + /// + /// + /// Retained for compatibility. The active matchmaking client flow uses the WebSocket + /// rendezvous instead of polling this endpoint. + /// + private static IResult VerifyDiscovery(string token, JoinSessionService joinService) { + var port = joinService.GetDiscoveredPort(token); + return port is null + ? TypedResults.Ok(new StatusResponse("pending")) + : TypedResults.Ok(new DiscoveryResponse(port.Value)); + } + + /// + /// Closes a lobby by host token. + /// + private static Results> CloseLobby( + string token, + LobbyService lobbyService, + JoinSessionService joinService + ) { + if (!lobbyService.RemoveLobbyByToken(token, joinService.CleanupSessionsForLobby)) + return TypedResults.NotFound(new ErrorResponse("Lobby not found")); + + ProgramState.Logger.LogInformation("[LOBBY] Closed by host"); + return TypedResults.NoContent(); + } + + /// + /// Refreshes the lobby heartbeat to prevent expiration. + /// + private static Results, NotFound> Heartbeat( + string token, + HeartbeatRequest request, + LobbyService lobbyService + ) { + return lobbyService.Heartbeat(token, request.ConnectedPlayers) + ? TypedResults.Ok(new StatusResponse("alive")) + : TypedResults.NotFound(new ErrorResponse("Lobby not found")); + } + + /// + /// Registers a client join attempt, returning host connection info and rendezvous metadata. + /// + private static IResult JoinLobby( + string connectionData, + JoinLobbyRequest request, + LobbyService lobbyService, + JoinSessionService joinService, + HttpContext context + ) { + var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData); + if (lobby == null) + return TypedResults.NotFound(new ErrorResponse("Lobby not found")); + + if (string.Equals(lobby.LobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase) && + !MatchmakingVersionValidation.Validate(request.MatchmakingVersion)) + return MatchmakingOutdatedResult(); + + if (!TryResolveClientAddress(request, context, out var clientIp, out var clientIpError)) + return clientIpError!; + + ProgramState.Logger.LogInformation( + "[JOIN] {ConnectionDetails}", + ProgramState.IsDevelopment + ? $"{clientIp}:{request.ClientPort} -> {lobby.AdvertisedConnectionData}" + : $"[Redacted]:{request.ClientPort} -> [Redacted]" + ); + + var lanConnectionData = TryResolveLanConnectionData(lobby, clientIp); + + if (!string.Equals(lobby.LobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase)) { + return TypedResults.Ok( + new JoinResponse( + lobby.AdvertisedConnectionData, + lobby.LobbyType, + clientIp, + request.ClientPort, + lanConnectionData, + null, + null + ) + ); + } + + var session = joinService.CreateJoinSession(lobby, clientIp); + if (session == null) + return TypedResults.NotFound(new ErrorResponse("Lobby not found")); + + return TypedResults.Ok( + new JoinResponse( + lobby.AdvertisedConnectionData, + lobby.LobbyType, + clientIp, + request.ClientPort, + lanConnectionData, + session.ClientDiscoveryToken, + session.JoinId + ) + ); + } + + /// + /// Resolves the connectionData string for a lobby being created. + /// + private static bool TryResolveConnectionData( + CreateLobbyRequest request, + string lobbyType, + HttpContext context, + out string connectionData, + out IResult? error + ) { + connectionData = string.Empty; + error = null; + + if (string.Equals(lobbyType, "steam", StringComparison.OrdinalIgnoreCase)) { + if (string.IsNullOrEmpty(request.ConnectionData)) { + error = TypedResults.BadRequest(new ErrorResponse("Steam lobby requires ConnectionData")); + return false; + } + + connectionData = request.ConnectionData; + return true; + } + + var rawHostIp = request.HostIp ?? context.Connection.RemoteIpAddress?.ToString(); + if (string.IsNullOrEmpty(rawHostIp) || !IPAddress.TryParse(rawHostIp, out var parsedHostIp)) { + error = TypedResults.BadRequest(new ErrorResponse("Invalid IP address")); + return false; + } + + if (request.HostPort is null or <= 0 or > 65535) { + error = TypedResults.BadRequest(new ErrorResponse("Invalid port number")); + return false; + } + + connectionData = $"{parsedHostIp}:{request.HostPort}"; + return true; + } + + /// + /// Resolves and validates the client IP address for a join request. + /// + private static bool TryResolveClientAddress( + JoinLobbyRequest request, + HttpContext context, + out string clientIp, + out IResult? error + ) { + clientIp = string.Empty; + error = null; + + var rawClientIp = request.ClientIp ?? context.Connection.RemoteIpAddress?.ToString(); + if (string.IsNullOrEmpty(rawClientIp) || !IPAddress.TryParse(rawClientIp, out var parsedIp)) { + error = TypedResults.BadRequest(new ErrorResponse("Invalid IP address")); + return false; + } + + if (request.ClientPort is <= 0 or > 65535) { + error = TypedResults.BadRequest(new ErrorResponse("Invalid port")); + return false; + } + + clientIp = parsedIp.ToString(); + return true; + } + + /// + /// Returns the host LAN address when the joining client shares the host's WAN IP. + /// + private static string? TryResolveLanConnectionData(_Lobby lobby, string clientIp) { + if (string.IsNullOrEmpty(lobby.HostLanIp)) + return null; + + var hostWanIp = lobby.ConnectionData.Split(':')[0]; + if (clientIp != hostWanIp) + return null; + + ProgramState.Logger.LogInformation( + "[JOIN] Local network detected - returning LAN IP: {HostLanIp}", + lobby.HostLanIp + ); + + return lobby.HostLanIp; + } + + /// + /// Returns a bad request result indicating the client's matchmaking version is outdated. + /// + private static IResult MatchmakingOutdatedResult() => + TypedResults.BadRequest( + new ErrorResponse( + "Please update to the latest version in order to use matchmaking!", + MatchmakingProtocol.UpdateRequiredErrorCode + ) + ); + + /// + /// Returns the value as-is in development, or [Redacted] in production. + /// + private static string RedactInProduction(string value) => + ProgramState.IsDevelopment ? value : "[Redacted]"; +} diff --git a/MMS/Features/Lobby/LobbyEndpoints.cs b/MMS/Features/Lobby/LobbyEndpoints.cs new file mode 100644 index 0000000..bdfc779 --- /dev/null +++ b/MMS/Features/Lobby/LobbyEndpoints.cs @@ -0,0 +1,56 @@ +using MMS.Http; + +namespace MMS.Features.Lobby; + +/// +/// Maps lobby-oriented MMS HTTP endpoints. +/// +internal static partial class LobbyEndpoints +{ + /// + /// Maps lobby management and matchmaking HTTP endpoints. + /// + /// The web application to map non-lobby-root endpoints onto. + /// The grouped route builder for /lobby routes. + public static void MapLobbyEndpoints(this WebApplication app, RouteGroupBuilder lobby) + { + app.Endpoint() + .Get("/lobbies") + .Handler(GetLobbies) + .WithName("ListLobbies") + .RequireRateLimiting("search") + .Build(); + + lobby.Endpoint() + .Post("") + .Handler(CreateLobby) + .WithName("CreateLobby") + .RequireRateLimiting("create") + .Build(); + + lobby.Endpoint() + .Delete("/{token}") + .Handler(CloseLobby) + .WithName("CloseLobby") + .Build(); + + lobby.Endpoint() + .Post("/heartbeat/{token}") + .Handler(Heartbeat) + .WithName("Heartbeat") + .Build(); + + lobby.Endpoint() + .Post("/discovery/verify/{token}") + .Handler(VerifyDiscovery) + .WithName("VerifyDiscovery") + .Build(); + + lobby.Endpoint() + .Post("/{connectionData}/join") + .Handler(JoinLobby) + .WithName("JoinLobby") + .RequireRateLimiting("join") + .Build(); + } +} diff --git a/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs new file mode 100644 index 0000000..7580791 --- /dev/null +++ b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs @@ -0,0 +1,25 @@ +using MMS.Models; + +namespace MMS.Features.Matchmaking; + +/// +/// Shared matchmaking protocol version validation helpers. +/// +internal static class MatchmakingVersionValidation +{ + /// + /// Returns if matches the current protocol version. + /// + /// The client-reported matchmaking protocol version. + public static bool Validate(int? matchmakingVersion) => + matchmakingVersion == MatchmakingProtocol.CurrentVersion; + + /// + /// Parses and validates a matchmaking version from a query string value. + /// + /// Raw query string value. + /// if the version is present and matches the current protocol. + public static bool TryValidate(string? matchmakingVersion) => + int.TryParse(matchmakingVersion, out var parsedVersion) && + parsedVersion == MatchmakingProtocol.CurrentVersion; +} diff --git a/MMS/Features/WebSockets/WebSocketEndpoints.cs b/MMS/Features/WebSockets/WebSocketEndpoints.cs new file mode 100644 index 0000000..2a8dd74 --- /dev/null +++ b/MMS/Features/WebSockets/WebSocketEndpoints.cs @@ -0,0 +1,301 @@ +using System.Net.Sockets; +using System.Net.WebSockets; +using MMS.Bootstrap; +using MMS.Features.Matchmaking; +using MMS.Http; +using MMS.Models; +using MMS.Models.Matchmaking; +using MMS.Services.Lobby; +using MMS.Services.Matchmaking; +using static MMS.Contracts.Responses; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Features.WebSockets; + +/// +/// Maps WebSocket endpoints used by hosts and matchmaking join sessions. +/// +internal static class WebSocketEndpoints { + /// + /// Maps all MMS WebSocket endpoints onto the provided route group builders. + /// + /// The grouped route builder for /ws routes. + /// The grouped route builder for /ws/join routes. + public static void MapWebSocketEndpoints( + RouteGroupBuilder webSockets, + RouteGroupBuilder joinWebSockets + ) { + webSockets.Endpoint() + .Map("/{token}") + .Handler(HandleHostWebSocketAsync) + .Build(); + + joinWebSockets.Endpoint() + .Map("/{joinId}") + .Handler(HandleJoinWebSocketAsync) + .Build(); + } + + /// + /// Handles the persistent host WebSocket used for push notifications. + /// Keeps the socket open until the host closes the connection or disconnects. + /// + private static async Task HandleHostWebSocketAsync( + HttpContext context, + string token, + LobbyService lobbyService + ) { + if (!context.WebSockets.IsWebSocketRequest) { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var lobby = lobbyService.GetLobbyByToken(token); + if (lobby == null) { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + var previousSocket = lobby.HostWebSocket; + if (previousSocket != null && !ReferenceEquals(previousSocket, webSocket)) + await CloseReplacedHostSocketAsync(previousSocket, GetLobbyIdentifier(lobby), context.RequestAborted); + + lobby.HostWebSocket = webSocket; + + ProgramState.Logger.LogInformation( + "[WS] Host connected for lobby {LobbyIdentifier}", + GetLobbyIdentifier(lobby) + ); + + try { + await DrainHostWebSocketAsync(webSocket, GetLobbyIdentifier(lobby)); + } finally { + if (ReferenceEquals(lobby.HostWebSocket, webSocket)) + lobby.HostWebSocket = null; + + ProgramState.Logger.LogInformation( + "[WS] Host disconnected from lobby {LobbyIdentifier}", + GetLobbyIdentifier(lobby) + ); + } + } + + /// + /// Reads and discards incoming frames until the socket closes or the connection is reset. + /// The host WebSocket is receive-only; all meaningful communication is server-to-host push. + /// + /// The accepted host WebSocket. + /// The lobby identifier, used for diagnostic logging on unexpected disconnects. + private static async Task DrainHostWebSocketAsync(WebSocket webSocket, string lobbyIdentifier) { + var buffer = new byte[1024]; + try { + while (webSocket.State == WebSocketState.Open) { + var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + break; + } + } catch (WebSocketException ex) { + ProgramState.Logger.LogDebug( + ex, "[WS] Host socket closed unexpectedly for lobby {LobbyIdentifier}", lobbyIdentifier + ); + // Host disconnected without a proper close handshake. + } catch (Exception ex) when (ex.InnerException is SocketException) { + ProgramState.Logger.LogDebug(ex, "[WS] Host socket reset for lobby {LobbyIdentifier}", lobbyIdentifier); + // Connection was forcibly reset during shutdown or game exit. + } + } + + /// + /// Attempts a graceful close of a host WebSocket that has been superseded by a newer connection, + /// falling back to an abort if the socket is not in a closeable state or if the close fails. + /// + /// The WebSocket to close and dispose. + /// The lobby identifier, used for diagnostic logging on failure. + /// A token to cancel the close handshake. + private static async Task CloseReplacedHostSocketAsync( + WebSocket previousSocket, + string lobbyIdentifier, + CancellationToken cancellationToken + ) { + try { + if (previousSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) { + await previousSocket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Replaced by newer host connection", + cancellationToken + ); + } else { + previousSocket.Abort(); + } + } catch (Exception ex) { + ProgramState.Logger.LogDebug( + ex, + "[WS] Failed to close replaced host socket for lobby {LobbyIdentifier}", + lobbyIdentifier + ); + previousSocket.Abort(); + } finally { + previousSocket.Dispose(); + } + } + + /// + /// Handles the short-lived matchmaking WebSocket used during join rendezvous. + /// Validates the request, attaches the socket to the session, orchestrates the + /// host reachability check, then waits for the client to disconnect. + /// + private static async Task HandleJoinWebSocketAsync( + HttpContext context, + string joinId, + LobbyService lobbyService, + JoinSessionService joinService + ) { + if (!context.WebSockets.IsWebSocketRequest) { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + if (!ValidateMatchmakingVersion(context)) + return; + + var session = joinService.GetJoinSession(joinId); + if (session == null) { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + if (!joinService.AttachJoinWebSocket(joinId, webSocket)) { + await webSocket.CloseAsync( + WebSocketCloseStatus.PolicyViolation, + "join session not found", + context.RequestAborted + ); + return; + } + + await joinService.SendBeginClientMappingAsync(joinId, context.RequestAborted); + + if (!await EnsureHostReachableAsync(context, joinId, session, lobbyService, joinService)) { + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) { + await webSocket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Host unreachable", + context.RequestAborted + ); + } + + return; + } + + await DrainJoinWebSocketAsync(webSocket, joinId, joinService, context.RequestAborted); + } + + /// + /// Validates the matchmakingVersion query parameter. + /// Writes a 426 Upgrade Required response if validation fails. + /// + /// The current HTTP context. + /// + /// if the version is acceptable; otherwise . + /// + private static bool ValidateMatchmakingVersion(HttpContext context) { + if (MatchmakingVersionValidation.TryValidate(context.Request.Query["matchmakingVersion"])) + return true; + + context.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + context.Response + .WriteAsJsonAsync( + new ErrorResponse( + "Please update to the latest version in order to use matchmaking!", + MatchmakingProtocol.UpdateRequiredErrorCode + ), + context.RequestAborted + ) + .GetAwaiter() + .GetResult(); + return false; + } + + /// + /// Verifies that the host WebSocket for the session's lobby is reachable, + /// and requests a port refresh if the lobby has no known external port. + /// Fails the join session if the host cannot be reached. + /// + /// The current HTTP context, used for cancellation. + /// The join session identifier. + /// The resolved join session. + /// The lobby service for host lookup. + /// The join session service for signaling. + /// + /// if the host is reachable and the session may proceed; + /// otherwise . + /// + private static async Task EnsureHostReachableAsync( + HttpContext context, + string joinId, + JoinSession session, + LobbyService lobbyService, + JoinSessionService joinService + ) { + var lobby = lobbyService.GetLobby(session.LobbyConnectionData); + if (lobby?.HostWebSocket is not { State: WebSocketState.Open }) { + await joinService.FailJoinSessionAsync(joinId, "host_unreachable", context.RequestAborted); + return false; + } + + if (lobby.ExternalPort != null) + return true; + + var refreshSent = await joinService.SendHostRefreshRequestAsync(joinId, context.RequestAborted); + if (!refreshSent) { + await joinService.FailJoinSessionAsync(joinId, "host_unreachable", context.RequestAborted); + return false; + } + + return true; + } + + /// + /// Reads and discards incoming frames on the join WebSocket until the client disconnects + /// or the request is canceled. Fails the session as client_disconnected on exit + /// if the session is still active. + /// + /// The accepted join WebSocket. + /// The join session identifier. + /// The join session service used to fail the session on disconnect. + /// The request cancellation token. + private static async Task DrainJoinWebSocketAsync( + WebSocket webSocket, + string joinId, + JoinSessionService joinService, + CancellationToken cancellationToken + ) { + var buffer = new byte[256]; + try { + while (webSocket.State == WebSocketState.Open) { + var result = await webSocket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + break; + } + } catch (OperationCanceledException) { + // Request was canceled so session cleanup is handled in finally. + } catch (WebSocketException) { + // Client disconnected without a proper close handshake. + } finally { + if (joinService.GetJoinSession(joinId) != null) + await joinService.FailJoinSessionAsync(joinId, "client_disconnected", CancellationToken.None); + } + } + + /// + /// Returns a lobby identifier appropriate for the current environment. + /// Uses in development for full diagnostic detail, + /// and in production to avoid leaking connection info. + /// + /// The lobby to identify. + /// A string identifier suitable for logging. + private static string GetLobbyIdentifier(_Lobby lobby) => + ProgramState.IsDevelopment ? lobby.ConnectionData : lobby.LobbyName; +} diff --git a/MMS/Http/EndpointBuilder.cs b/MMS/Http/EndpointBuilder.cs new file mode 100644 index 0000000..73c93cb --- /dev/null +++ b/MMS/Http/EndpointBuilder.cs @@ -0,0 +1,138 @@ +namespace MMS.Http; + +/// +/// Fluent builder for registering minimal API endpoints with a compact, readable syntax. +/// +public sealed class EndpointBuilder(IEndpointRouteBuilder routes) +{ + private string _method = "GET"; + private string _route = "/"; + private Delegate? _handler; + private string? _name; + private string? _rateLimitingPolicy; + + /// + /// Configures the endpoint as an HTTP GET route. + /// + /// The route pattern to map. + /// The same builder for chaining. + public EndpointBuilder Get(string route) + { + _method = "GET"; + _route = route; + return this; + } + + /// + /// Configures the endpoint as an HTTP POST route. + /// + /// The route pattern to map. + /// The same builder for chaining. + public EndpointBuilder Post(string route) + { + _method = "POST"; + _route = route; + return this; + } + + /// + /// Configures the endpoint as an HTTP DELETE route. + /// + /// The route pattern to map. + /// The same builder for chaining. + public EndpointBuilder Delete(string route) + { + _method = "DELETE"; + _route = route; + return this; + } + + /// + /// Configures the endpoint as a generic mapped route, useful for WebSocket handlers. + /// + /// The route pattern to map. + /// The same builder for chaining. + public EndpointBuilder Map(string route) + { + _method = "MAP"; + _route = route; + return this; + } + + /// + /// Sets the request handler delegate. + /// + /// The delegate to invoke when the endpoint matches. + /// The same builder for chaining. + public EndpointBuilder Handler(Delegate handler) + { + _handler = handler; + return this; + } + + /// + /// Sets the endpoint name. + /// + /// The endpoint name. + /// The same builder for chaining. + public EndpointBuilder WithName(string name) + { + _name = name; + return this; + } + + /// + /// Applies an ASP.NET Core rate-limiting policy to the endpoint. + /// + /// The name of the rate-limiting policy. + /// The same builder for chaining. + public EndpointBuilder RequireRateLimiting(string policyName) + { + _rateLimitingPolicy = policyName; + return this; + } + + /// + /// Builds and registers the configured endpoint. + /// + /// The same route builder that created this endpoint. + public void Build() + { + ArgumentNullException.ThrowIfNull(_handler); + + var endpoint = _method switch + { + "GET" => routes.MapGet(_route, _handler), + "POST" => routes.MapPost(_route, _handler), + "DELETE" => routes.MapDelete(_route, _handler), + "MAP" => routes.Map(_route, _handler), + _ => throw new NotSupportedException($"Method {_method} is not supported.") + }; + + if (_name is not null) + endpoint.WithName(_name); + + if (_rateLimitingPolicy is not null) + endpoint.RequireRateLimiting(_rateLimitingPolicy); + } +} + +/// +/// Extension methods for starting fluent endpoint registrations. +/// +internal static class FluentEndpointBuilderExtensions +{ + /// + /// Starts building an endpoint on a web application. + /// + /// The application to map onto. + /// A new endpoint builder. + public static EndpointBuilder Endpoint(this WebApplication app) => new(app); + + /// + /// Starts building an endpoint on a grouped route builder. + /// + /// The route group to map onto. + /// A new endpoint builder. + public static EndpointBuilder Endpoint(this RouteGroupBuilder group) => new(group); +} diff --git a/MMS/Models/Lobby.cs b/MMS/Models/Lobby/Lobby.cs similarity index 83% rename from MMS/Models/Lobby.cs rename to MMS/Models/Lobby/Lobby.cs index 1093d57..06e26e4 100644 --- a/MMS/Models/Lobby.cs +++ b/MMS/Models/Lobby/Lobby.cs @@ -1,12 +1,6 @@ -using System.Collections.Concurrent; using System.Net.WebSockets; -namespace MMS.Models; - -/// -/// Client waiting for NAT hole-punch. -/// -public record PendingClient(string ClientIp, int ClientPort, DateTime RequestedAt); +namespace MMS.Models.Lobby; /// /// Game lobby. ConnectionData serves as both identifier and connection info. @@ -19,7 +13,8 @@ public class Lobby( string lobbyName, string lobbyType = "matchmaking", string? hostLanIp = null, - bool isPublic = true + bool isPublic = true, + string? hostDiscoveryToken = null ) { /// Stable connection data used as the lobby identity and storage key. public string ConnectionData { get; } = connectionData; @@ -45,9 +40,6 @@ public class Lobby( /// Timestamp of the last heartbeat from the host. public DateTime LastHeartbeat { get; set; } = DateTime.UtcNow; - /// Queue of clients waiting for NAT hole-punch. - public ConcurrentQueue PendingClients { get; } = new(); - /// True if no heartbeat received in the last 60 seconds. public bool IsDead => DateTime.UtcNow - LastHeartbeat > TimeSpan.FromSeconds(60); @@ -55,7 +47,7 @@ public class Lobby( public int? ExternalPort { get; internal set; } /// Token used for UDP port discovery. - public string? HostDiscoveryToken { get; init; } + public string? HostDiscoveryToken { get; } = hostDiscoveryToken; /// Connection data that should be advertised to clients. public string AdvertisedConnectionData { diff --git a/MMS/Models/DiscoveryTokenMetadata.cs b/MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs similarity index 65% rename from MMS/Models/DiscoveryTokenMetadata.cs rename to MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs index 2eedba5..2ab286c 100644 --- a/MMS/Models/DiscoveryTokenMetadata.cs +++ b/MMS/Models/Matchmaking/DiscoveryTokenMetadata.cs @@ -1,4 +1,4 @@ -namespace MMS.Models; +namespace MMS.Models.Matchmaking; /// /// Metadata for an active NAT traversal discovery session. @@ -8,7 +8,7 @@ public sealed class DiscoveryTokenMetadata { /// The UTC timestamp when this discovery token was created. /// Used for automatic cleanup of stale sessions. /// - public DateTime CreatedAt { get; } = DateTime.UtcNow; + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; /// /// The external port discovered via UDP. @@ -17,16 +17,10 @@ public sealed class DiscoveryTokenMetadata { public int? DiscoveredPort { get; set; } /// - /// The invite code of the lobby this token is associated with. + /// The join session associated with the client discovery token. /// Only populated for client discovery tokens. /// - public string? LobbyCode { get; init; } - - /// - /// The public IP address of the client performing discovery. - /// Only populated for client discovery tokens. - /// - public string? ClientIp { get; init; } + public string? JoinId { get; init; } /// /// The connection data of the host lobby. diff --git a/MMS/Models/Matchmaking/JoinSession.cs b/MMS/Models/Matchmaking/JoinSession.cs new file mode 100644 index 0000000..ac2c200 --- /dev/null +++ b/MMS/Models/Matchmaking/JoinSession.cs @@ -0,0 +1,43 @@ +using System.Net.WebSockets; + +namespace MMS.Models.Matchmaking; + +/// +/// Short-lived matchmaking rendezvous session for a single join attempt. +/// +public sealed class JoinSession { + /// + /// Unique identifier for this join attempt. + /// + public required string JoinId { get; init; } + + /// + /// Connection data of the lobby being joined. + /// + public required string LobbyConnectionData { get; init; } + + /// + /// Public IP address of the joining client. + /// + public required string ClientIp { get; init; } + + /// + /// Client discovery token used to map the external port. + /// + public required string ClientDiscoveryToken { get; init; } + + /// + /// Externally visible client port once discovery completes. + /// + public int? ClientExternalPort { get; set; } + + /// + /// The client WebSocket attached to this join attempt, if connected. + /// + public WebSocket? ClientWebSocket { get; set; } + + /// + /// Timestamp when this join session should expire. + /// + public DateTime ExpiresAtUtc { get; } = DateTime.UtcNow.AddSeconds(20); +} diff --git a/MMS/Models/MatchmakingProtocol.cs b/MMS/Models/MatchmakingProtocol.cs new file mode 100644 index 0000000..13de033 --- /dev/null +++ b/MMS/Models/MatchmakingProtocol.cs @@ -0,0 +1,21 @@ +namespace MMS.Models; + +/// +/// Shared constants for the strict matchmaking rendezvous protocol. +/// +internal static class MatchmakingProtocol { + /// + /// The current matchmaking protocol version required for MMS matchmaking operations. + /// + public const int CurrentVersion = 1; + + /// + /// Error code returned when a client must update before using matchmaking. + /// + public const string UpdateRequiredErrorCode = "update_required"; + + /// + /// Offset applied before issuing synchronized start_punch instructions. + /// + public const int PunchTimingOffsetMs = 250; +} diff --git a/MMS/Program.cs b/MMS/Program.cs index 611309e..173a387 100644 --- a/MMS/Program.cs +++ b/MMS/Program.cs @@ -1,597 +1,42 @@ -using System.Net; -using System.Net.WebSockets; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Threading.RateLimiting; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.HttpOverrides; -using MMS.Services; +using MMS.Bootstrap; +using MMS.Features; namespace MMS; /// -/// Main class for the MatchMaking Server. +/// Entry point and composition root for the MatchMaking Server. /// // ReSharper disable once ClassNeverInstantiated.Global public class Program { /// - /// Whether we are running a development environment. - /// - internal static bool IsDevelopment { get; private set; } - - /// - /// The logger for logging information to the console. - /// - private static ILogger Logger { get; set; } = null!; - - /// - /// Entrypoint for the MMS. + /// Application entry point. /// + /// Command-line arguments. public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + var isDevelopment = builder.Environment.IsDevelopment(); - IsDevelopment = builder.Environment.IsDevelopment(); - - builder.Logging.ClearProviders(); - builder.Logging.AddSimpleConsole(options => { - options.SingleLine = true; - options.IncludeScopes = false; - options.TimestampFormat = "HH:mm:ss "; - } - ); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + // ProgramState is initialized once during startup + // and treated as read-only thereafter. + ProgramState.IsDevelopment = isDevelopment; - builder.Services.Configure(options => { - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedHost | - ForwardedHeaders.XForwardedProto; - } - ); - - if (IsDevelopment) { - builder.Services.AddHttpLogging(_ => { }); - } else { - if (!ConfigureHttpsCertificate(builder)) { - return; - } - } - - builder.Services.AddRateLimiter(options => { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.OnRejected = async (context, token) => { - // The current client treats 429s as generic failures. - // Keep this response stable until the client adds explicit rate-limit handling. - await context.HttpContext.Response.WriteAsJsonAsync( - new ErrorResponse("Too many requests. Please try again later."), cancellationToken: token - ); - }; - - options.AddPolicy( - "create", context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 5, - Window = TimeSpan.FromSeconds(30), - QueueLimit = 0 - } - ) - ); + builder.Services.AddMmsCoreServices(); + builder.Services.AddMmsInfrastructure(builder.Configuration, isDevelopment); - options.AddPolicy( - "search", context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 10, - Window = TimeSpan.FromSeconds(10), - QueueLimit = 0 - } - ) - ); - - options.AddPolicy( - "join", context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 5, - Window = TimeSpan.FromSeconds(30), - QueueLimit = 0 - } - ) - ); + if (!builder.TryConfigureMmsHttps(isDevelopment)) { + using var loggerFactory = LoggerFactory.Create(logging => logging.AddSimpleConsole()); + loggerFactory.CreateLogger(nameof(Program)) + .LogCritical("MMS HTTPS configuration failed, exiting"); + return; } - ); var app = builder.Build(); + // ProgramState.Logger is assigned once after the host is built, + // it should only be read after this point. + ProgramState.Logger = app.Logger; - Logger = app.Logger; - - if (IsDevelopment) { - app.UseHttpLogging(); - } else { - app.UseExceptionHandler("/error"); - } - - app.UseForwardedHeaders(); - app.UseRateLimiter(); - app.UseWebSockets(); - MapEndpoints(app); - app.Urls.Add(IsDevelopment ? "http://0.0.0.0:5000" : "https://0.0.0.0:5000"); + app.UseMmsPipeline(isDevelopment); + app.MapMmsEndpoints(); app.Run(); } - - #region Web Application Initialization - - /// - /// Tries to configure HTTPS by reading an SSL certificate and enabling HTTPS when the web application is built. - /// - /// The web application builder. - /// True if the certificate could be read, false otherwise. - private static bool ConfigureHttpsCertificate(WebApplicationBuilder builder) { - if (!File.Exists("cert.pem")) { - Console.WriteLine("Certificate file 'cert.pem' does not exist"); - return false; - } - - if (!File.Exists("key.pem")) { - Console.WriteLine("Certificate key file 'key.pem' does not exist"); - return false; - } - - string pem; - string key; - try { - pem = File.ReadAllText("cert.pem"); - key = File.ReadAllText("key.pem"); - } catch (Exception e) { - Console.WriteLine($"Could not read either 'cert.pem' or 'key.pem':\n{e}"); - return false; - } - - X509Certificate2 x509; - try { - x509 = X509Certificate2.CreateFromPem(pem, key); - } catch (CryptographicException e) { - Console.WriteLine($"Could not create certificate object from pem files:\n{e}"); - return false; - } - - builder.WebHost.ConfigureKestrel(s => { - s.ListenAnyIP( - 5000, options => { options.UseHttps(x509); } - ); - } - ); - - return true; - } - - #endregion - - #region Endpoint Registration - - /// - /// Registers all API endpoints for the MatchMaking Server. - /// - private static void MapEndpoints(WebApplication app) { - var lobbyService = app.Services.GetRequiredService(); - - // Health & Monitoring - app.MapGet("/", () => Results.Ok(new { service = "MMS", version = "1.0", status = "healthy" })) - .WithName("HealthCheck"); - app.MapGet("/lobbies", GetLobbies).WithName("ListLobbies").RequireRateLimiting("search"); - - // Lobby Management - app.MapPost("/lobby", CreateLobby).WithName("CreateLobby").RequireRateLimiting("create"); - app.MapDelete("/lobby/{token}", CloseLobby).WithName("CloseLobby"); - - // Host Operations - app.MapPost("/lobby/heartbeat/{token}", Heartbeat).WithName("Heartbeat"); - app.MapGet("/lobby/pending/{token}", GetPendingClients).WithName("GetPendingClients"); - app.MapPost("/lobby/discovery/verify/{token}", VerifyDiscovery).WithName("VerifyDiscovery"); - - // WebSocket for host push notifications - app.Map( - "/ws/{token}", async (HttpContext context, string token) => { - if (!context.WebSockets.IsWebSocketRequest) { - context.Response.StatusCode = 400; - return; - } - - var lobby = lobbyService.GetLobbyByToken(token); - if (lobby == null) { - context.Response.StatusCode = 404; - return; - } - - using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); - lobby.HostWebSocket = webSocket; - - Logger.LogInformation( - "[WS] Host connected for lobby {LobbyIdentifier}", - IsDevelopment ? lobby.ConnectionData : lobby.LobbyName - ); - - // Keep connection alive until closed - var buffer = new byte[1024]; - try { - while (webSocket.State == WebSocketState.Open) { - var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) break; - } - } catch (WebSocketException) { - // Host disconnected without proper close handshake (normal during game exit) - } catch (Exception ex) when (ex.InnerException is System.Net.Sockets.SocketException) { - // Connection forcibly reset (normal during game exit) - } finally { - lobby.HostWebSocket = null; - Logger.LogInformation( - "[WS] Host disconnected from lobby {LobbyIdentifier}", - IsDevelopment ? lobby.ConnectionData : lobby.LobbyName - ); - } - } - ); - - // Client Operations - app.MapPost("/lobby/{connectionData}/join", JoinLobby).WithName("JoinLobby").RequireRateLimiting("join"); - } - - #endregion - - #region Endpoint Handlers - - /// - /// Returns all lobbies, optionally filtered by type. - /// - private static Ok> GetLobbies(LobbyService lobbyService, string? type = null) { - var lobbies = lobbyService.GetLobbies(type) - .Select(l => new LobbyResponse( - l.AdvertisedConnectionData, - l.LobbyName, - l.LobbyType, - l.LobbyCode - ) - ); - return TypedResults.Ok(lobbies); - } - - /// - /// Creates a new lobby (Steam or Matchmaking). - /// - private static Results, BadRequest> CreateLobby( - CreateLobbyRequest request, - LobbyService lobbyService, - LobbyNameService lobbyNameService, - HttpContext context - ) { - var lobbyType = request.LobbyType ?? "matchmaking"; - string connectionData; - - if (lobbyType == "steam") { - if (string.IsNullOrEmpty(request.ConnectionData)) { - return TypedResults.BadRequest(new ErrorResponse("Steam lobby requires ConnectionData")); - } - - connectionData = request.ConnectionData; - } else { - var rawHostIp = request.HostIp ?? context.Connection.RemoteIpAddress?.ToString(); - if (string.IsNullOrEmpty(rawHostIp) || !IPAddress.TryParse(rawHostIp, out var parsedHostIp)) { - return TypedResults.BadRequest(new ErrorResponse("Invalid IP address")); - } - - var hostIp = parsedHostIp.ToString(); - if (request.HostPort is null or <= 0 or > 65535) { - return TypedResults.BadRequest(new ErrorResponse("Invalid port number")); - } - - connectionData = $"{hostIp}:{request.HostPort}"; - } - - var lobbyName = lobbyNameService.GenerateLobbyName(); - - var lobby = lobbyService.CreateLobby( - connectionData, - lobbyName, - lobbyType, - request.HostLanIp, - request.IsPublic ?? true - ); - - var visibility = lobby.IsPublic ? "Public" : "Private"; - var connectionDataString = IsDevelopment ? lobby.AdvertisedConnectionData : "[Redacted]"; - Logger.LogInformation( - "[LOBBY] Created: '{LobbyName}' [{LobbyType}] ({Visibility}) -> {ConnectionDataString} (Code: {LobbyCode})", - lobby.LobbyName, - lobby.LobbyType, - visibility, - connectionDataString, - lobby.LobbyCode - ); - - return TypedResults.Created( - $"/lobby/{lobby.LobbyCode}", - new CreateLobbyResponse( - lobby.AdvertisedConnectionData, lobby.HostToken, lobby.LobbyName, lobby.LobbyCode, lobby.HostDiscoveryToken - ) - ); - } - - /// - /// Returns the discovered external port when ready. - /// Joining clients do not maintain a WebSocket to MMS, so they poll this endpoint - /// until discovery completes. - /// Once discovery succeeds, the host is notified via WebSocket if one is connected. - /// - private static async Task VerifyDiscovery( - string token, - LobbyService lobbyService, - CancellationToken cancellationToken = default - ) { - var port = lobbyService.GetDiscoveredPort(token); - if (port is null) return TypedResults.Ok(new StatusResponse("pending")); - - await TryNotifyHostAsync(token, port.Value, lobbyService, cancellationToken); - lobbyService.ApplyHostPort(token, port.Value); - lobbyService.RemoveDiscoveryToken(token); - return TypedResults.Ok(new DiscoveryResponse(port.Value)); - } - - /// - /// If the token belongs to a client, pushes their external endpoint to the host via WebSocket. - /// Silently skips if the lobby or WebSocket is unavailable. - /// - private static async Task TryNotifyHostAsync( - string token, - int port, - LobbyService lobbyService, - CancellationToken cancellationToken - ) { - if (!lobbyService.TryGetClientInfo(token, out var lobbyCode, out var clientIp)) - return; - - var lobby = lobbyService.GetLobbyByCode(lobbyCode); - - if (lobby?.HostWebSocket is not { State: WebSocketState.Open } ws) - return; - - var payload = JsonSerializer.SerializeToUtf8Bytes( - new { - clientIp, - clientPort = port - } - ); - - await ws.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); - - Logger.LogInformation( - "Pushed client {ClientIp}:{ClientPort} to host via WebSocket after discovery", - clientIp, - port - ); - } - - /// - /// Closes a lobby by host token. - /// - private static Results> CloseLobby(string token, LobbyService lobbyService) { - if (!lobbyService.RemoveLobbyByToken(token)) { - return TypedResults.NotFound(new ErrorResponse("Lobby not found")); - } - - Logger.LogInformation("[LOBBY] Closed by host"); - return TypedResults.NoContent(); - } - - /// - /// Refreshes lobby heartbeat to prevent expiration. - /// - private static Results, NotFound> Heartbeat( - string token, - LobbyService lobbyService - ) { - return lobbyService.Heartbeat(token) - ? TypedResults.Ok(new StatusResponse("alive")) - : TypedResults.NotFound(new ErrorResponse("Lobby not found")); - } - - /// - /// Returns pending clients waiting for NAT hole-punch (clears the queue). - /// - private static Results>, NotFound> GetPendingClients( - string token, - LobbyService lobbyService - ) { - var lobby = lobbyService.GetLobbyByToken(token); - if (lobby == null) { - return TypedResults.NotFound(new ErrorResponse("Lobby not found")); - } - - var pending = new List(); - var cutoff = DateTime.UtcNow.AddSeconds(-30); - - while (lobby.PendingClients.TryDequeue(out var client)) { - if (client.RequestedAt >= cutoff) { - pending.Add(new PendingClientResponse(client.ClientIp, client.ClientPort)); - } - } - - return TypedResults.Ok(pending); - } - - /// - /// Notifies host of pending client and returns host connection info. - /// Uses WebSocket push if available, otherwise queues for polling. - /// - private static Results, NotFound> JoinLobby( - string connectionData, - JoinLobbyRequest request, - LobbyService lobbyService, - HttpContext context - ) { - // Try as lobby code first, then as connectionData - var lobby = lobbyService.GetLobbyByCode(connectionData) ?? lobbyService.GetLobby(connectionData); - if (lobby == null) { - return TypedResults.NotFound(new ErrorResponse("Lobby not found")); - } - - var rawClientIp = request.ClientIp ?? context.Connection.RemoteIpAddress?.ToString(); - if (string.IsNullOrEmpty(rawClientIp) || !IPAddress.TryParse(rawClientIp, out var parsedIp)) { - return TypedResults.NotFound(new ErrorResponse("Invalid IP address")); - } - - var clientIp = parsedIp.ToString(); - - if (request.ClientPort is <= 0 or > 65535) { - return TypedResults.NotFound(new ErrorResponse("Invalid port")); - } - - var clientDiscoveryToken = lobbyService.RegisterClientDiscoveryToken(lobby.LobbyCode, clientIp); - - Logger.LogInformation( - "[JOIN] {ConnectionDetails}", - IsDevelopment - ? $"{clientIp}:{request.ClientPort} -> {lobby.AdvertisedConnectionData}" - : $"[Redacted]:{request.ClientPort} -> [Redacted]" - ); - - /* Host notification is delayed until VerifyDisco very returns the NAT-mapped port. */ - /* The host is then notified over WebSocket with the externally visible endpoint. */ - - // Fallback to queue for polling (legacy clients) - lobby.PendingClients.Enqueue( - new Models.PendingClient(clientIp, request.ClientPort, DateTime.UtcNow) - ); - - // Check if client is on the same network as the host - var joinConnectionData = lobby.AdvertisedConnectionData; - string? lanConnectionData = null; - - // We can only check IP equality if we have the host's IP (for matchmaking lobbies mainly) - // NOTE: This assumes lobby.ConnectionData is in "IP:Port" format for matchmaking - if (string.IsNullOrEmpty(lobby.HostLanIp)) { - return TypedResults.Ok( - new JoinResponse( - joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData, - clientDiscoveryToken - ) - ); - } - - // Parse Host Public IP from ConnectionData (format: "IP:Port") - var hostPublicIp = lobby.ConnectionData.Split(':')[0]; - - if (clientIp != hostPublicIp) { - return TypedResults.Ok( - new JoinResponse( - joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData, - clientDiscoveryToken - ) - ); - } - - Logger.LogInformation("[JOIN] Local Network Detected! Returning LAN IP: {HostLanIp}", lobby.HostLanIp); - lanConnectionData = lobby.HostLanIp; - - return TypedResults.Ok( - new JoinResponse( - joinConnectionData, lobby.LobbyType, clientIp, request.ClientPort, lanConnectionData, - clientDiscoveryToken - ) - ); - } - - #endregion - - #region DTOs - - /// Host IP (Matchmaking only, optional). - /// Host port (Matchmaking only). - /// Steam lobby ID (Steam only). - /// "steam" or "matchmaking" (default: matchmaking). - /// Host LAN IP for local network discovery. - /// Whether lobby appears in browser (default: true). - [UsedImplicitly] - private record CreateLobbyRequest( - string? HostIp, - int? HostPort, - string? ConnectionData, - string? LobbyType, - string? HostLanIp, - bool? IsPublic - ); - - /// Connection identifier (IP:Port or Steam lobby ID). - /// Secret token for host operations. - /// Name for the lobby. - /// Human-readable invite code. - /// Discovery token used to confirm the host's external UDP port. - [UsedImplicitly] - internal record CreateLobbyResponse( - string ConnectionData, - string HostToken, - string LobbyName, - string LobbyCode, - string? HostDiscoveryToken - ); - - /// Connection identifier (IP:Port or Steam lobby ID). - /// Display name. - /// "steam" or "matchmaking". - /// Human-readable invite code. - [UsedImplicitly] - internal record LobbyResponse( - string ConnectionData, - string Name, - string LobbyType, - string LobbyCode - ); - - /// Client IP (optional - uses connection IP if null). - /// Client's local port for hole-punching. - [UsedImplicitly] - internal record JoinLobbyRequest(string? ClientIp, int ClientPort); - - /// Host connection data (IP:Port or Steam lobby ID). - /// "steam" or "matchmaking". - /// Client's public IP as seen by MMS. - /// Client's public port. - /// Host's LAN connection data in case LAN is detected. - [UsedImplicitly] - internal record JoinResponse( - string ConnectionData, - string LobbyType, - string ClientIp, - int ClientPort, - string? LanConnectionData, - string? ClientDiscoveryToken - ); - - /// Discovered external port. - [UsedImplicitly] - internal record DiscoveryResponse(int ExternalPort); - - /// Pending client's IP. - /// Pending client's port. - [UsedImplicitly] - internal record PendingClientResponse(string ClientIp, int ClientPort); - - /// Error message. - [UsedImplicitly] - internal record ErrorResponse(string Error); - - /// Status message. - [UsedImplicitly] - internal record StatusResponse(string Status); - - #endregion } diff --git a/MMS/Services/Lobby/LobbyCleanupService.cs b/MMS/Services/Lobby/LobbyCleanupService.cs new file mode 100644 index 0000000..af20ef9 --- /dev/null +++ b/MMS/Services/Lobby/LobbyCleanupService.cs @@ -0,0 +1,34 @@ +namespace MMS.Services.Lobby; + +using Matchmaking; + +/// Background service that removes expired lobbies and matchmaking sessions every 30 seconds. +public class LobbyCleanupService( + LobbyService lobbyService, + JoinSessionService joinSessionService, + ILogger logger +) : BackgroundService { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + logger.LogInformation("Lobby cleanup service started"); + + while (!stoppingToken.IsCancellationRequested) { + try { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } catch (OperationCanceledException) { + break; + } + + try { + var removed = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby); + joinSessionService.CleanupExpiredSessions(); + if (removed > 0) { + logger.LogInformation("Removed {RemovedCount} expired lobbies", removed); + } + } catch (Exception ex) { + logger.LogError(ex, "Lobby cleanup iteration failed"); + } + } + + logger.LogInformation("Lobby cleanup service stopped"); + } +} diff --git a/MMS/Services/LobbyNameService.cs b/MMS/Services/Lobby/LobbyNameService.cs similarity index 95% rename from MMS/Services/LobbyNameService.cs rename to MMS/Services/Lobby/LobbyNameService.cs index fe9f576..e3f5c56 100644 --- a/MMS/Services/LobbyNameService.cs +++ b/MMS/Services/Lobby/LobbyNameService.cs @@ -4,7 +4,7 @@ using System.Runtime.Serialization; using System.Text.Json; -namespace MMS.Services; +namespace MMS.Services.Lobby; /// /// Lobby name providing service that randomly generates lobby names from words in an embedded JSON. @@ -42,11 +42,8 @@ public LobbyNameService() { var fileString = streamReader.ReadToEnd(); var data = JsonSerializer.Deserialize(fileString); - if (data == null) { - throw new SerializationException("Could not deserialize lobby name data from embedded resource"); - } - _lobbyNameData = data; + _lobbyNameData = data ?? throw new SerializationException("Could not deserialize lobby name data from embedded resource"); } /// diff --git a/MMS/Services/Lobby/LobbyService.cs b/MMS/Services/Lobby/LobbyService.cs new file mode 100644 index 0000000..ccc1d13 --- /dev/null +++ b/MMS/Services/Lobby/LobbyService.cs @@ -0,0 +1,238 @@ +using System.Collections.Concurrent; +using MMS.Bootstrap; +using MMS.Services.Matchmaking; +using MMS.Services.Utility; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Services.Lobby; + +/// +/// Thread-safe in-memory lobby store. +/// Handles lobby creation, lookup, heartbeat, and expiry sweeps. +/// NAT hole-punch coordination and join session management are delegated to +/// . +/// +public class LobbyService(LobbyNameService lobbyNameService) { + private readonly ConcurrentDictionary _lobbies = new(); + private readonly ConcurrentDictionary _tokenToConnectionData = new(); + private readonly ConcurrentDictionary _codeToConnectionData = new(); + private readonly Lock _createLobbyLock = new(); + + /// + /// Creates and stores a new lobby. + /// + /// + /// The unique connection identifier for this lobby (e.g. ip:port for matchmaking, + /// Steam lobby ID for Steam lobbies). + /// + /// Human-readable display name assigned to this lobby. + /// + /// Lobby transport type. Accepted values: "matchmaking" (default), "steam". + /// + /// Optional LAN address of the host, used for same-network fast-path. + /// Whether the lobby appears in the public browser. + /// The newly created instance. + public _Lobby CreateLobby( + string connectionData, + string lobbyName, + string lobbyType = "matchmaking", + string? hostLanIp = null, + bool isPublic = true + ) { + if (!IsMatchmakingLobbyType(lobbyType) && !IsSteamLobby(lobbyType)) + throw new ArgumentOutOfRangeException( + nameof(lobbyType), lobbyType, "Lobby type must be 'matchmaking' or 'steam'." + ); + + var hostToken = TokenGenerator.GenerateToken(32); + var hostDiscoveryToken = IsMatchmakingLobbyType(lobbyType) ? TokenGenerator.GenerateToken(32) : null; + lock (_createLobbyLock) { + if (_lobbies.TryGetValue(connectionData, out var existingLobby)) { + RemoveLobbyIndexes(existingLobby); + if (!string.IsNullOrEmpty(existingLobby.LobbyName)) + lobbyNameService.FreeLobbyName(existingLobby.LobbyName); + } + + var lobbyCode = IsSteamLobby(lobbyType) + ? "" + : ReserveLobbyCode(connectionData); + + var lobby = new _Lobby( + connectionData, + hostToken, + lobbyCode, + lobbyName, + lobbyType, + hostLanIp, + isPublic, + hostDiscoveryToken + ); + + _lobbies[connectionData] = lobby; + _tokenToConnectionData[hostToken] = connectionData; + return lobby; + } + } + + /// + /// Returns the lobby for , or if absent or expired. + /// Expired lobbies are removed lazily on access. + /// + /// The connection identifier the lobby was registered under. + public _Lobby? GetLobby(string connectionData) { + if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null; + if (!lobby.IsDead) return lobby; + + RemoveLobby(lobby); + return null; + } + + /// + /// Returns the lobby owned by , or if absent or expired. + /// + /// The host authentication token issued at lobby creation. + public _Lobby? GetLobbyByToken(string token) { + if (!_tokenToConnectionData.TryGetValue(token, out var connData)) + return null; + + var lobby = GetLobby(connData); + return lobby is not null && lobby.HostToken == token ? lobby : null; + } + + /// + /// Returns the lobby identified by , or if absent or expired. + /// The lookup is case-insensitive. + /// + /// The player-facing lobby code (e.g. ABC123). + public _Lobby? GetLobbyByCode(string code) { + var normalizedCode = code.ToUpperInvariant(); + if (!_codeToConnectionData.TryGetValue(normalizedCode, out var connData)) return null; + + var lobby = GetLobby(connData); + return (lobby is not null && string.Equals(lobby.LobbyCode, normalizedCode, StringComparison.Ordinal)) + ? lobby + : null; + } + + /// + /// Returns all active public lobbies, optionally filtered by . + /// + /// + /// Optional case-insensitive filter (e.g. "matchmaking" or "steam"). + /// Pass to return all types. + /// + public IEnumerable<_Lobby> GetLobbies(string? lobbyType = null) { + var active = _lobbies.Values.Where(l => l is { IsDead: false, IsPublic: true }); + return string.IsNullOrEmpty(lobbyType) + ? active + : active.Where(l => l.LobbyType.Equals(lobbyType, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Records a heartbeat for the lobby owned by and updates the + /// connected-player count. + /// + /// + /// When drops to zero on a matchmaking lobby, + /// is cleared so that stale NAT mappings are not + /// reused for subsequent joins. + /// + /// The host authentication token. + /// Current number of remote players connected to the host. + /// if the lobby was found and updated; otherwise. + public bool Heartbeat(string token, int connectedPlayers) { + if (connectedPlayers < 0) + return false; + + var lobby = GetLobbyByToken(token); + if (lobby == null) return false; + + lobby.LastHeartbeat = DateTime.UtcNow; + + if (IsMatchmakingLobby(lobby) && connectedPlayers == 0) + lobby.ExternalPort = null; + + return true; + } + + /// + /// Removes the lobby owned by from all indexes. + /// + /// The host authentication token issued at lobby creation. + /// Optional callback invoked with the lobby instance before it is removed from all indexes. + /// if the lobby was found and removed; otherwise. + public bool RemoveLobbyByToken(string token, Action<_Lobby>? onRemoving = null) { + var lobby = GetLobbyByToken(token); + return lobby != null && RemoveLobby(lobby, onRemoving); + } + + /// + /// Removes all lobbies whose flag is set. + /// + /// Optional callback invoked with each lobby instance before it is removed. + /// The number of lobbies removed. + public int CleanupDeadLobbies(Action<_Lobby>? onRemoving = null) { + var dead = _lobbies.Values.Where(l => l.IsDead).ToList(); + return dead.Count(lobby => RemoveLobby(lobby, onRemoving)); + } + + /// + /// Removes a lobby from all indexes and releases its name back to . + /// + /// The specific lobby instance to remove. + /// Optional callback invoked with the lobby instance before it is removed from all indexes. + /// if the lobby was not found (already removed). + private bool RemoveLobby(_Lobby lobby, Action<_Lobby>? onRemoving = null) { + if (!_lobbies.TryRemove(new KeyValuePair(lobby.ConnectionData, lobby))) + return false; + + try { + onRemoving?.Invoke(lobby); + } catch (Exception ex) { + ProgramState.Logger.LogWarning(ex, "Lobby removal callback failed for {ConnectionData}", lobby.ConnectionData); + } finally { + _tokenToConnectionData.TryRemove(lobby.HostToken, out _); + _codeToConnectionData.TryRemove(lobby.LobbyCode, out _); + lobbyNameService.FreeLobbyName(lobby.LobbyName); + } + + return true; + } + + /// Returns if is "steam". + private static bool IsSteamLobby(string lobbyType) => + lobbyType.Equals("steam", StringComparison.OrdinalIgnoreCase); + + /// + /// Reserves a unique lobby code and associates it with the given connection data. + /// Retries until a code is successfully inserted into the concurrent store. + /// + /// The connection data to associate with the reserved code. + /// The reserved lobby code. + private string ReserveLobbyCode(string connectionData) { + while (true) { + var code = TokenGenerator.GenerateUniqueLobbyCode(new HashSet(_codeToConnectionData.Keys)); + if (_codeToConnectionData.TryAdd(code, connectionData)) + return code; + } + } + + /// + /// Removes all index entries associated with a lobby, including its host token + /// and lobby code if one was assigned. + /// + /// The lobby whose indexes should be removed. + private void RemoveLobbyIndexes(_Lobby lobby) { + _tokenToConnectionData.TryRemove(lobby.HostToken, out _); + if (!string.IsNullOrEmpty(lobby.LobbyCode)) + _codeToConnectionData.TryRemove(lobby.LobbyCode, out _); + } + + /// Returns if is "matchmaking". + private static bool IsMatchmakingLobbyType(string lobbyType) => + lobbyType.Equals("matchmaking", StringComparison.OrdinalIgnoreCase); + + /// Returns if is a matchmaking lobby. + private static bool IsMatchmakingLobby(_Lobby lobby) => + lobby.LobbyType.Equals("matchmaking", StringComparison.OrdinalIgnoreCase); +} diff --git a/MMS/Services/LobbyCleanupService.cs b/MMS/Services/LobbyCleanupService.cs deleted file mode 100644 index 0fcc8d3..0000000 --- a/MMS/Services/LobbyCleanupService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace MMS.Services; - -/// Background service that removes expired lobbies every 30 seconds. -public class LobbyCleanupService(LobbyService lobbyService) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Console.WriteLine("[CLEANUP] Service started"); - - while (!stoppingToken.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); - - var removed = lobbyService.CleanupDeadLobbies(); - if (removed > 0) { - Console.WriteLine($"[CLEANUP] Removed {removed} expired lobbies"); - } - } - } -} diff --git a/MMS/Services/LobbyService.cs b/MMS/Services/LobbyService.cs deleted file mode 100644 index 08d8276..0000000 --- a/MMS/Services/LobbyService.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System.Collections.Concurrent; -using MMS.Models; - -namespace MMS.Services; - -/// -/// Thread-safe in-memory lobby storage with heartbeat-based expiration. -/// Lobbies are keyed by ConnectionData (Steam ID or IP:Port). -/// -public class LobbyService(LobbyNameService lobbyNameService) { - /// Thread-safe dictionary of lobbies keyed by ConnectionData. - private readonly ConcurrentDictionary _lobbies = new(); - - /// Maps host tokens to ConnectionData for quick lookup. - private readonly ConcurrentDictionary _tokenToConnectionData = new(); - - /// Maps lobby codes to ConnectionData for quick lookup. - private readonly ConcurrentDictionary _codeToConnectionData = new(); - - /// Consolidated metadata for active discovery sessions (matchmaking only). - private readonly ConcurrentDictionary _discoveryMetadata = new(); - - /// Random number generator for token and code generation. - private static readonly Random Random = new(); - - /// Characters used for host authentication tokens (lowercase alphanumeric). - private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789"; - - /// Characters used for lobby codes (uppercase alphanumeric). - private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - /// Length of generated lobby codes. - private const int LobbyCodeLength = 6; - - /// - /// Creates a new lobby keyed by ConnectionData. - /// - public Lobby CreateLobby( - string connectionData, - string lobbyName, - string lobbyType = "matchmaking", - string? hostLanIp = null, - bool isPublic = true - ) { - var hostToken = GenerateToken(32); - - // Only generate lobby codes for matchmaking lobbies - // Steam lobbies use Steam's native join flow (no MMS invite codes) - var lobbyCode = lobbyType == "steam" ? "" : GenerateLobbyCode(); - // Matchmaking lobbies use a discovery token for NAT traversal; Steam lobbies do not. - string? hostDiscoveryToken = null; - if (lobbyType == "matchmaking") { - hostDiscoveryToken = GenerateToken(32); - _discoveryMetadata[hostDiscoveryToken] = new DiscoveryTokenMetadata { - HostConnectionData = connectionData - }; - } - - var lobby = new Lobby(connectionData, hostToken, lobbyCode, lobbyName, lobbyType, hostLanIp, isPublic) { - HostDiscoveryToken = hostDiscoveryToken - }; - - _lobbies[connectionData] = lobby; - _tokenToConnectionData[hostToken] = connectionData; - - // Only register code if we generated one - if (!string.IsNullOrEmpty(lobbyCode)) { - _codeToConnectionData[lobbyCode] = connectionData; - } - - return lobby; - } - - /// - /// Gets lobby by ConnectionData. Returns null if not found or expired. - /// - public Lobby? GetLobby(string connectionData) { - if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null; - if (!lobby.IsDead) return lobby; - - RemoveLobby(connectionData); - return null; - } - - /// - /// Gets lobby by host token. Returns null if not found or expired. - /// - public Lobby? GetLobbyByToken(string token) { - return _tokenToConnectionData.TryGetValue(token, out var connData) ? GetLobby(connData) : null; - } - - /// - /// Gets lobby by lobby code. Returns null if not found or expired. - /// - public Lobby? GetLobbyByCode(string code) { - // Normalize to uppercase for case-insensitive matching - var normalizedCode = code.ToUpperInvariant(); - return _codeToConnectionData.TryGetValue(normalizedCode, out var connData) ? GetLobby(connData) : null; - } - - /// - /// Refreshes lobby heartbeat. Returns false if lobby not found. - /// - public bool Heartbeat(string token) { - var lobby = GetLobbyByToken(token); - if (lobby == null) return false; - - lobby.LastHeartbeat = DateTime.UtcNow; - return true; - } - - /// - /// Removes lobby by host token. Returns false if not found. - /// - public bool RemoveLobbyByToken(string token) { - var lobby = GetLobbyByToken(token); - return lobby != null && RemoveLobby(lobby.ConnectionData); - } - - /// - /// Returns all active (non-expired) lobbies. - /// - public IEnumerable GetAllLobbies() => _lobbies.Values.Where(l => !l.IsDead); - - /// - /// Returns active PUBLIC lobbies, optionally filtered by type ("steam" or "matchmaking"). - /// Private lobbies are excluded from browser listings. - /// - public IEnumerable GetLobbies(string? lobbyType = null) { - var lobbies = _lobbies.Values.Where(l => l is { IsDead: false, IsPublic: true }); - return string.IsNullOrEmpty(lobbyType) - ? lobbies - : lobbies.Where(l => l.LobbyType.Equals(lobbyType, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Removes all expired lobbies. Returns count removed. - /// - public int CleanupDeadLobbies() { - var dead = _lobbies.Values.Where(l => l.IsDead).ToList(); - foreach (var lobby in dead) { - RemoveLobby(lobby.ConnectionData); - } - - // Cleanup expired discovery tokens (older than 2 minutes) - var tokenCutoff = DateTime.UtcNow.AddMinutes(-2); - var expiredTokens = _discoveryMetadata - .Where(kvp => kvp.Value.CreatedAt < tokenCutoff) - .Select(kvp => kvp.Key) - .ToList(); - - foreach (var token in expiredTokens) { - _discoveryMetadata.TryRemove(token, out _); - } - - return dead.Count; - } - - /// - /// Removes a lobby by its ConnectionData and cleans up token/code mappings. - /// - /// The ConnectionData of the lobby to remove. - /// True if the lobby was found and removed; otherwise, false. - private bool RemoveLobby(string connectionData) { - if (!_lobbies.TryRemove(connectionData, out var lobby)) return false; - - _tokenToConnectionData.TryRemove(lobby.HostToken, out _); - _codeToConnectionData.TryRemove(lobby.LobbyCode, out _); - - if (lobby.HostDiscoveryToken != null) { - _discoveryMetadata.TryRemove(lobby.HostDiscoveryToken, out _); - } - - lobbyNameService.FreeLobbyName(lobby.LobbyName); - - return true; - } - - /// - /// Registers a new discovery token for a client (matchmaking only). - /// - public string? RegisterClientDiscoveryToken(string lobbyCode, string clientIp) { - var lobby = GetLobbyByCode(lobbyCode); - if (lobby == null || lobby.LobbyType == "steam") return null; - - var token = GenerateToken(32); - _discoveryMetadata[token] = new DiscoveryTokenMetadata { - LobbyCode = lobbyCode, - ClientIp = clientIp - }; - return token; - } - - /// - /// Gets client info for a discovery token. - /// - public bool TryGetClientInfo(string token, out string lobbyCode, out string clientIp) { - if (_discoveryMetadata.TryGetValue(token, out var metadata) && metadata.ClientIp != null) { - lobbyCode = metadata.LobbyCode ?? ""; - clientIp = metadata.ClientIp; - return true; - } - lobbyCode = ""; - clientIp = ""; - return false; - } - - /// - /// If the token belongs to a host, updates their lobby's external port. - /// - public void ApplyHostPort(string token, int port) { - if (!_discoveryMetadata.TryGetValue(token, out var metadata) || metadata.HostConnectionData == null) return; - var lobby = GetLobby(metadata.HostConnectionData); - if (lobby != null) { - lobby.ExternalPort = port; - } - } - - /// - /// Updates the discovered port for a given token. - /// - public void SetDiscoveredPort(string token, int port) { - if (_discoveryMetadata.TryGetValue(token, out var metadata)) { - metadata.DiscoveredPort = port; - } - } - - /// - /// Gets the discovered port for a given token, if any. - /// - public int? GetDiscoveredPort(string token) { - return _discoveryMetadata.TryGetValue(token, out var metadata) ? metadata.DiscoveredPort : null; - } - - /// - /// Removes a discovery token and its associated metadata. - /// - public void RemoveDiscoveryToken(string token) { - _discoveryMetadata.TryRemove(token, out _); - } - - /// - /// Generates a random token of the specified length. - /// - /// Length of the token to generate. - /// A random alphanumeric token string. - private static string GenerateToken(int length) { - return new string(Enumerable.Range(0, length).Select(_ => TokenChars[Random.Next(TokenChars.Length)]).ToArray()); - } - - /// - /// Generates a unique lobby code, retrying on collision. - /// - /// A unique 6-character uppercase alphanumeric code. - private string GenerateLobbyCode() { - // Generate unique code, retry if collision (extremely rare with 30^6 = 729M combinations) - string code; - do { - code = new string(Enumerable.Range(0, LobbyCodeLength) - .Select(_ => LobbyCodeChars[Random.Next(LobbyCodeChars.Length)]).ToArray()); - } while (_codeToConnectionData.ContainsKey(code)); - return code; - } -} diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs new file mode 100644 index 0000000..b1cd691 --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -0,0 +1,375 @@ +using System.Net.WebSockets; +using MMS.Models; +using MMS.Models.Matchmaking; +using MMS.Services.Lobby; +using MMS.Services.Utility; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Services.Matchmaking; + +/// +/// Coordinates join-session lifecycle, discovery routing, and NAT punch orchestration. +/// +public sealed class JoinSessionCoordinator( + JoinSessionStore store, + JoinSessionMessenger messenger, + LobbyService lobbyService, + ILogger logger +) { + /// + /// Allocates a new join session for a client attempting to connect to . + /// + /// The target lobby. Steam lobbies are rejected immediately. + /// The joining client's IP address, used later for punch coordination. + /// + /// The new , or for Steam lobbies + /// + public JoinSession? CreateJoinSession(_Lobby lobby, string clientIp) { + if (lobby.LobbyType.Equals("steam", StringComparison.OrdinalIgnoreCase)) + return null; + + var session = new JoinSession { + JoinId = TokenGenerator.GenerateToken(32), + LobbyConnectionData = lobby.ConnectionData, + ClientIp = clientIp, + ClientDiscoveryToken = TokenGenerator.GenerateToken(32) + }; + + store.Add(session); + store.UpsertDiscoveryToken( + session.ClientDiscoveryToken, + new DiscoveryTokenMetadata { JoinId = session.JoinId } + ); + + RegisterHostDiscoveryTokenIfAbsent(lobby); + + return session; + } + + /// + /// Returns an active, non-expired session by its identifier. + /// Expired sessions are cleaned up upon access. + /// + /// The join session identifier. + /// The session, or if not found or expired. + public JoinSession? GetJoinSession(string joinId) { + if (!store.TryGet(joinId, out var session) || session == null) + return null; + + if (session.ExpiresAtUtc >= DateTime.UtcNow) + return session; + + CleanupJoinSession(joinId); + return null; + } + + /// + /// Associates a WebSocket with an existing session so the server can push events to the client. + /// + /// The join session identifier. + /// The client's WebSocket connection. + /// if the session was found and the socket was attached. + public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) { + var session = GetJoinSession(joinId); + if (session == null) return false; + + session.ClientWebSocket = webSocket; + return true; + } + + /// + /// Records the externally observed UDP port for a discovery token and advances the punch flow. + /// + /// + /// The token determines whether this is a host or client port discovery event and + /// dispatches to the appropriate handler. + /// + /// The discovery token included in the UDP packet. + /// The external port observed by the server. + /// Propagates notification that the operation should be cancelled. + public async Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) { + if (!store.TryGetDiscoveryMetadata(token, out var metadata) || metadata == null) + return; + + if (!store.TrySetDiscoveredPort(token, port)) + return; + + if (metadata.HostConnectionData != null) { + await HandleHostPortDiscoveredAsync(metadata.HostConnectionData, port, cancellationToken); + return; + } + + if (metadata.JoinId != null) + await HandleClientPortDiscoveredAsync(metadata.JoinId, port, cancellationToken); + } + + /// + /// Returns the externally observed UDP port for a discovery token, or + /// if the port has not yet been recorded. + /// + /// The discovery token to query. + public int? GetDiscoveredPort(string token) => store.GetDiscoveredPort(token); + + /// + /// Sends the begin_client_mapping message to the client identified by . + /// + /// The join session identifier. + /// Propagates notification that the operation should be cancelled. + public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancellationToken) { + var session = GetJoinSession(joinId); + return session == null + ? Task.CompletedTask + : JoinSessionMessenger.SendBeginClientMappingAsync(session, cancellationToken); + } + + /// + /// Asks the host to refresh its NAT mapping for the given join session. + /// + /// The join session identifier used to locate the lobby. + /// Propagates notification that the operation should be cancelled. + /// + /// if the refresh message was dispatched successfully. + /// + public async Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) { + var session = GetJoinSession(joinId); + return session != null && + await messenger.SendHostRefreshRequestAsync(joinId, session.LobbyConnectionData, cancellationToken); + } + + /// + /// Fails a join session: notifies both the client and the host, then cleans up the session. + /// + /// The join session identifier. + /// A short machine-readable failure reason (e.g. "host_unreachable"). + /// Propagates notification that the operation should be cancelled. + public async Task FailJoinSessionAsync( + string joinId, + string reason, + CancellationToken cancellationToken = default + ) { + var session = GetJoinSession(joinId); + if (session == null) return; + + try { + await JoinSessionMessenger.SendJoinFailedToClientAsync(session, reason, cancellationToken); + } catch (Exception ex) when (IsSocketSendFailure(ex)) { + logger.LogDebug(ex, "Failed to notify join client for {JoinId}", joinId); + } + + try { + await messenger.SendJoinFailedToHostAsync(session.LobbyConnectionData, joinId, reason, cancellationToken); + } catch (Exception ex) when (IsSocketSendFailure(ex)) { + logger.LogDebug(ex, "Failed to notify host about join failure for {JoinId}", joinId); + } + + CleanupJoinSession(joinId); + } + + /// + /// Removes all sessions that have passed their expiry time and purges stale discovery tokens. + /// + /// + /// Discovery tokens are considered stale after 2 minutes, giving them a longer + /// grace period than sessions to handle timing edge cases. + /// + public void CleanupExpiredSessions() { + var now = DateTime.UtcNow; + + foreach (var joinId in store.GetExpiredJoinIds(now)) + CleanupJoinSession(joinId); + + foreach (var token in store.GetExpiredDiscoveryTokens(now.AddMinutes(-2))) + store.RemoveDiscoveryToken(token); + } + + /// + /// Removes all sessions belonging to and its host discovery token. + /// Called when a lobby is closed or evicted. + /// + /// The lobby being removed. + public void CleanupSessionsForLobby(_Lobby lobby) { + foreach (var joinId in store.GetJoinIdsForLobby(lobby.ConnectionData)) + CleanupJoinSession(joinId); + + if (!string.IsNullOrEmpty(lobby.HostDiscoveryToken)) + store.RemoveDiscoveryToken(lobby.HostDiscoveryToken); + } + + /// + /// Registers the host discovery token for a lobby if it is not already tracked. + /// This ensures the server can correlate the host's UDP packet with the correct lobby. + /// + private void RegisterHostDiscoveryTokenIfAbsent(_Lobby lobby) { + if (lobby.HostDiscoveryToken == null || store.ContainsDiscoveryToken(lobby.HostDiscoveryToken)) + return; + + store.UpsertDiscoveryToken( + lobby.HostDiscoveryToken, + new DiscoveryTokenMetadata { HostConnectionData = lobby.ConnectionData } + ); + } + + /// + /// Handles a UDP port discovery event originating from the host side. + /// Records the host's external port and attempts to advance any pending client sessions. + /// + private async Task HandleHostPortDiscoveredAsync( + string lobbyConnectionData, + int port, + CancellationToken cancellationToken + ) { + var lobby = lobbyService.GetLobby(lobbyConnectionData); + if (lobby == null) return; + + lobby.ExternalPort = port; + await JoinSessionMessenger.SendHostMappingReceivedAsync(lobby, port, cancellationToken); + await TryStartPendingJoinSessionsAsync(lobbyConnectionData, cancellationToken); + } + + /// + /// Handles a UDP port discovery event originating from the client side. + /// Records the client's external port, requests a host refresh, and attempts to start punching. + /// + private async Task HandleClientPortDiscoveredAsync( + string joinId, + int port, + CancellationToken cancellationToken + ) { + var session = GetJoinSession(joinId); + if (session == null) return; + + session.ClientExternalPort = port; + await JoinSessionMessenger.SendClientMappingReceivedAsync(session, port, cancellationToken); + + var hostRefreshed = await messenger.SendHostRefreshRequestAsync( + joinId, + session.LobbyConnectionData, + cancellationToken + ); + + if (!hostRefreshed) { + await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); + return; + } + + await TryStartJoinSessionAsync(joinId, cancellationToken); + } + + /// + /// Attempts to start all client sessions waiting on the given lobby. + /// Called after the host's external port becomes known. + /// + private async Task TryStartPendingJoinSessionsAsync( + string lobbyConnectionData, + CancellationToken cancellationToken + ) { + foreach (var joinId in store.GetJoinIdsForLobby(lobbyConnectionData)) + await TryStartJoinSessionAsync(joinId, cancellationToken); + } + + /// + /// Attempts to issue synchronized start_punch messages to both sides. + /// + /// + /// The host is notified first so a late host disconnect cannot leave the client punching alone. + /// Any socket failure while dispatching the pair is converted into a join failure. + /// + private async Task TryStartJoinSessionAsync(string joinId, CancellationToken cancellationToken) { + var session = GetJoinSession(joinId); + if (session?.ClientExternalPort == null) return; + + var lobby = lobbyService.GetLobby(session.LobbyConnectionData); + if (lobby == null) { + await FailJoinSessionAsync(joinId, "lobby_closed", cancellationToken); + return; + } + + if (lobby.HostWebSocket is not { State: WebSocketState.Open }) { + await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); + return; + } + + if (session.ClientWebSocket is not { State: WebSocketState.Open }) { + await FailJoinSessionAsync(joinId, "client_disconnected", cancellationToken); + return; + } + + // Host port is not yet known so we wait for the next host discovery event. + if (lobby.ExternalPort == null) return; + + // Matchmaking lobbies store ConnectionData as "IP:Port". + // Steam lobbies are filtered out at session creation. + var hostIp = lobby.ConnectionData.Split(':')[0]; + var startTimeMs = DateTimeOffset.UtcNow + .AddMilliseconds(MatchmakingProtocol.PunchTimingOffsetMs) + .ToUnixTimeMilliseconds(); + + try { + var hostSent = await JoinSessionMessenger.SendStartPunchToHostAsync( + lobby, + joinId, + session.ClientIp, + session.ClientExternalPort.Value, + lobby.ExternalPort.Value, + startTimeMs, + cancellationToken + ); + if (!hostSent) { + await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); + return; + } + + var clientSent = await JoinSessionMessenger.SendStartPunchToClientAsync( + session, + lobby.ExternalPort.Value, + hostIp, + startTimeMs, + cancellationToken + ); + if (!clientSent) { + await FailJoinSessionAsync(joinId, "client_disconnected", cancellationToken); + return; + } + } catch (Exception ex) when (IsSocketSendFailure(ex)) { + logger.LogDebug(ex, "Failed to dispatch start_punch for join {JoinId}", joinId); + await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); + return; + } + + CleanupJoinSession(joinId); + } + + /// + /// Removes a session and its client discovery token from the store, + /// then performs a best-effort close of the client WebSocket. + /// + private void CleanupJoinSession(string joinId) { + if (!store.Remove(joinId, out var session) || session == null) return; + + store.RemoveDiscoveryToken(session.ClientDiscoveryToken); + + if (session.ClientWebSocket is { State: WebSocketState.Open } ws) + _ = CloseJoinSocketAsync(ws, joinId); + } + + /// + /// Attempts a graceful close of the client rendezvous socket using a bounded timeout. + /// + /// The client WebSocket to close. + /// Join identifier used for debug logging. + private async Task CloseJoinSocketAsync(WebSocket webSocket, string joinId) { + try { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "join complete", timeoutCts.Token); + } catch (Exception ex) when (IsSocketSendFailure(ex) || ex is OperationCanceledException) { + logger.LogDebug(ex, "Failed to close client join WebSocket for join {JoinId}", joinId); + } + } + + /// + /// Returns for exceptions that commonly mean the peer disappeared during send/close. + /// + /// The exception raised by a socket operation. + private static bool IsSocketSendFailure(Exception ex) => + ex is WebSocketException or ObjectDisposedException; +} diff --git a/MMS/Services/Matchmaking/JoinSessionMessenger.cs b/MMS/Services/Matchmaking/JoinSessionMessenger.cs new file mode 100644 index 0000000..66c31a3 --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs @@ -0,0 +1,195 @@ +using System.Net.WebSockets; +using MMS.Models.Matchmaking; +using MMS.Services.Lobby; +using MMS.Services.Network; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Services.Matchmaking; + +/// +/// Sends matchmaking rendezvous messages over client and host WebSockets. +/// Methods returning distinguish "target missing" from transport exceptions. +/// +public sealed class JoinSessionMessenger(LobbyService lobbyService) { + /// Tells the joining client to begin its UDP mapping phase. + public static Task SendBeginClientMappingAsync(JoinSession session, CancellationToken cancellationToken) => + SendToJoinClientAsync( + session, + new { + action = "begin_client_mapping", + joinId = session.JoinId, + clientDiscoveryToken = session.ClientDiscoveryToken, + serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }, + cancellationToken + ); + + /// + /// Asks the host to refresh its NAT mapping by re-sending its UDP discovery packet. + /// Returns if the host is unavailable or missing a discovery token. + /// + public async Task SendHostRefreshRequestAsync( + string joinId, + string lobbyConnectionData, + CancellationToken cancellationToken + ) { + var lobby = lobbyService.GetLobby(lobbyConnectionData); + if (lobby?.HostWebSocket is not { State: WebSocketState.Open } hostWs || + string.IsNullOrEmpty(lobby.HostDiscoveryToken)) { + return false; + } + + await WebSocketMessenger.SendAsync( + hostWs, + new { + action = "refresh_host_mapping", + joinId, + hostDiscoveryToken = lobby.HostDiscoveryToken, + serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }, + cancellationToken + ); + return true; + } + + /// Notifies the joining client that its external UDP port has been observed. + public static Task SendClientMappingReceivedAsync( + JoinSession session, + int port, + CancellationToken cancellationToken + ) => + SendToJoinClientAsync( + session, + new { + action = "client_mapping_received", + clientPort = port, + serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }, + cancellationToken + ); + + /// Notifies the host that its external UDP port has been observed. + public static Task SendHostMappingReceivedAsync(_Lobby lobby, int port, CancellationToken cancellationToken) => + SendToHostAsync( + lobby, + new { + action = "host_mapping_received", + hostPort = port, + serverTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }, + cancellationToken + ); + + /// + /// Sends a synchronized NAT punch instruction to the joining client. + /// is the coordinated UTC timestamp (Unix ms) at which both sides punch. + /// Returns if the client socket is not open. + /// + public static async Task SendStartPunchToClientAsync( + JoinSession session, + int hostPort, + string hostIp, + long startTimeMs, + CancellationToken cancellationToken + ) { + if (session.ClientWebSocket is not { State: WebSocketState.Open } ws) + return false; + + await WebSocketMessenger.SendAsync( + ws, + new { + action = "start_punch", + joinId = session.JoinId, + hostIp, + hostPort, + startTimeMs + }, + cancellationToken + ); + return true; + } + + /// + /// Sends a synchronized NAT punch instruction to the lobby host. + /// is the coordinated UTC timestamp (Unix ms) at which both sides punch. + /// Returns if the host socket is not open. + /// + public static async Task SendStartPunchToHostAsync( + _Lobby lobby, + string joinId, + string clientIp, + int clientPort, + int hostPort, + long startTimeMs, + CancellationToken cancellationToken + ) { + if (lobby.HostWebSocket is not { State: WebSocketState.Open } hostWs) + return false; + + await WebSocketMessenger.SendAsync( + hostWs, + new { + action = "start_punch", + joinId, + clientIp, + clientPort, + hostPort, + startTimeMs + }, + cancellationToken + ); + return true; + } + + /// Notifies the joining client that the join attempt has failed. + public static Task SendJoinFailedToClientAsync( + JoinSession session, + string reason, + CancellationToken cancellationToken + ) => + SendToJoinClientAsync( + session, + new { action = "join_failed", joinId = session.JoinId, reason }, + cancellationToken + ); + + /// Notifies the lobby host that a join attempt has failed. + public async Task SendJoinFailedToHostAsync( + string lobbyConnectionData, + string joinId, + string reason, + CancellationToken cancellationToken + ) { + var lobby = lobbyService.GetLobby(lobbyConnectionData); + if (lobby?.HostWebSocket is not { State: WebSocketState.Open } hostWs) + return; + + await WebSocketMessenger.SendAsync( + hostWs, + new { action = "join_failed", joinId, reason }, + cancellationToken + ); + } + + /// Sends to the session's client WebSocket, if open. + private static Task SendToJoinClientAsync( + JoinSession session, + object payload, + CancellationToken cancellationToken + ) { + return session.ClientWebSocket is not { State: WebSocketState.Open } ws + ? Task.CompletedTask + : WebSocketMessenger.SendAsync(ws, payload, cancellationToken); + } + + /// Sends to the lobby's host WebSocket, if open. + private static Task SendToHostAsync( + _Lobby lobby, + object payload, + CancellationToken cancellationToken + ) { + return lobby.HostWebSocket is not { State: WebSocketState.Open } ws + ? Task.CompletedTask + : WebSocketMessenger.SendAsync(ws, payload, cancellationToken); + } +} diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs new file mode 100644 index 0000000..e846c4f --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -0,0 +1,73 @@ +using System.Net.WebSockets; +using MMS.Models.Matchmaking; +using _Lobby = MMS.Models.Lobby.Lobby; + +namespace MMS.Services.Matchmaking; + +/// +/// Compatibility facade over for the join-session +/// lifecycle and NAT hole-punch coordination. +/// +/// +/// A join session represents a single client attempt to connect to a lobby host. +/// Typical flow: +/// +/// POST /lobby/{id}/join -> allocates a session and client discovery token. +/// Client opens a WebSocket -> stores it for server-push events. +/// Client UDP packet arrives -> records the external port and sends refresh_host_mapping to the host. +/// Host UDP packet arrives -> records the host port and sends synchronized start_punch to both sides. +/// +/// +public class JoinSessionService(JoinSessionCoordinator coordinator) { + /// + /// Allocates a new join session for a client attempting to connect to . + /// Returns for Steam lobbies. + /// + public JoinSession? CreateJoinSession(_Lobby lobby, string clientIp) => + coordinator.CreateJoinSession(lobby, clientIp); + + /// Returns an active, non-expired session by its identifier, or if not found or expired. + public JoinSession? GetJoinSession(string joinId) => + coordinator.GetJoinSession(joinId); + + /// + /// Associates a WebSocket with an existing session so the server can push events to the client. + /// Returns if the session was not found. + /// + public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) => + coordinator.AttachJoinWebSocket(joinId, webSocket); + + /// Records the externally observed UDP port for a discovery token and advances the punch flow. + public Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) => + coordinator.SetDiscoveredPortAsync(token, port, cancellationToken); + + /// Returns the externally observed UDP port for a discovery token, or if not yet recorded. + public int? GetDiscoveredPort(string token) => + coordinator.GetDiscoveredPort(token); + + /// Sends the begin_client_mapping message to the client identified by . + public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancellationToken) => + coordinator.SendBeginClientMappingAsync(joinId, cancellationToken); + + /// + /// Asks the host to refresh its NAT mapping for the given join session. + /// Returns if the message could not be dispatched. + /// + public Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) => + coordinator.SendHostRefreshRequestAsync(joinId, cancellationToken); + + /// Notifies both client and host of failure, then cleans up the session. + public Task FailJoinSessionAsync(string joinId, string reason, CancellationToken cancellationToken = default) => + coordinator.FailJoinSessionAsync(joinId, reason, cancellationToken); + + /// Removes all expired sessions and purges stale discovery tokens. + public void CleanupExpiredSessions() => + coordinator.CleanupExpiredSessions(); + + /// + /// Removes all sessions belonging to and its host discovery token. + /// Called when a lobby is closed or evicted. + /// + internal void CleanupSessionsForLobby(_Lobby lobby) => + coordinator.CleanupSessionsForLobby(lobby); +} diff --git a/MMS/Services/Matchmaking/JoinSessionStore.cs b/MMS/Services/Matchmaking/JoinSessionStore.cs new file mode 100644 index 0000000..74188cd --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionStore.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; +using MMS.Models.Matchmaking; + +namespace MMS.Services.Matchmaking; + +/// +/// Thread-safe in-memory store for active join sessions and discovery tokens. +/// Individual operations are atomic; callers own higher-level consistency +/// (e.g. removing a session and its token together). +/// +public sealed class JoinSessionStore { + private readonly ConcurrentDictionary _joinSessions = new(); + private readonly ConcurrentDictionary _discoveryMetadata = new(); + private readonly ConcurrentDictionary> _joinIdsByLobby = new(); + private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = new(); + private readonly Lock _indexLock = new(); + + /// Adds or replaces the session keyed by . + public void Add(JoinSession session) { + if (_joinSessions.TryGetValue(session.JoinId, out var previous)) + RemoveIndexes(previous); + + _joinSessions[session.JoinId] = session; + AddIndexes(session); + } + + /// Attempts to retrieve a session by its join identifier. + public bool TryGet(string joinId, out JoinSession? session) => + _joinSessions.TryGetValue(joinId, out session); + + /// Removes a session and returns it. Returns if not found. + public bool Remove(string joinId, out JoinSession? session) { + if (!_joinSessions.TryRemove(joinId, out session)) + return false; + + RemoveIndexes(session); + + return true; + } + + /// Returns the join identifiers for all sessions belonging to the given lobby. + public IReadOnlyList GetJoinIdsForLobby(string lobbyConnectionData) => + _joinIdsByLobby.TryGetValue(lobbyConnectionData, out var joinIds) + ? joinIds.Keys.ToList() + : []; + + /// Returns the join identifiers of all sessions expired before . + public IReadOnlyList GetExpiredJoinIds(DateTime nowUtc) { + var expiredJoinIds = new List(); + + lock (_indexLock) { + expiredJoinIds.AddRange( + _expiryIndex + .TakeWhile(entry => entry.expiresAtUtc < nowUtc) + .Select(entry => entry.joinId) + ); + } + + return expiredJoinIds; + } + + /// Inserts or replaces the metadata associated with a discovery token. + public void UpsertDiscoveryToken(string token, DiscoveryTokenMetadata metadata) => + _discoveryMetadata[token] = CloneMetadata(metadata); + + /// Returns if the discovery token is currently registered. + public bool ContainsDiscoveryToken(string token) => _discoveryMetadata.ContainsKey(token); + + /// Attempts to retrieve the metadata for a discovery token. + public bool TryGetDiscoveryMetadata(string token, out DiscoveryTokenMetadata? metadata) { + if (!_discoveryMetadata.TryGetValue(token, out var stored)) { + metadata = null; + return false; + } + + metadata = CloneMetadata(stored); + return true; + } + + /// + /// Returns the discovered port for a token, or if the token + /// is unknown or its port has not yet been recorded. + /// + public int? GetDiscoveredPort(string token) => + _discoveryMetadata.TryGetValue(token, out var metadata) ? metadata.DiscoveredPort : null; + + /// Removes a discovery token and its metadata. + public void RemoveDiscoveryToken(string token) => + _discoveryMetadata.TryRemove(token, out _); + + /// Updates only the discovered port for an existing discovery token. + public bool TrySetDiscoveredPort(string token, int port) { + while (_discoveryMetadata.TryGetValue(token, out var metadata)) { + var updated = CloneMetadata(metadata); + updated.DiscoveredPort = port; + + if (_discoveryMetadata.TryUpdate(token, updated, metadata)) + return true; + } + + return false; + } + + /// + /// Returns tokens created before . + /// Used during periodic cleanup to evict stale tokens no longer tied to an active session. + /// + public IReadOnlyList GetExpiredDiscoveryTokens(DateTime cutoffUtc) => + _discoveryMetadata.Where(kvp => kvp.Value.CreatedAt < cutoffUtc) + .Select(kvp => kvp.Key) + .ToList(); + + private void AddIndexes(JoinSession session) { + var lobbyJoinIds = _joinIdsByLobby.GetOrAdd( + session.LobbyConnectionData, _ => new ConcurrentDictionary() + ); + lobbyJoinIds[session.JoinId] = 0; + + lock (_indexLock) { + _expiryIndex.Add((session.ExpiresAtUtc, session.JoinId)); + } + } + + private void RemoveIndexes(JoinSession session) { + if (_joinIdsByLobby.TryGetValue(session.LobbyConnectionData, out var lobbyJoinIds)) + lobbyJoinIds.TryRemove(session.JoinId, out _); + + lock (_indexLock) { + _expiryIndex.Remove((session.ExpiresAtUtc, session.JoinId)); + } + } + + private static DiscoveryTokenMetadata CloneMetadata(DiscoveryTokenMetadata metadata) => + new() { + JoinId = metadata.JoinId, + HostConnectionData = metadata.HostConnectionData, + DiscoveredPort = metadata.DiscoveredPort, + CreatedAt = metadata.CreatedAt + }; +} diff --git a/MMS/Services/Network/UdpDiscoveryService.cs b/MMS/Services/Network/UdpDiscoveryService.cs new file mode 100644 index 0000000..fd0d5db --- /dev/null +++ b/MMS/Services/Network/UdpDiscoveryService.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using MMS.Bootstrap; +using MMS.Services.Matchmaking; + +namespace MMS.Services.Network; + +/// +/// Hosted background service that listens for incoming UDP packets on a fixed port +/// as part of the NAT traversal discovery flow. +/// +/// +/// Each valid packet carries a session token encoded as UTF-8. The sender's observed +/// external endpoint is recorded in , advancing +/// the hole-punch state machine for the corresponding host or client session. +/// +public sealed class UdpDiscoveryService : BackgroundService { + private readonly JoinSessionService _joinSessionService; + private readonly ILogger _logger; + + private static readonly int Port = ProgramState.DiscoveryPort; + + /// + /// Valid discovery packets must be exactly this many bytes. + /// Packets of any other length are dropped before string decoding. + /// + private const int TokenByteLength = 32; + + public UdpDiscoveryService(JoinSessionService joinSessionService, ILogger logger) { + _joinSessionService = joinSessionService; + _logger = logger; + } + + /// + /// Binds a to and enters a receive loop + /// until is cancelled by the hosting infrastructure. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + using var udpClient = new UdpClient(Port); + _logger.LogInformation("UDP Discovery Service listening on port {Port}", Port); + + while (!stoppingToken.IsCancellationRequested) { + try { + var result = await udpClient.ReceiveAsync(stoppingToken); + await ProcessPacketAsync(result.Buffer, result.RemoteEndPoint, stoppingToken); + } catch (OperationCanceledException) { + break; + } catch (Exception ex) { + _logger.LogError(ex, "Error in UDP Discovery Service receive loop"); + } + } + + _logger.LogInformation("UDP Discovery Service stopped"); + } + + /// + /// Validates and processes a single UDP packet. + /// Byte-length is checked before string decoding to avoid allocations for packets + /// that would be rejected anyway (oversized probes, garbage data, etc.). + /// + private async Task ProcessPacketAsync( + byte[] buffer, + IPEndPoint remoteEndPoint, + CancellationToken cancellationToken + ) { + if (buffer.Length != TokenByteLength) { + _logger.LogWarning( + "Received malformed discovery packet from {EndPoint} (length: {Length})", + FormatEndPoint(remoteEndPoint), + buffer.Length + ); + return; + } + + var token = Encoding.UTF8.GetString(buffer); + + _logger.LogDebug( + "Received discovery packet {TokenFingerprint} from {EndPoint}", + GetTokenFingerprint(token), + FormatEndPoint(remoteEndPoint) + ); + + await _joinSessionService.SetDiscoveredPortAsync(token, remoteEndPoint.Port, cancellationToken); + } + + /// Formats an endpoint for logging, redacting it in non-development environments. + private static string FormatEndPoint(IPEndPoint remoteEndPoint) => + ProgramState.IsDevelopment ? remoteEndPoint.ToString() : "[Redacted]"; + + private static string GetTokenFingerprint(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)))[..12]; +} diff --git a/MMS/Services/Network/WebSocketManager.cs b/MMS/Services/Network/WebSocketManager.cs new file mode 100644 index 0000000..872bc28 --- /dev/null +++ b/MMS/Services/Network/WebSocketManager.cs @@ -0,0 +1,26 @@ +using System.Net.WebSockets; +using System.Text.Json; +using MMS.Services.Lobby; +using MMS.Services.Matchmaking; + +namespace MMS.Services.Network; + +/// +/// Serializes and sends JSON payloads over WebSocket connections. +/// Shared by and so that +/// both use the same serialization path without a common base class. +/// +internal static class WebSocketMessenger +{ + /// + /// Serializes to UTF-8 JSON and sends it as a single text frame. + /// + /// The open WebSocket to send on. + /// The object to serialize. Must be JSON-serializable. + /// Token used to cancel the send operation. + public static async Task SendAsync(WebSocket webSocket, object payload, CancellationToken cancellationToken) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload); + await webSocket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } +} diff --git a/MMS/Services/UdpDiscoveryService.cs b/MMS/Services/UdpDiscoveryService.cs deleted file mode 100644 index aec4254..0000000 --- a/MMS/Services/UdpDiscoveryService.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace MMS.Services; - -/// -/// A hosted background service that listens for incoming UDP packets on a fixed port, -/// used as part of a NAT traversal / hole-punching discovery flow. -/// -public sealed class UdpDiscoveryService : BackgroundService { - private readonly LobbyService _lobbyService; - private readonly ILogger _logger; - - /// - /// The UDP port this service binds to at startup. - /// - private const int Port = 5001; - - /// - /// The exact number of bytes a valid discovery packet must contain. - /// Packets shorter or longer than this are considered malformed and are dropped. - /// - private const int TokenByteLength = 32; - - /// - /// Creates a new instance of with the services - /// it needs to store discovered port mappings and emit log output. - /// - /// - /// The lobby service that maps session tokens to their discovered external ports. - /// Called once per valid packet to record the NAT-mapped port for the sending client. - /// - /// - /// Logger used to emit startup, shutdown, and per-packet diagnostic messages. - /// - public UdpDiscoveryService(LobbyService lobbyService, ILogger logger) { - _lobbyService = lobbyService; - _logger = logger; - } - - /// - /// Entry point called by the .NET host when the application starts. - /// Binds a to and enters a receive loop - /// that runs until the host requests shutdown via . - /// - /// - /// Cancellation token provided by the .NET hosting infrastructure. - /// Triggered automatically on application shutdown (e.g. Ctrl+C, SIGTERM, IIS stop). - /// When fired, the current ReceiveAsync call is cancelled and the loop exits cleanly. - /// - /// A that completes when the service has fully stopped. - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - using var udpClient = new UdpClient(Port); - _logger.LogInformation("UDP Discovery Service listening on port {Port}", Port); - - while (!stoppingToken.IsCancellationRequested) { - try { - var result = await udpClient.ReceiveAsync(stoppingToken); - ProcessPacket(result.Buffer, result.RemoteEndPoint); - } catch (OperationCanceledException) { - break; - } catch (Exception ex) { - _logger.LogError(ex, "Error in UDP Discovery Service receive loop"); - } - } - - _logger.LogInformation("UDP Discovery Service stopped"); - } - - /// - /// Validates and processes a single incoming UDP packet. - /// If the packet is well-formed, the session token is decoded and the sender's - /// external port is recorded in . - /// - /// - /// Validation is intentionally done on the raw byte count - /// before decoding to a string. This avoids a heap allocation for packets - /// that would be rejected anyway (e.g. oversized probes, garbage data). - /// - /// - /// The raw bytes received from the UDP socket. Must be exactly - /// bytes long to be considered valid. - /// - /// - /// The IP address and port of the sender as seen by this server — i.e. the - /// NAT-translated public endpoint, not the client's LAN address. - /// The port component of this value is what gets stored as the discovered port. - /// - private void ProcessPacket(byte[] buffer, IPEndPoint remoteEndPoint) { - // Validate byte length before decoding to avoid unnecessary string allocation - if (buffer.Length != TokenByteLength) { - _logger.LogWarning( - "Received malformed discovery packet from {EndPoint} (length: {Length})", - FormatEndPoint(remoteEndPoint), - buffer.Length - ); - return; - } - - var token = Encoding.UTF8.GetString(buffer); - - _logger.LogInformation( - "Received discovery token {Token} from {EndPoint}", - token, - FormatEndPoint(remoteEndPoint) - ); - - _lobbyService.SetDiscoveredPort(token, remoteEndPoint.Port); - } - - private static string FormatEndPoint(IPEndPoint remoteEndPoint) => - Program.IsDevelopment ? remoteEndPoint.ToString() : "[Redacted]"; -} diff --git a/MMS/Services/Utility/TokenGenerator.cs b/MMS/Services/Utility/TokenGenerator.cs new file mode 100644 index 0000000..b2dcc52 --- /dev/null +++ b/MMS/Services/Utility/TokenGenerator.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; + +namespace MMS.Services.Utility; + +/// +/// Generates random tokens and lobby codes using a cryptographically secure RNG. +/// +internal static class TokenGenerator { + private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + /// Fixed length of all generated lobby codes. + private const int LobbyCodeLength = 6; + + /// + /// Generates a random URL-safe token of the requested length. + /// Characters are drawn from lowercase alphanumerics (a-z0-9). + /// + /// Number of characters in the returned token. + /// A random lowercase alphanumeric string of characters. + public static string GenerateToken(int length) => + string.Create( + length, 0, (span, _) => { + for (var i = 0; i < span.Length; i++) + span[i] = TokenChars[RandomNumberGenerator.GetInt32(TokenChars.Length)]; + } + ); + + /// + /// Generates a unique -character lobby code that does not + /// already exist in . + /// Characters are drawn from uppercase alphanumerics (A-Z0-9). + /// + /// The current set of live lobby codes used for collision detection. + /// A unique uppercase alphanumeric lobby code. + public static string GenerateUniqueLobbyCode(IReadOnlySet existingCodes) { + const int maxRetries = 100; + + string code; + var retries = 0; + do { + if (retries++ >= maxRetries) + throw new InvalidOperationException("Failed to generate a unique lobby code after 100 attempts."); + + code = string.Create( + LobbyCodeLength, 0, (span, _) => { + for (var i = 0; i < span.Length; i++) + span[i] = LobbyCodeChars[RandomNumberGenerator.GetInt32(LobbyCodeChars.Length)]; + } + ); + } while (existingCodes.Contains(code)); + + return code; + } +} From fbf4cef7b23e857af78e245f051a7f78926bcdb8 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Wed, 18 Mar 2026 23:54:22 +0200 Subject: [PATCH 2/6] chore: Added missing comments --- MMS/Bootstrap/HttpsCertificateConfigurator.cs | 15 ++- MMS/Bootstrap/ProgramState.cs | 3 + MMS/Program.cs | 4 - MMS/Services/Lobby/LobbyNameService.cs | 8 +- MMS/Services/Lobby/LobbyService.cs | 38 +++++++- .../Matchmaking/JoinSessionCoordinator.cs | 95 +++++++++++++------ .../Matchmaking/JoinSessionService.cs | 34 ++++--- MMS/Services/Matchmaking/JoinSessionStore.cs | 33 +++++++ MMS/Services/Network/UdpDiscoveryService.cs | 21 ++++ MMS/Services/Utility/TokenGenerator.cs | 4 + 10 files changed, 203 insertions(+), 52 deletions(-) diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs index 240f02c..b285afa 100644 --- a/MMS/Bootstrap/HttpsCertificateConfigurator.cs +++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs @@ -7,11 +7,24 @@ namespace MMS.Bootstrap; /// Configures Kestrel HTTPS bindings from PEM certificate files in the working directory. /// internal static class HttpsCertificateConfigurator { + /// + /// The filename of the PEM-encoded certificate. + /// private const string CertFile = "cert.pem"; + + /// + /// The filename of the PEM-encoded private key. + /// private const string KeyFile = "key.pem"; + /// + /// Static logger instance for the configurator. + /// private static readonly ILogger Logger; + /// + /// Initializes static members of the class. + /// static HttpsCertificateConfigurator() { using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(o => { o.SingleLine = true; @@ -94,7 +107,7 @@ private static bool TryCreateCertificate(string pem, string key, out X509Certifi var pkcs12 = ephemeralCertificate.Export(X509ContentType.Pkcs12); certificate = X509CertificateLoader.LoadPkcs12( pkcs12, - password: (string?) null, + password: null, X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.Exportable diff --git a/MMS/Bootstrap/ProgramState.cs b/MMS/Bootstrap/ProgramState.cs index 2cee46f..7fe008a 100644 --- a/MMS/Bootstrap/ProgramState.cs +++ b/MMS/Bootstrap/ProgramState.cs @@ -14,5 +14,8 @@ internal static class ProgramState { /// public static ILogger Logger { get; internal set; } = null!; + /// + /// Gets the fixed UDP port used for discovery packets. + /// public static int DiscoveryPort => 5001; } diff --git a/MMS/Program.cs b/MMS/Program.cs index 173a387..aaa7613 100644 --- a/MMS/Program.cs +++ b/MMS/Program.cs @@ -16,8 +16,6 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); var isDevelopment = builder.Environment.IsDevelopment(); - // ProgramState is initialized once during startup - // and treated as read-only thereafter. ProgramState.IsDevelopment = isDevelopment; builder.Services.AddMmsCoreServices(); @@ -31,8 +29,6 @@ public static void Main(string[] args) { } var app = builder.Build(); - // ProgramState.Logger is assigned once after the host is built, - // it should only be read after this point. ProgramState.Logger = app.Logger; app.UseMmsPipeline(isDevelopment); diff --git a/MMS/Services/Lobby/LobbyNameService.cs b/MMS/Services/Lobby/LobbyNameService.cs index e3f5c56..aad4649 100644 --- a/MMS/Services/Lobby/LobbyNameService.cs +++ b/MMS/Services/Lobby/LobbyNameService.cs @@ -32,10 +32,16 @@ public class LobbyNameService { /// private readonly Random _random = new(); + /// + /// Initializes a new instance of the class. + /// Loads the lobby name data from the embedded JSON resource. + /// + /// Thrown when the embedded JSON resource cannot be found. + /// Thrown when the JSON resource is malformed or empty. public LobbyNameService() { var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(LobbyNameDataFilePath); if (resourceStream == null) { - throw new MissingManifestResourceException("Could not load lobby name data from embedded resource"); + throw new MissingManifestResourceException($"Could not load lobby name data from embedded resource: {LobbyNameDataFilePath}"); } using var streamReader = new StreamReader(resourceStream); diff --git a/MMS/Services/Lobby/LobbyService.cs b/MMS/Services/Lobby/LobbyService.cs index ccc1d13..92d0d6e 100644 --- a/MMS/Services/Lobby/LobbyService.cs +++ b/MMS/Services/Lobby/LobbyService.cs @@ -12,12 +12,40 @@ namespace MMS.Services.Lobby; /// NAT hole-punch coordination and join session management are delegated to /// . /// -public class LobbyService(LobbyNameService lobbyNameService) { +public class LobbyService { + /// + /// The backing store for all active lobbies, keyed by their unique connection data. + /// private readonly ConcurrentDictionary _lobbies = new(); + + /// + /// Map for looking up lobbies by their host authentication token. + /// private readonly ConcurrentDictionary _tokenToConnectionData = new(); + + /// + /// Map for looking up lobbies by their short player-facing join code. + /// private readonly ConcurrentDictionary _codeToConnectionData = new(); + + /// + /// Thread synchronization primitive for atomic lobby creation and cleanup. + /// private readonly Lock _createLobbyLock = new(); + /// + /// Service used to generate random human-readable names for new lobbies. + /// + private readonly LobbyNameService _lobbyNameService; + + /// + /// Initializes a new instance of the class. + /// + /// Service used to generate random human-readable names for new lobbies. + public LobbyService(LobbyNameService lobbyNameService) { + _lobbyNameService = lobbyNameService; + } + /// /// Creates and stores a new lobby. /// @@ -50,7 +78,7 @@ public _Lobby CreateLobby( if (_lobbies.TryGetValue(connectionData, out var existingLobby)) { RemoveLobbyIndexes(existingLobby); if (!string.IsNullOrEmpty(existingLobby.LobbyName)) - lobbyNameService.FreeLobbyName(existingLobby.LobbyName); + _lobbyNameService.FreeLobbyName(existingLobby.LobbyName); } var lobbyCode = IsSteamLobby(lobbyType) @@ -189,11 +217,13 @@ private bool RemoveLobby(_Lobby lobby, Action<_Lobby>? onRemoving = null) { try { onRemoving?.Invoke(lobby); } catch (Exception ex) { - ProgramState.Logger.LogWarning(ex, "Lobby removal callback failed for {ConnectionData}", lobby.ConnectionData); + ProgramState.Logger.LogWarning( + ex, "Lobby removal callback failed for {ConnectionData}", lobby.ConnectionData + ); } finally { _tokenToConnectionData.TryRemove(lobby.HostToken, out _); _codeToConnectionData.TryRemove(lobby.LobbyCode, out _); - lobbyNameService.FreeLobbyName(lobby.LobbyName); + _lobbyNameService.FreeLobbyName(lobby.LobbyName); } return true; diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs index b1cd691..f40374c 100644 --- a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -10,12 +10,45 @@ namespace MMS.Services.Matchmaking; /// /// Coordinates join-session lifecycle, discovery routing, and NAT punch orchestration. /// -public sealed class JoinSessionCoordinator( - JoinSessionStore store, - JoinSessionMessenger messenger, - LobbyService lobbyService, - ILogger logger -) { +public sealed class JoinSessionCoordinator { + /// + /// Thread-safe in-memory store for active join sessions and discovery tokens. + /// + private readonly JoinSessionStore _store; + + /// + /// Service for sending WebSocket and HTTP messages to clients and hosts. + /// + private readonly JoinSessionMessenger _messenger; + + /// + /// Service for managing lobby state and metadata. + /// + private readonly LobbyService _lobbyService; + + /// + /// Logger instance for this coordinator. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Thread-safe in-memory store for active join sessions. + /// Service for sending session-related messages. + /// Service for managing lobby state. + /// Logger instance for this coordinator. + public JoinSessionCoordinator( + JoinSessionStore store, + JoinSessionMessenger messenger, + LobbyService lobbyService, + ILogger logger + ) { + _store = store; + _messenger = messenger; + _lobbyService = lobbyService; + _logger = logger; + } /// /// Allocates a new join session for a client attempting to connect to . /// @@ -35,8 +68,8 @@ ILogger logger ClientDiscoveryToken = TokenGenerator.GenerateToken(32) }; - store.Add(session); - store.UpsertDiscoveryToken( + _store.Add(session); + _store.UpsertDiscoveryToken( session.ClientDiscoveryToken, new DiscoveryTokenMetadata { JoinId = session.JoinId } ); @@ -53,7 +86,7 @@ ILogger logger /// The join session identifier. /// The session, or if not found or expired. public JoinSession? GetJoinSession(string joinId) { - if (!store.TryGet(joinId, out var session) || session == null) + if (!_store.TryGet(joinId, out var session) || session == null) return null; if (session.ExpiresAtUtc >= DateTime.UtcNow) @@ -88,10 +121,10 @@ public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) { /// The external port observed by the server. /// Propagates notification that the operation should be cancelled. public async Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) { - if (!store.TryGetDiscoveryMetadata(token, out var metadata) || metadata == null) + if (!_store.TryGetDiscoveryMetadata(token, out var metadata) || metadata == null) return; - if (!store.TrySetDiscoveredPort(token, port)) + if (!_store.TrySetDiscoveredPort(token, port)) return; if (metadata.HostConnectionData != null) { @@ -108,7 +141,7 @@ public async Task SetDiscoveredPortAsync(string token, int port, CancellationTok /// if the port has not yet been recorded. /// /// The discovery token to query. - public int? GetDiscoveredPort(string token) => store.GetDiscoveredPort(token); + public int? GetDiscoveredPort(string token) => _store.GetDiscoveredPort(token); /// /// Sends the begin_client_mapping message to the client identified by . @@ -133,7 +166,7 @@ public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancell public async Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) { var session = GetJoinSession(joinId); return session != null && - await messenger.SendHostRefreshRequestAsync(joinId, session.LobbyConnectionData, cancellationToken); + await _messenger.SendHostRefreshRequestAsync(joinId, session.LobbyConnectionData, cancellationToken); } /// @@ -153,13 +186,13 @@ public async Task FailJoinSessionAsync( try { await JoinSessionMessenger.SendJoinFailedToClientAsync(session, reason, cancellationToken); } catch (Exception ex) when (IsSocketSendFailure(ex)) { - logger.LogDebug(ex, "Failed to notify join client for {JoinId}", joinId); + _logger.LogDebug(ex, "Failed to notify join client for {JoinId}", joinId); } try { - await messenger.SendJoinFailedToHostAsync(session.LobbyConnectionData, joinId, reason, cancellationToken); + await _messenger.SendJoinFailedToHostAsync(session.LobbyConnectionData, joinId, reason, cancellationToken); } catch (Exception ex) when (IsSocketSendFailure(ex)) { - logger.LogDebug(ex, "Failed to notify host about join failure for {JoinId}", joinId); + _logger.LogDebug(ex, "Failed to notify host about join failure for {JoinId}", joinId); } CleanupJoinSession(joinId); @@ -175,11 +208,11 @@ public async Task FailJoinSessionAsync( public void CleanupExpiredSessions() { var now = DateTime.UtcNow; - foreach (var joinId in store.GetExpiredJoinIds(now)) + foreach (var joinId in _store.GetExpiredJoinIds(now)) CleanupJoinSession(joinId); - foreach (var token in store.GetExpiredDiscoveryTokens(now.AddMinutes(-2))) - store.RemoveDiscoveryToken(token); + foreach (var token in _store.GetExpiredDiscoveryTokens(now.AddMinutes(-2))) + _store.RemoveDiscoveryToken(token); } /// @@ -188,11 +221,11 @@ public void CleanupExpiredSessions() { /// /// The lobby being removed. public void CleanupSessionsForLobby(_Lobby lobby) { - foreach (var joinId in store.GetJoinIdsForLobby(lobby.ConnectionData)) + foreach (var joinId in _store.GetJoinIdsForLobby(lobby.ConnectionData)) CleanupJoinSession(joinId); if (!string.IsNullOrEmpty(lobby.HostDiscoveryToken)) - store.RemoveDiscoveryToken(lobby.HostDiscoveryToken); + _store.RemoveDiscoveryToken(lobby.HostDiscoveryToken); } /// @@ -200,10 +233,10 @@ public void CleanupSessionsForLobby(_Lobby lobby) { /// This ensures the server can correlate the host's UDP packet with the correct lobby. /// private void RegisterHostDiscoveryTokenIfAbsent(_Lobby lobby) { - if (lobby.HostDiscoveryToken == null || store.ContainsDiscoveryToken(lobby.HostDiscoveryToken)) + if (lobby.HostDiscoveryToken == null || _store.ContainsDiscoveryToken(lobby.HostDiscoveryToken)) return; - store.UpsertDiscoveryToken( + _store.UpsertDiscoveryToken( lobby.HostDiscoveryToken, new DiscoveryTokenMetadata { HostConnectionData = lobby.ConnectionData } ); @@ -218,7 +251,7 @@ private async Task HandleHostPortDiscoveredAsync( int port, CancellationToken cancellationToken ) { - var lobby = lobbyService.GetLobby(lobbyConnectionData); + var lobby = _lobbyService.GetLobby(lobbyConnectionData); if (lobby == null) return; lobby.ExternalPort = port; @@ -241,7 +274,7 @@ CancellationToken cancellationToken session.ClientExternalPort = port; await JoinSessionMessenger.SendClientMappingReceivedAsync(session, port, cancellationToken); - var hostRefreshed = await messenger.SendHostRefreshRequestAsync( + var hostRefreshed = await _messenger.SendHostRefreshRequestAsync( joinId, session.LobbyConnectionData, cancellationToken @@ -263,7 +296,7 @@ private async Task TryStartPendingJoinSessionsAsync( string lobbyConnectionData, CancellationToken cancellationToken ) { - foreach (var joinId in store.GetJoinIdsForLobby(lobbyConnectionData)) + foreach (var joinId in _store.GetJoinIdsForLobby(lobbyConnectionData)) await TryStartJoinSessionAsync(joinId, cancellationToken); } @@ -278,7 +311,7 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can var session = GetJoinSession(joinId); if (session?.ClientExternalPort == null) return; - var lobby = lobbyService.GetLobby(session.LobbyConnectionData); + var lobby = _lobbyService.GetLobby(session.LobbyConnectionData); if (lobby == null) { await FailJoinSessionAsync(joinId, "lobby_closed", cancellationToken); return; @@ -331,7 +364,7 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can return; } } catch (Exception ex) when (IsSocketSendFailure(ex)) { - logger.LogDebug(ex, "Failed to dispatch start_punch for join {JoinId}", joinId); + _logger.LogDebug(ex, "Failed to dispatch start_punch for join {JoinId}", joinId); await FailJoinSessionAsync(joinId, "host_unreachable", cancellationToken); return; } @@ -344,9 +377,9 @@ private async Task TryStartJoinSessionAsync(string joinId, CancellationToken can /// then performs a best-effort close of the client WebSocket. /// private void CleanupJoinSession(string joinId) { - if (!store.Remove(joinId, out var session) || session == null) return; + if (!_store.Remove(joinId, out var session) || session == null) return; - store.RemoveDiscoveryToken(session.ClientDiscoveryToken); + _store.RemoveDiscoveryToken(session.ClientDiscoveryToken); if (session.ClientWebSocket is { State: WebSocketState.Open } ws) _ = CloseJoinSocketAsync(ws, joinId); @@ -362,7 +395,7 @@ private async Task CloseJoinSocketAsync(WebSocket webSocket, string joinId) { using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "join complete", timeoutCts.Token); } catch (Exception ex) when (IsSocketSendFailure(ex) || ex is OperationCanceledException) { - logger.LogDebug(ex, "Failed to close client join WebSocket for join {JoinId}", joinId); + _logger.LogDebug(ex, "Failed to close client join WebSocket for join {JoinId}", joinId); } } diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs index e846c4f..a6dd68d 100644 --- a/MMS/Services/Matchmaking/JoinSessionService.cs +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -18,56 +18,68 @@ namespace MMS.Services.Matchmaking; /// Host UDP packet arrives -> records the host port and sends synchronized start_punch to both sides. /// /// -public class JoinSessionService(JoinSessionCoordinator coordinator) { +public class JoinSessionService { + /// + /// The core coordinator that handles the cross-component logic for joining sessions. + /// + private readonly JoinSessionCoordinator _coordinator; + + /// + /// Initializes a new instance of the class. + /// + /// The core coordinator that handles join session lifecycle logic. + public JoinSessionService(JoinSessionCoordinator coordinator) { + _coordinator = coordinator; + } /// /// Allocates a new join session for a client attempting to connect to . /// Returns for Steam lobbies. /// public JoinSession? CreateJoinSession(_Lobby lobby, string clientIp) => - coordinator.CreateJoinSession(lobby, clientIp); + _coordinator.CreateJoinSession(lobby, clientIp); /// Returns an active, non-expired session by its identifier, or if not found or expired. public JoinSession? GetJoinSession(string joinId) => - coordinator.GetJoinSession(joinId); + _coordinator.GetJoinSession(joinId); /// /// Associates a WebSocket with an existing session so the server can push events to the client. /// Returns if the session was not found. /// public bool AttachJoinWebSocket(string joinId, WebSocket webSocket) => - coordinator.AttachJoinWebSocket(joinId, webSocket); + _coordinator.AttachJoinWebSocket(joinId, webSocket); /// Records the externally observed UDP port for a discovery token and advances the punch flow. public Task SetDiscoveredPortAsync(string token, int port, CancellationToken cancellationToken = default) => - coordinator.SetDiscoveredPortAsync(token, port, cancellationToken); + _coordinator.SetDiscoveredPortAsync(token, port, cancellationToken); /// Returns the externally observed UDP port for a discovery token, or if not yet recorded. public int? GetDiscoveredPort(string token) => - coordinator.GetDiscoveredPort(token); + _coordinator.GetDiscoveredPort(token); /// Sends the begin_client_mapping message to the client identified by . public Task SendBeginClientMappingAsync(string joinId, CancellationToken cancellationToken) => - coordinator.SendBeginClientMappingAsync(joinId, cancellationToken); + _coordinator.SendBeginClientMappingAsync(joinId, cancellationToken); /// /// Asks the host to refresh its NAT mapping for the given join session. /// Returns if the message could not be dispatched. /// public Task SendHostRefreshRequestAsync(string joinId, CancellationToken cancellationToken) => - coordinator.SendHostRefreshRequestAsync(joinId, cancellationToken); + _coordinator.SendHostRefreshRequestAsync(joinId, cancellationToken); /// Notifies both client and host of failure, then cleans up the session. public Task FailJoinSessionAsync(string joinId, string reason, CancellationToken cancellationToken = default) => - coordinator.FailJoinSessionAsync(joinId, reason, cancellationToken); + _coordinator.FailJoinSessionAsync(joinId, reason, cancellationToken); /// Removes all expired sessions and purges stale discovery tokens. public void CleanupExpiredSessions() => - coordinator.CleanupExpiredSessions(); + _coordinator.CleanupExpiredSessions(); /// /// Removes all sessions belonging to and its host discovery token. /// Called when a lobby is closed or evicted. /// internal void CleanupSessionsForLobby(_Lobby lobby) => - coordinator.CleanupSessionsForLobby(lobby); + _coordinator.CleanupSessionsForLobby(lobby); } diff --git a/MMS/Services/Matchmaking/JoinSessionStore.cs b/MMS/Services/Matchmaking/JoinSessionStore.cs index 74188cd..5c2fe30 100644 --- a/MMS/Services/Matchmaking/JoinSessionStore.cs +++ b/MMS/Services/Matchmaking/JoinSessionStore.cs @@ -9,10 +9,30 @@ namespace MMS.Services.Matchmaking; /// (e.g. removing a session and its token together). /// public sealed class JoinSessionStore { + /// + /// Thread-safe dictionary of active join sessions keyed by . + /// private readonly ConcurrentDictionary _joinSessions = new(); + + /// + /// Metadata for active discovery tokens, used for correlating UDP discovery packets. + /// private readonly ConcurrentDictionary _discoveryMetadata = new(); + + /// + /// Secondary index for efficient lookup of join sessions by lobby connection data. + /// Valus are dummy bytes to use as a set. + /// private readonly ConcurrentDictionary> _joinIdsByLobby = new(); + + /// + /// Secondary index for efficient lookup of expired sessions. + /// private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = new(); + + /// + /// Lock for thread-safe access to . + /// private readonly Lock _indexLock = new(); /// Adds or replaces the session keyed by . @@ -110,6 +130,10 @@ public IReadOnlyList GetExpiredDiscoveryTokens(DateTime cutoffUtc) => .Select(kvp => kvp.Key) .ToList(); + /// + /// Adds the session to the lobby and expiry indexes. + /// + /// The session to index. private void AddIndexes(JoinSession session) { var lobbyJoinIds = _joinIdsByLobby.GetOrAdd( session.LobbyConnectionData, _ => new ConcurrentDictionary() @@ -121,6 +145,10 @@ private void AddIndexes(JoinSession session) { } } + /// + /// Removes the session from the lobby and expiry indexes. + /// + /// The session to de-index. private void RemoveIndexes(JoinSession session) { if (_joinIdsByLobby.TryGetValue(session.LobbyConnectionData, out var lobbyJoinIds)) lobbyJoinIds.TryRemove(session.JoinId, out _); @@ -130,6 +158,11 @@ private void RemoveIndexes(JoinSession session) { } } + /// + /// Creates a deep copy of the given discovery metadata. + /// + /// The metadata to clone. + /// A new instance with identical values. private static DiscoveryTokenMetadata CloneMetadata(DiscoveryTokenMetadata metadata) => new() { JoinId = metadata.JoinId, diff --git a/MMS/Services/Network/UdpDiscoveryService.cs b/MMS/Services/Network/UdpDiscoveryService.cs index fd0d5db..80b699a 100644 --- a/MMS/Services/Network/UdpDiscoveryService.cs +++ b/MMS/Services/Network/UdpDiscoveryService.cs @@ -17,9 +17,19 @@ namespace MMS.Services.Network; /// the hole-punch state machine for the corresponding host or client session. /// public sealed class UdpDiscoveryService : BackgroundService { + /// + /// Service used to record discovered UDP ports and advance join session states. + /// private readonly JoinSessionService _joinSessionService; + + /// + /// Logger instance for this service. + /// private readonly ILogger _logger; + /// + /// The fixed UDP port used for discovery packets. + /// private static readonly int Port = ProgramState.DiscoveryPort; /// @@ -28,6 +38,11 @@ public sealed class UdpDiscoveryService : BackgroundService { /// private const int TokenByteLength = 32; + /// + /// Initializes a new instance of the class. + /// + /// Service used to record discovered UDP ports and advance join session states. + /// Logger instance for this service. public UdpDiscoveryService(JoinSessionService joinSessionService, ILogger logger) { _joinSessionService = joinSessionService; _logger = logger; @@ -89,6 +104,12 @@ CancellationToken cancellationToken private static string FormatEndPoint(IPEndPoint remoteEndPoint) => ProgramState.IsDevelopment ? remoteEndPoint.ToString() : "[Redacted]"; + /// + /// Generates a short, non-reversible SHA-256 fingerprint for a session token. + /// Used for correlation in debug logs without exposing the full token. + /// + /// The token string whose fingerprint to generate. + /// A 12-character hex string representing the fingerprint. private static string GetTokenFingerprint(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)))[..12]; } diff --git a/MMS/Services/Utility/TokenGenerator.cs b/MMS/Services/Utility/TokenGenerator.cs index b2dcc52..0338ab0 100644 --- a/MMS/Services/Utility/TokenGenerator.cs +++ b/MMS/Services/Utility/TokenGenerator.cs @@ -6,7 +6,11 @@ namespace MMS.Services.Utility; /// Generates random tokens and lobby codes using a cryptographically secure RNG. /// internal static class TokenGenerator { + + /// Characters used for host authentication tokens (lowercase alphanumeric). private const string TokenChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + + /// Characters used for lobby codes (uppercase alphanumeric). private const string LobbyCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; /// Fixed length of all generated lobby codes. From 9e4531ed08a39507bcd7d9140dec19010bf83ff7 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Thu, 19 Mar 2026 15:21:22 +0200 Subject: [PATCH 3/6] refactor(LobbyCleanupService): extract helper methods and debounce cleanup logs feat: add PrivacyFormatter utility class chore: redact sensitive data from logs in production --- MMS/Features/Lobby/LobbyEndpointHandlers.cs | 17 +-- MMS/Features/WebSockets/WebSocketEndpoints.cs | 19 +-- MMS/Services/Lobby/LobbyCleanupService.cs | 99 +++++++++++++--- MMS/Services/Lobby/LobbyService.cs | 2 +- .../Matchmaking/JoinSessionCoordinator.cs | 12 +- .../Matchmaking/JoinSessionService.cs | 2 +- MMS/Services/Network/UdpDiscoveryService.cs | 9 +- MMS/Services/Utility/PrivacyFormatter.cs | 111 ++++++++++++++++++ 8 files changed, 218 insertions(+), 53 deletions(-) create mode 100644 MMS/Services/Utility/PrivacyFormatter.cs diff --git a/MMS/Features/Lobby/LobbyEndpointHandlers.cs b/MMS/Features/Lobby/LobbyEndpointHandlers.cs index d2fad17..51de986 100644 --- a/MMS/Features/Lobby/LobbyEndpointHandlers.cs +++ b/MMS/Features/Lobby/LobbyEndpointHandlers.cs @@ -5,6 +5,7 @@ using MMS.Models; using MMS.Services.Lobby; using MMS.Services.Matchmaking; +using MMS.Services.Utility; using static MMS.Contracts.Requests; using static MMS.Contracts.Responses; using _Lobby = MMS.Models.Lobby.Lobby; @@ -62,7 +63,7 @@ HttpContext context lobby.LobbyName, lobby.LobbyType, lobby.IsPublic ? "Public" : "Private", - RedactInProduction(lobby.AdvertisedConnectionData), + PrivacyFormatter.Format(lobby.AdvertisedConnectionData), lobby.LobbyCode ); @@ -142,10 +143,9 @@ HttpContext context return clientIpError!; ProgramState.Logger.LogInformation( - "[JOIN] {ConnectionDetails}", - ProgramState.IsDevelopment - ? $"{clientIp}:{request.ClientPort} -> {lobby.AdvertisedConnectionData}" - : $"[Redacted]:{request.ClientPort} -> [Redacted]" + "[JOIN] {ClientEndPoint} -> {LobbyEndPoint}", + $"{PrivacyFormatter.Format(clientIp)}:{request.ClientPort}", + PrivacyFormatter.Format(lobby.AdvertisedConnectionData) ); var lanConnectionData = TryResolveLanConnectionData(lobby, clientIp); @@ -259,7 +259,7 @@ out IResult? error ProgramState.Logger.LogInformation( "[JOIN] Local network detected - returning LAN IP: {HostLanIp}", - lobby.HostLanIp + PrivacyFormatter.Format(lobby.HostLanIp) ); return lobby.HostLanIp; @@ -276,9 +276,4 @@ private static IResult MatchmakingOutdatedResult() => ) ); - /// - /// Returns the value as-is in development, or [Redacted] in production. - /// - private static string RedactInProduction(string value) => - ProgramState.IsDevelopment ? value : "[Redacted]"; } diff --git a/MMS/Features/WebSockets/WebSocketEndpoints.cs b/MMS/Features/WebSockets/WebSocketEndpoints.cs index 2a8dd74..1e8b229 100644 --- a/MMS/Features/WebSockets/WebSocketEndpoints.cs +++ b/MMS/Features/WebSockets/WebSocketEndpoints.cs @@ -7,8 +7,8 @@ using MMS.Models.Matchmaking; using MMS.Services.Lobby; using MMS.Services.Matchmaking; +using MMS.Services.Utility; using static MMS.Contracts.Responses; -using _Lobby = MMS.Models.Lobby.Lobby; namespace MMS.Features.WebSockets; @@ -59,24 +59,24 @@ LobbyService lobbyService using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); var previousSocket = lobby.HostWebSocket; if (previousSocket != null && !ReferenceEquals(previousSocket, webSocket)) - await CloseReplacedHostSocketAsync(previousSocket, GetLobbyIdentifier(lobby), context.RequestAborted); + await CloseReplacedHostSocketAsync(previousSocket, PrivacyFormatter.Format(lobby.ConnectionData), context.RequestAborted); lobby.HostWebSocket = webSocket; ProgramState.Logger.LogInformation( "[WS] Host connected for lobby {LobbyIdentifier}", - GetLobbyIdentifier(lobby) + PrivacyFormatter.Format(lobby.ConnectionData) ); try { - await DrainHostWebSocketAsync(webSocket, GetLobbyIdentifier(lobby)); + await DrainHostWebSocketAsync(webSocket, PrivacyFormatter.Format(lobby.ConnectionData)); } finally { if (ReferenceEquals(lobby.HostWebSocket, webSocket)) lobby.HostWebSocket = null; ProgramState.Logger.LogInformation( "[WS] Host disconnected from lobby {LobbyIdentifier}", - GetLobbyIdentifier(lobby) + PrivacyFormatter.Format(lobby.ConnectionData) ); } } @@ -289,13 +289,4 @@ CancellationToken cancellationToken } } - /// - /// Returns a lobby identifier appropriate for the current environment. - /// Uses in development for full diagnostic detail, - /// and in production to avoid leaking connection info. - /// - /// The lobby to identify. - /// A string identifier suitable for logging. - private static string GetLobbyIdentifier(_Lobby lobby) => - ProgramState.IsDevelopment ? lobby.ConnectionData : lobby.LobbyName; } diff --git a/MMS/Services/Lobby/LobbyCleanupService.cs b/MMS/Services/Lobby/LobbyCleanupService.cs index af20ef9..67d3216 100644 --- a/MMS/Services/Lobby/LobbyCleanupService.cs +++ b/MMS/Services/Lobby/LobbyCleanupService.cs @@ -8,27 +8,90 @@ public class LobbyCleanupService( JoinSessionService joinSessionService, ILogger logger ) : BackgroundService { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - logger.LogInformation("Lobby cleanup service started"); + /// How often the cleanup pass runs. + private static readonly TimeSpan CleanupInterval + = TimeSpan.FromSeconds(30); + + /// Accumulated lobby removals not yet written to the log. + private int _pendingLobbies; + + /// Accumulated session removals not yet written to the log. + private int _pendingSessions; + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - try { - await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); - } catch (OperationCanceledException) { - break; - } - - try { - var removed = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby); - joinSessionService.CleanupExpiredSessions(); - if (removed > 0) { - logger.LogInformation("Removed {RemovedCount} expired lobbies", removed); - } - } catch (Exception ex) { - logger.LogError(ex, "Lobby cleanup iteration failed"); - } + if (!await WaitForNextCycle(stoppingToken)) + return; + + RunCleanup(); } + } + + /// + /// Delays execution for one . + /// + /// + /// if the delay completed normally; + /// if the service is stopping and the loop should exit. + /// + private static async Task WaitForNextCycle(CancellationToken stoppingToken) { + try { + await Task.Delay(CleanupInterval, stoppingToken); + return true; + } catch (OperationCanceledException) { + return false; + } + } + + /// + /// Runs one cleanup pass, removing dead lobbies and expired sessions, + /// then delegates logging to . + /// + private void RunCleanup() { + try { + var removedLobbies + = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby); + var removedSessions + = joinSessionService.CleanupExpiredSessions(); + + AccumulateAndLog(removedLobbies, removedSessions); + } catch (Exception ex) { + logger.LogError(ex, "Lobby cleanup iteration failed"); + } + } + + /// + /// Accumulates removal counts across consecutive active cycles. + /// Calls once a quiet cycle (nothing removed) is observed, + /// emitting at most one log line per burst regardless of how many cycles it spans. + /// + /// Lobbies removed during this cycle. + /// Sessions removed during this cycle. + private void AccumulateAndLog(int removedLobbies, int removedSessions) { + if (removedLobbies > 0 || removedSessions > 0) { + _pendingLobbies += removedLobbies; + _pendingSessions += removedSessions; + return; + } + + FlushPendingLogIfAny(); + } + + /// + /// Writes accumulated removal counts to the log and resets the pending totals. + /// Does nothing if there is nothing to report. + /// + private void FlushPendingLogIfAny() { + if (_pendingLobbies == 0 && _pendingSessions == 0) + return; + + logger.LogInformation( + "Cleanup removed {Lobbies} expired lobbies and {Sessions} orphaned sessions", + _pendingLobbies, _pendingSessions + ); - logger.LogInformation("Lobby cleanup service stopped"); + _pendingLobbies = 0; + _pendingSessions = 0; } } diff --git a/MMS/Services/Lobby/LobbyService.cs b/MMS/Services/Lobby/LobbyService.cs index 92d0d6e..2fb7f9e 100644 --- a/MMS/Services/Lobby/LobbyService.cs +++ b/MMS/Services/Lobby/LobbyService.cs @@ -218,7 +218,7 @@ private bool RemoveLobby(_Lobby lobby, Action<_Lobby>? onRemoving = null) { onRemoving?.Invoke(lobby); } catch (Exception ex) { ProgramState.Logger.LogWarning( - ex, "Lobby removal callback failed for {ConnectionData}", lobby.ConnectionData + ex, "Lobby removal callback failed for {ConnectionData}", PrivacyFormatter.Format(lobby.ConnectionData) ); } finally { _tokenToConnectionData.TryRemove(lobby.HostToken, out _); diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs index f40374c..14d3bdf 100644 --- a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -205,14 +205,22 @@ public async Task FailJoinSessionAsync( /// Discovery tokens are considered stale after 2 minutes, giving them a longer /// grace period than sessions to handle timing edge cases. /// - public void CleanupExpiredSessions() { + /// The number of expired sessions removed during this call. + public int CleanupExpiredSessions() + { var now = DateTime.UtcNow; - + var removed = 0; + foreach (var joinId in _store.GetExpiredJoinIds(now)) + { CleanupJoinSession(joinId); + removed++; + } foreach (var token in _store.GetExpiredDiscoveryTokens(now.AddMinutes(-2))) _store.RemoveDiscoveryToken(token); + + return removed; } /// diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs index a6dd68d..db82414 100644 --- a/MMS/Services/Matchmaking/JoinSessionService.cs +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -73,7 +73,7 @@ public Task FailJoinSessionAsync(string joinId, string reason, CancellationToken _coordinator.FailJoinSessionAsync(joinId, reason, cancellationToken); /// Removes all expired sessions and purges stale discovery tokens. - public void CleanupExpiredSessions() => + public int CleanupExpiredSessions() => _coordinator.CleanupExpiredSessions(); /// diff --git a/MMS/Services/Network/UdpDiscoveryService.cs b/MMS/Services/Network/UdpDiscoveryService.cs index 80b699a..f0630e1 100644 --- a/MMS/Services/Network/UdpDiscoveryService.cs +++ b/MMS/Services/Network/UdpDiscoveryService.cs @@ -4,6 +4,7 @@ using System.Text; using MMS.Bootstrap; using MMS.Services.Matchmaking; +using MMS.Services.Utility; namespace MMS.Services.Network; @@ -83,7 +84,7 @@ CancellationToken cancellationToken if (buffer.Length != TokenByteLength) { _logger.LogWarning( "Received malformed discovery packet from {EndPoint} (length: {Length})", - FormatEndPoint(remoteEndPoint), + PrivacyFormatter.Format(remoteEndPoint), buffer.Length ); return; @@ -94,16 +95,12 @@ CancellationToken cancellationToken _logger.LogDebug( "Received discovery packet {TokenFingerprint} from {EndPoint}", GetTokenFingerprint(token), - FormatEndPoint(remoteEndPoint) + PrivacyFormatter.Format(remoteEndPoint) ); await _joinSessionService.SetDiscoveredPortAsync(token, remoteEndPoint.Port, cancellationToken); } - /// Formats an endpoint for logging, redacting it in non-development environments. - private static string FormatEndPoint(IPEndPoint remoteEndPoint) => - ProgramState.IsDevelopment ? remoteEndPoint.ToString() : "[Redacted]"; - /// /// Generates a short, non-reversible SHA-256 fingerprint for a session token. /// Used for correlation in debug logs without exposing the full token. diff --git a/MMS/Services/Utility/PrivacyFormatter.cs b/MMS/Services/Utility/PrivacyFormatter.cs new file mode 100644 index 0000000..61f346b --- /dev/null +++ b/MMS/Services/Utility/PrivacyFormatter.cs @@ -0,0 +1,111 @@ +using MMS.Bootstrap; +using System.Net; + +namespace MMS.Services.Utility; + +/// +/// Provides utilities for formatting network endpoints in a way that is +/// safe for logging — full detail in development, redacted in all other environments. +/// +public static class PrivacyFormatter { + /// + /// Gets a value indicating whether the application is running in the development environment. + /// + private static bool IsDevelopment => ProgramState.IsDevelopment; + + /// + /// A collection of humorous placeholder strings used as redaction substitutes. + /// + private static readonly string[] RedactedVariants = [ + "[None of your business]", + "[Nice try]", + "[A place far, far away]", + "[A galaxy far, far away]", + "[Over the rainbow]", + "[Somewhere out there]", + "[If I told you, I'd have to delete you]", + "[Ask your mom]", + "[Classified]", + "[Behind you]", + "[¯\\_(ツ)_/¯]", + "[What are you looking for?]", + "[Error 404: Address Not Found]", + "[It's a secret... to everybody]", + "[Have you tried turning it off and on again?]", + "[SomewhereOnEarth™]", + "[Undisclosed location]", + "[The void]", + "[Mind your own business.exe]", + "[This information self-destructed]", + "[The FBI knows, but they won't tell you either]", + "[Schrodinger's Endpoint: both here and not here]", + "[Currently unavailable due to something idk]", + "[My other endpoint is a Porsche]", + "[Would you like to know more? Too bad :)]", + "[Somewhere between 0.0.0.0 and 255.255.255.255]", + "[Location redacted by the order of the cats]", + "[Lost in the abyss that lies in-between the couch cushions]", + "[In a parallel universe, slightly to the left]", + "[sudo show address -> Permission denied]", + "[This endpoint does not exist. Never did. Move along.]", + "[Carrier pigeon lost en route]", + "[The address is a lie]", + "[Currently on vacation. Please try again never.]", + "[I am not the endpoint you are looking for]", + "[¿Qué? No hablo 'your concern'.]", + "[It's giving... nothing. IT'S GIVING NOTHING!]", + "[Endpoint entered witness protection]", + "[We asked it nicely to stay hidden]", + "[Loading... just kidding]", + "[no]", + "[This message will self-destruct in... oh wait, it already did]", + "[Somewhere, where the WiFi is better]", + "[Behind seven proxies]", + "[In the cloud (no, not that cloud, the fluffy kind)]", + "[Ask Clippy]", + "[Encrypted with vibes]", + "[This field intentionally left blank - lol no it isn't]", + "[IP? More like... never mind.]", + "[Gone fishing 🎣]", + "[The address got up and walked away]", + "[We hid it really well this time]", + "[Not on this network. Possibly not on this planet.]", + "[git blame won't help you here]", + "[According to my lawyer, no comment]", + "[Why do you ask? 👀]", + "[Hidden in plain sight... except not plain, and not in sight]", + "[Bounced through 47 VPNs and counting...48..49]" + ]; + + /// + /// Gets a redaction placeholder string. Returns a random humorous variant with + /// 1% probability; otherwise returns [Redacted]. + /// + private static string RedactedPlaceholder => + Random.Shared.NextDouble() < 0.50 + ? RedactedVariants[Random.Shared.Next(RedactedVariants.Length)] + : "[Redacted]"; + + /// + /// Formats an for logging. + /// Returns the full address:port string in development; + /// otherwise returns [Redacted]. + /// + /// The endpoint to format. + /// A log-safe string representation of the endpoint. + public static string Format(IPEndPoint? endPoint) => + IsDevelopment + ? endPoint?.ToString() ?? "" + : RedactedPlaceholder; + + /// + /// Formats a raw endpoint string (e.g. a hostname or connection string excerpt) + /// for logging. + /// + /// A string representation of the endpoint. + /// A log-safe string representation of the endpoint. + public static string Format(string? endPoint) => + IsDevelopment + ? endPoint ?? "" + : RedactedPlaceholder; +} From 7b6200d34b39d2690b9bd4e7b1fc53e3e1f2b943 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sun, 22 Mar 2026 21:26:47 +0200 Subject: [PATCH 4/6] fix: Applied requested fixes --- MMS/Bootstrap/HttpsCertificateConfigurator.cs | 27 ++------ MMS/Bootstrap/ProgramState.cs | 4 +- MMS/Bootstrap/ServiceCollectionExtensions.cs | 6 +- MMS/Bootstrap/WebApplicationExtensions.cs | 6 +- MMS/Contracts/Requests.cs | 3 +- MMS/Contracts/Responses.cs | 3 +- .../EndpointRouteBuilderExtensions.cs | 8 +-- .../LobbyEndpointHandlers.cs | 27 ++++++-- .../{Lobby => Lobbies}/LobbyEndpoints.cs | 9 ++- .../MatchmakingVersionValidation.cs | 5 +- MMS/Features/WebSockets/WebSocketEndpoints.cs | 6 +- MMS/Http/EndpointBuilder.cs | 33 +++------ MMS/Program.cs | 20 +++++- .../{Lobby => Lobbies}/LobbyCleanupService.cs | 2 +- .../{Lobby => Lobbies}/LobbyNameService.cs | 2 +- .../{Lobby => Lobbies}/LobbyService.cs | 5 +- .../Matchmaking/JoinSessionCoordinator.cs | 19 +++-- .../Matchmaking/JoinSessionMessenger.cs | 10 +-- .../Matchmaking/JoinSessionService.cs | 6 +- MMS/Services/Matchmaking/JoinSessionStore.cs | 4 +- MMS/Services/Network/UdpDiscoveryService.cs | 2 +- MMS/Services/Network/WebSocketManager.cs | 8 +-- MMS/Services/Utility/PrivacyFormatter.cs | 69 +------------------ 23 files changed, 106 insertions(+), 178 deletions(-) rename MMS/Features/{Lobby => Lobbies}/LobbyEndpointHandlers.cs (89%) rename MMS/Features/{Lobby => Lobbies}/LobbyEndpoints.cs (91%) rename MMS/Services/{Lobby => Lobbies}/LobbyCleanupService.cs (99%) rename MMS/Services/{Lobby => Lobbies}/LobbyNameService.cs (99%) rename MMS/Services/{Lobby => Lobbies}/LobbyService.cs (99%) diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs index b285afa..35d2d44 100644 --- a/MMS/Bootstrap/HttpsCertificateConfigurator.cs +++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs @@ -17,25 +17,6 @@ internal static class HttpsCertificateConfigurator { /// private const string KeyFile = "key.pem"; - /// - /// Static logger instance for the configurator. - /// - private static readonly ILogger Logger; - - /// - /// Initializes static members of the class. - /// - static HttpsCertificateConfigurator() { - using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(o => { - o.SingleLine = true; - o.IncludeScopes = false; - o.TimestampFormat = "HH:mm:ss "; - } - ) - ); - Logger = loggerFactory.CreateLogger(nameof(HttpsCertificateConfigurator)); - } - /// /// Reads cert.pem and key.pem from the working directory and configures /// Kestrel to terminate TLS with that certificate on port 5000. @@ -71,12 +52,12 @@ private static bool TryReadPemFiles(out string pem, out string key) { pem = key = string.Empty; if (!File.Exists(CertFile)) { - Logger.LogError("Certificate file '{File}' does not exist", CertFile); + ProgramState.Logger.LogError("Certificate file '{File}' does not exist", CertFile); return false; } if (!File.Exists(KeyFile)) { - Logger.LogError("Key file '{File}' does not exist", KeyFile); + ProgramState.Logger.LogError("Key file '{File}' does not exist", KeyFile); return false; } @@ -85,7 +66,7 @@ private static bool TryReadPemFiles(out string pem, out string key) { key = File.ReadAllText(KeyFile); return true; } catch (Exception e) { - Logger.LogError(e, "Could not read '{CertFile}' or '{KeyFile}'", CertFile, KeyFile); + ProgramState.Logger.LogError(e, "Could not read '{CertFile}' or '{KeyFile}'", CertFile, KeyFile); return false; } } @@ -114,7 +95,7 @@ private static bool TryCreateCertificate(string pem, string key, out X509Certifi ); return true; } catch (CryptographicException e) { - Logger.LogError(e, "Could not create certificate from PEM files"); + ProgramState.Logger.LogError(e, "Could not create certificate from PEM files"); return false; } } diff --git a/MMS/Bootstrap/ProgramState.cs b/MMS/Bootstrap/ProgramState.cs index 7fe008a..a4a8b5d 100644 --- a/MMS/Bootstrap/ProgramState.cs +++ b/MMS/Bootstrap/ProgramState.cs @@ -10,7 +10,9 @@ internal static class ProgramState { public static bool IsDevelopment { get; internal set; } /// - /// Gets or sets the application-level logger after the host has been built. + /// Gets or sets the shared application logger. + /// Assigned by before HTTPS configuration runs and + /// later replaced with the built host logger after application startup completes. /// public static ILogger Logger { get; internal set; } = null!; diff --git a/MMS/Bootstrap/ServiceCollectionExtensions.cs b/MMS/Bootstrap/ServiceCollectionExtensions.cs index 934cc1d..a557c2d 100644 --- a/MMS/Bootstrap/ServiceCollectionExtensions.cs +++ b/MMS/Bootstrap/ServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; -using MMS.Services.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Matchmaking; using MMS.Services.Network; using static MMS.Contracts.Responses; @@ -84,11 +84,15 @@ private static void AddMmsForwardedHeaders(this IServiceCollection services, ICo ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto; + // Trust individual reverse-proxy IPs from configuration so ASP.NET Core + // accepts forwarded client IP/protocol/host headers from those addresses. foreach (var proxy in configuration.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) { if (IPAddress.TryParse(proxy, out var address)) options.KnownProxies.Add(address); } + // Trust whole proxy networks expressed as CIDR ranges. Invalid entries are + // ignored so a malformed value does not break startup. foreach (var network in configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []) { if (!TryParseNetwork(network, out var ipNetwork)) diff --git a/MMS/Bootstrap/WebApplicationExtensions.cs b/MMS/Bootstrap/WebApplicationExtensions.cs index 305cf4f..13e53b8 100644 --- a/MMS/Bootstrap/WebApplicationExtensions.cs +++ b/MMS/Bootstrap/WebApplicationExtensions.cs @@ -3,16 +3,14 @@ namespace MMS.Bootstrap; /// /// Extension methods for configuring the MMS middleware pipeline. /// -internal static class WebApplicationExtensions -{ +internal static class WebApplicationExtensions { /// /// Applies the MMS middleware pipeline and binds the listener URL. /// /// The web application to configure. /// Whether the app is running in development. /// The same web application for chaining. - public static void UseMmsPipeline(this WebApplication app, bool isDevelopment) - { + public static void UseMmsPipeline(this WebApplication app, bool isDevelopment) { if (isDevelopment) app.UseHttpLogging(); else diff --git a/MMS/Contracts/Requests.cs b/MMS/Contracts/Requests.cs index 45bddb1..c731bff 100644 --- a/MMS/Contracts/Requests.cs +++ b/MMS/Contracts/Requests.cs @@ -5,8 +5,7 @@ namespace MMS.Contracts; /// /// Request DTOs accepted by the MMS HTTP API. /// -internal static class Requests -{ +internal static class Requests { /// /// Request payload for lobby creation. /// diff --git a/MMS/Contracts/Responses.cs b/MMS/Contracts/Responses.cs index ef61911..730ddc5 100644 --- a/MMS/Contracts/Responses.cs +++ b/MMS/Contracts/Responses.cs @@ -5,8 +5,7 @@ namespace MMS.Contracts; /// /// Response DTOs returned by the MMS HTTP API. /// -internal static class Responses -{ +internal static class Responses { /// /// Response payload returned by the health check endpoint. /// diff --git a/MMS/Features/EndpointRouteBuilderExtensions.cs b/MMS/Features/EndpointRouteBuilderExtensions.cs index e7dfd18..579fd0d 100644 --- a/MMS/Features/EndpointRouteBuilderExtensions.cs +++ b/MMS/Features/EndpointRouteBuilderExtensions.cs @@ -1,5 +1,5 @@ using MMS.Features.Health; -using MMS.Features.Lobby; +using MMS.Features.Lobbies; using MMS.Features.WebSockets; namespace MMS.Features; @@ -7,14 +7,12 @@ namespace MMS.Features; /// /// Composes all MMS endpoint groups onto the web application. /// -internal static class EndpointRouteBuilderExtensions -{ +internal static class EndpointRouteBuilderExtensions { /// /// Maps all HTTP and WebSocket endpoints exposed by MMS. /// /// The web application to map endpoints onto. - public static void MapMmsEndpoints(this WebApplication app) - { + public static void MapMmsEndpoints(this WebApplication app) { var lobby = app.MapGroup("/lobby"); var webSockets = app.MapGroup("/ws"); var joinWebSockets = webSockets.MapGroup("/join"); diff --git a/MMS/Features/Lobby/LobbyEndpointHandlers.cs b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs similarity index 89% rename from MMS/Features/Lobby/LobbyEndpointHandlers.cs rename to MMS/Features/Lobbies/LobbyEndpointHandlers.cs index 51de986..2cf73ae 100644 --- a/MMS/Features/Lobby/LobbyEndpointHandlers.cs +++ b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs @@ -3,14 +3,15 @@ using MMS.Bootstrap; using MMS.Features.Matchmaking; using MMS.Models; -using MMS.Services.Lobby; +using MMS.Models.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Matchmaking; using MMS.Services.Utility; using static MMS.Contracts.Requests; using static MMS.Contracts.Responses; -using _Lobby = MMS.Models.Lobby.Lobby; -namespace MMS.Features.Lobby; +// ReSharper disable once CheckNamespace +namespace MMS.Features.Lobbies; /// /// Contains handler and validation logic for lobby-oriented MMS endpoints. @@ -19,7 +20,7 @@ internal static partial class LobbyEndpoints { /// /// Returns all lobbies, optionally filtered by type. /// - private static IResult GetLobbies(LobbyService lobbyService, string? type = null) { + private static Ok> GetLobbies(LobbyService lobbyService, string? type = null) { var lobbies = lobbyService.GetLobbies(type) .Select(l => new LobbyResponse( l.AdvertisedConnectionData, @@ -42,7 +43,7 @@ HttpContext context ) { var lobbyType = request.LobbyType ?? "matchmaking"; - if (!string.Equals(lobbyType, "steam", StringComparison.OrdinalIgnoreCase) && + if (string.Equals(lobbyType, "matchmaking", StringComparison.OrdinalIgnoreCase) && !MatchmakingVersionValidation.Validate(request.MatchmakingVersion)) return MatchmakingOutdatedResult(); @@ -184,6 +185,20 @@ HttpContext context /// /// Resolves the connectionData string for a lobby being created. /// + /// The create-lobby request. + /// The resolved lobby type. + /// The current HTTP context. + /// + /// When this method returns , contains the resolved connection data string. + /// Otherwise, . + /// + /// + /// When this method returns , contains the validation error result to return to the caller. + /// Otherwise, . + /// + /// + /// when connection data was resolved successfully; otherwise . + /// private static bool TryResolveConnectionData( CreateLobbyRequest request, string lobbyType, @@ -249,7 +264,7 @@ out IResult? error /// /// Returns the host LAN address when the joining client shares the host's WAN IP. /// - private static string? TryResolveLanConnectionData(_Lobby lobby, string clientIp) { + private static string? TryResolveLanConnectionData(Lobby lobby, string clientIp) { if (string.IsNullOrEmpty(lobby.HostLanIp)) return null; diff --git a/MMS/Features/Lobby/LobbyEndpoints.cs b/MMS/Features/Lobbies/LobbyEndpoints.cs similarity index 91% rename from MMS/Features/Lobby/LobbyEndpoints.cs rename to MMS/Features/Lobbies/LobbyEndpoints.cs index bdfc779..bbff42a 100644 --- a/MMS/Features/Lobby/LobbyEndpoints.cs +++ b/MMS/Features/Lobbies/LobbyEndpoints.cs @@ -1,19 +1,18 @@ using MMS.Http; -namespace MMS.Features.Lobby; +// ReSharper disable once CheckNamespace +namespace MMS.Features.Lobbies; /// /// Maps lobby-oriented MMS HTTP endpoints. /// -internal static partial class LobbyEndpoints -{ +internal static partial class LobbyEndpoints { /// /// Maps lobby management and matchmaking HTTP endpoints. /// /// The web application to map non-lobby-root endpoints onto. /// The grouped route builder for /lobby routes. - public static void MapLobbyEndpoints(this WebApplication app, RouteGroupBuilder lobby) - { + public static void MapLobbyEndpoints(this WebApplication app, RouteGroupBuilder lobby) { app.Endpoint() .Get("/lobbies") .Handler(GetLobbies) diff --git a/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs index 7580791..0670594 100644 --- a/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs +++ b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs @@ -5,8 +5,7 @@ namespace MMS.Features.Matchmaking; /// /// Shared matchmaking protocol version validation helpers. /// -internal static class MatchmakingVersionValidation -{ +internal static class MatchmakingVersionValidation { /// /// Returns if matches the current protocol version. /// @@ -21,5 +20,5 @@ public static bool Validate(int? matchmakingVersion) => /// if the version is present and matches the current protocol. public static bool TryValidate(string? matchmakingVersion) => int.TryParse(matchmakingVersion, out var parsedVersion) && - parsedVersion == MatchmakingProtocol.CurrentVersion; + Validate(parsedVersion); } diff --git a/MMS/Features/WebSockets/WebSocketEndpoints.cs b/MMS/Features/WebSockets/WebSocketEndpoints.cs index 1e8b229..12ebee6 100644 --- a/MMS/Features/WebSockets/WebSocketEndpoints.cs +++ b/MMS/Features/WebSockets/WebSocketEndpoints.cs @@ -5,7 +5,7 @@ using MMS.Http; using MMS.Models; using MMS.Models.Matchmaking; -using MMS.Services.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Matchmaking; using MMS.Services.Utility; using static MMS.Contracts.Responses; @@ -194,7 +194,7 @@ await webSocket.CloseAsync( /// /// Validates the matchmakingVersion query parameter. - /// Writes a 426 Upgrade Required response if validation fails. + /// Writes a 400 Bad Request response if validation fails. /// /// The current HTTP context. /// @@ -204,7 +204,7 @@ private static bool ValidateMatchmakingVersion(HttpContext context) { if (MatchmakingVersionValidation.TryValidate(context.Request.Query["matchmakingVersion"])) return true; - context.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response .WriteAsJsonAsync( new ErrorResponse( diff --git a/MMS/Http/EndpointBuilder.cs b/MMS/Http/EndpointBuilder.cs index 73c93cb..050b0ad 100644 --- a/MMS/Http/EndpointBuilder.cs +++ b/MMS/Http/EndpointBuilder.cs @@ -3,8 +3,7 @@ namespace MMS.Http; /// /// Fluent builder for registering minimal API endpoints with a compact, readable syntax. /// -public sealed class EndpointBuilder(IEndpointRouteBuilder routes) -{ +public sealed class EndpointBuilder(IEndpointRouteBuilder routes) { private string _method = "GET"; private string _route = "/"; private Delegate? _handler; @@ -16,8 +15,7 @@ public sealed class EndpointBuilder(IEndpointRouteBuilder routes) /// /// The route pattern to map. /// The same builder for chaining. - public EndpointBuilder Get(string route) - { + public EndpointBuilder Get(string route) { _method = "GET"; _route = route; return this; @@ -28,8 +26,7 @@ public EndpointBuilder Get(string route) /// /// The route pattern to map. /// The same builder for chaining. - public EndpointBuilder Post(string route) - { + public EndpointBuilder Post(string route) { _method = "POST"; _route = route; return this; @@ -40,8 +37,7 @@ public EndpointBuilder Post(string route) /// /// The route pattern to map. /// The same builder for chaining. - public EndpointBuilder Delete(string route) - { + public EndpointBuilder Delete(string route) { _method = "DELETE"; _route = route; return this; @@ -52,8 +48,7 @@ public EndpointBuilder Delete(string route) /// /// The route pattern to map. /// The same builder for chaining. - public EndpointBuilder Map(string route) - { + public EndpointBuilder Map(string route) { _method = "MAP"; _route = route; return this; @@ -64,8 +59,7 @@ public EndpointBuilder Map(string route) /// /// The delegate to invoke when the endpoint matches. /// The same builder for chaining. - public EndpointBuilder Handler(Delegate handler) - { + public EndpointBuilder Handler(Delegate handler) { _handler = handler; return this; } @@ -75,8 +69,7 @@ public EndpointBuilder Handler(Delegate handler) /// /// The endpoint name. /// The same builder for chaining. - public EndpointBuilder WithName(string name) - { + public EndpointBuilder WithName(string name) { _name = name; return this; } @@ -86,8 +79,7 @@ public EndpointBuilder WithName(string name) /// /// The name of the rate-limiting policy. /// The same builder for chaining. - public EndpointBuilder RequireRateLimiting(string policyName) - { + public EndpointBuilder RequireRateLimiting(string policyName) { _rateLimitingPolicy = policyName; return this; } @@ -96,12 +88,10 @@ public EndpointBuilder RequireRateLimiting(string policyName) /// Builds and registers the configured endpoint. /// /// The same route builder that created this endpoint. - public void Build() - { + public void Build() { ArgumentNullException.ThrowIfNull(_handler); - var endpoint = _method switch - { + var endpoint = _method switch { "GET" => routes.MapGet(_route, _handler), "POST" => routes.MapPost(_route, _handler), "DELETE" => routes.MapDelete(_route, _handler), @@ -120,8 +110,7 @@ public void Build() /// /// Extension methods for starting fluent endpoint registrations. /// -internal static class FluentEndpointBuilderExtensions -{ +internal static class FluentEndpointBuilderExtensions { /// /// Starts building an endpoint on a web application. /// diff --git a/MMS/Program.cs b/MMS/Program.cs index aaa7613..feb45ba 100644 --- a/MMS/Program.cs +++ b/MMS/Program.cs @@ -15,16 +15,16 @@ public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); var isDevelopment = builder.Environment.IsDevelopment(); + using var startupLoggerFactory = CreateStartupLoggerFactory(); ProgramState.IsDevelopment = isDevelopment; + ProgramState.Logger = startupLoggerFactory.CreateLogger(nameof(Program)); builder.Services.AddMmsCoreServices(); builder.Services.AddMmsInfrastructure(builder.Configuration, isDevelopment); if (!builder.TryConfigureMmsHttps(isDevelopment)) { - using var loggerFactory = LoggerFactory.Create(logging => logging.AddSimpleConsole()); - loggerFactory.CreateLogger(nameof(Program)) - .LogCritical("MMS HTTPS configuration failed, exiting"); + ProgramState.Logger.LogCritical("MMS HTTPS configuration failed, exiting"); return; } @@ -35,4 +35,18 @@ public static void Main(string[] args) { app.MapMmsEndpoints(); app.Run(); } + + /// + /// Creates the temporary logger factory used before the ASP.NET Core host logger is available. + /// + /// A simple console logger factory for early startup diagnostics. + private static ILoggerFactory CreateStartupLoggerFactory() { + return LoggerFactory.Create(logging => logging.AddSimpleConsole(options => { + options.SingleLine = true; + options.IncludeScopes = false; + options.TimestampFormat = "HH:mm:ss "; + } + ) + ); + } } diff --git a/MMS/Services/Lobby/LobbyCleanupService.cs b/MMS/Services/Lobbies/LobbyCleanupService.cs similarity index 99% rename from MMS/Services/Lobby/LobbyCleanupService.cs rename to MMS/Services/Lobbies/LobbyCleanupService.cs index 67d3216..0476816 100644 --- a/MMS/Services/Lobby/LobbyCleanupService.cs +++ b/MMS/Services/Lobbies/LobbyCleanupService.cs @@ -1,4 +1,4 @@ -namespace MMS.Services.Lobby; +namespace MMS.Services.Lobbies; using Matchmaking; diff --git a/MMS/Services/Lobby/LobbyNameService.cs b/MMS/Services/Lobbies/LobbyNameService.cs similarity index 99% rename from MMS/Services/Lobby/LobbyNameService.cs rename to MMS/Services/Lobbies/LobbyNameService.cs index aad4649..adc26ed 100644 --- a/MMS/Services/Lobby/LobbyNameService.cs +++ b/MMS/Services/Lobbies/LobbyNameService.cs @@ -4,7 +4,7 @@ using System.Runtime.Serialization; using System.Text.Json; -namespace MMS.Services.Lobby; +namespace MMS.Services.Lobbies; /// /// Lobby name providing service that randomly generates lobby names from words in an embedded JSON. diff --git a/MMS/Services/Lobby/LobbyService.cs b/MMS/Services/Lobbies/LobbyService.cs similarity index 99% rename from MMS/Services/Lobby/LobbyService.cs rename to MMS/Services/Lobbies/LobbyService.cs index 2fb7f9e..64c2196 100644 --- a/MMS/Services/Lobby/LobbyService.cs +++ b/MMS/Services/Lobbies/LobbyService.cs @@ -1,10 +1,11 @@ using System.Collections.Concurrent; using MMS.Bootstrap; +using MMS.Models.Lobby; using MMS.Services.Matchmaking; using MMS.Services.Utility; using _Lobby = MMS.Models.Lobby.Lobby; -namespace MMS.Services.Lobby; +namespace MMS.Services.Lobbies; /// /// Thread-safe in-memory lobby store. @@ -264,5 +265,5 @@ private static bool IsMatchmakingLobbyType(string lobbyType) => /// Returns if is a matchmaking lobby. private static bool IsMatchmakingLobby(_Lobby lobby) => - lobby.LobbyType.Equals("matchmaking", StringComparison.OrdinalIgnoreCase); + IsMatchmakingLobbyType(lobby.LobbyType); } diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs index 14d3bdf..7c52684 100644 --- a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -1,9 +1,9 @@ using System.Net.WebSockets; using MMS.Models; +using MMS.Models.Lobby; using MMS.Models.Matchmaking; -using MMS.Services.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Utility; -using _Lobby = MMS.Models.Lobby.Lobby; namespace MMS.Services.Matchmaking; @@ -49,6 +49,7 @@ ILogger logger _lobbyService = lobbyService; _logger = logger; } + /// /// Allocates a new join session for a client attempting to connect to . /// @@ -57,7 +58,7 @@ ILogger logger /// /// The new , or for Steam lobbies /// - public JoinSession? CreateJoinSession(_Lobby lobby, string clientIp) { + public JoinSession? CreateJoinSession(Lobby lobby, string clientIp) { if (lobby.LobbyType.Equals("steam", StringComparison.OrdinalIgnoreCase)) return null; @@ -206,13 +207,11 @@ public async Task FailJoinSessionAsync( /// grace period than sessions to handle timing edge cases. /// /// The number of expired sessions removed during this call. - public int CleanupExpiredSessions() - { + public int CleanupExpiredSessions() { var now = DateTime.UtcNow; var removed = 0; - - foreach (var joinId in _store.GetExpiredJoinIds(now)) - { + + foreach (var joinId in _store.GetExpiredJoinIds(now)) { CleanupJoinSession(joinId); removed++; } @@ -228,7 +227,7 @@ public int CleanupExpiredSessions() /// Called when a lobby is closed or evicted. /// /// The lobby being removed. - public void CleanupSessionsForLobby(_Lobby lobby) { + public void CleanupSessionsForLobby(Lobby lobby) { foreach (var joinId in _store.GetJoinIdsForLobby(lobby.ConnectionData)) CleanupJoinSession(joinId); @@ -240,7 +239,7 @@ public void CleanupSessionsForLobby(_Lobby lobby) { /// Registers the host discovery token for a lobby if it is not already tracked. /// This ensures the server can correlate the host's UDP packet with the correct lobby. /// - private void RegisterHostDiscoveryTokenIfAbsent(_Lobby lobby) { + private void RegisterHostDiscoveryTokenIfAbsent(Lobby lobby) { if (lobby.HostDiscoveryToken == null || _store.ContainsDiscoveryToken(lobby.HostDiscoveryToken)) return; diff --git a/MMS/Services/Matchmaking/JoinSessionMessenger.cs b/MMS/Services/Matchmaking/JoinSessionMessenger.cs index 66c31a3..da50ea3 100644 --- a/MMS/Services/Matchmaking/JoinSessionMessenger.cs +++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs @@ -1,8 +1,8 @@ using System.Net.WebSockets; +using MMS.Models.Lobby; using MMS.Models.Matchmaking; -using MMS.Services.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Network; -using _Lobby = MMS.Models.Lobby.Lobby; namespace MMS.Services.Matchmaking; @@ -69,7 +69,7 @@ CancellationToken cancellationToken ); /// Notifies the host that its external UDP port has been observed. - public static Task SendHostMappingReceivedAsync(_Lobby lobby, int port, CancellationToken cancellationToken) => + public static Task SendHostMappingReceivedAsync(Lobby lobby, int port, CancellationToken cancellationToken) => SendToHostAsync( lobby, new { @@ -115,7 +115,7 @@ await WebSocketMessenger.SendAsync( /// Returns if the host socket is not open. /// public static async Task SendStartPunchToHostAsync( - _Lobby lobby, + Lobby lobby, string joinId, string clientIp, int clientPort, @@ -184,7 +184,7 @@ CancellationToken cancellationToken /// Sends to the lobby's host WebSocket, if open. private static Task SendToHostAsync( - _Lobby lobby, + Lobby lobby, object payload, CancellationToken cancellationToken ) { diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs index db82414..ba94cbd 100644 --- a/MMS/Services/Matchmaking/JoinSessionService.cs +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; +using MMS.Models.Lobby; using MMS.Models.Matchmaking; -using _Lobby = MMS.Models.Lobby.Lobby; namespace MMS.Services.Matchmaking; @@ -35,7 +35,7 @@ public JoinSessionService(JoinSessionCoordinator coordinator) { /// Allocates a new join session for a client attempting to connect to . /// Returns for Steam lobbies. /// - public JoinSession? CreateJoinSession(_Lobby lobby, string clientIp) => + public JoinSession? CreateJoinSession(Lobby lobby, string clientIp) => _coordinator.CreateJoinSession(lobby, clientIp); /// Returns an active, non-expired session by its identifier, or if not found or expired. @@ -80,6 +80,6 @@ public int CleanupExpiredSessions() => /// Removes all sessions belonging to and its host discovery token. /// Called when a lobby is closed or evicted. /// - internal void CleanupSessionsForLobby(_Lobby lobby) => + internal void CleanupSessionsForLobby(Lobby lobby) => _coordinator.CleanupSessionsForLobby(lobby); } diff --git a/MMS/Services/Matchmaking/JoinSessionStore.cs b/MMS/Services/Matchmaking/JoinSessionStore.cs index 5c2fe30..90bbd12 100644 --- a/MMS/Services/Matchmaking/JoinSessionStore.cs +++ b/MMS/Services/Matchmaking/JoinSessionStore.cs @@ -21,14 +21,14 @@ public sealed class JoinSessionStore { /// /// Secondary index for efficient lookup of join sessions by lobby connection data. - /// Valus are dummy bytes to use as a set. + /// Values are dummy bytes to use as a set. /// private readonly ConcurrentDictionary> _joinIdsByLobby = new(); /// /// Secondary index for efficient lookup of expired sessions. /// - private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = new(); + private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = []; /// /// Lock for thread-safe access to . diff --git a/MMS/Services/Network/UdpDiscoveryService.cs b/MMS/Services/Network/UdpDiscoveryService.cs index f0630e1..cfdf40a 100644 --- a/MMS/Services/Network/UdpDiscoveryService.cs +++ b/MMS/Services/Network/UdpDiscoveryService.cs @@ -51,7 +51,7 @@ public UdpDiscoveryService(JoinSessionService joinSessionService, ILogger /// Binds a to and enters a receive loop - /// until is cancelled by the hosting infrastructure. + /// until is canceled by the hosting infrastructure. /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var udpClient = new UdpClient(Port); diff --git a/MMS/Services/Network/WebSocketManager.cs b/MMS/Services/Network/WebSocketManager.cs index 872bc28..281e4b2 100644 --- a/MMS/Services/Network/WebSocketManager.cs +++ b/MMS/Services/Network/WebSocketManager.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; using System.Text.Json; -using MMS.Services.Lobby; +using MMS.Services.Lobbies; using MMS.Services.Matchmaking; namespace MMS.Services.Network; @@ -10,16 +10,14 @@ namespace MMS.Services.Network; /// Shared by and so that /// both use the same serialization path without a common base class. /// -internal static class WebSocketMessenger -{ +internal static class WebSocketMessenger { /// /// Serializes to UTF-8 JSON and sends it as a single text frame. /// /// The open WebSocket to send on. /// The object to serialize. Must be JSON-serializable. /// Token used to cancel the send operation. - public static async Task SendAsync(WebSocket webSocket, object payload, CancellationToken cancellationToken) - { + public static async Task SendAsync(WebSocket webSocket, object payload, CancellationToken cancellationToken) { var bytes = JsonSerializer.SerializeToUtf8Bytes(payload); await webSocket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); } diff --git a/MMS/Services/Utility/PrivacyFormatter.cs b/MMS/Services/Utility/PrivacyFormatter.cs index 61f346b..2682806 100644 --- a/MMS/Services/Utility/PrivacyFormatter.cs +++ b/MMS/Services/Utility/PrivacyFormatter.cs @@ -13,78 +13,11 @@ public static class PrivacyFormatter { /// private static bool IsDevelopment => ProgramState.IsDevelopment; - /// - /// A collection of humorous placeholder strings used as redaction substitutes. - /// - private static readonly string[] RedactedVariants = [ - "[None of your business]", - "[Nice try]", - "[A place far, far away]", - "[A galaxy far, far away]", - "[Over the rainbow]", - "[Somewhere out there]", - "[If I told you, I'd have to delete you]", - "[Ask your mom]", - "[Classified]", - "[Behind you]", - "[¯\\_(ツ)_/¯]", - "[What are you looking for?]", - "[Error 404: Address Not Found]", - "[It's a secret... to everybody]", - "[Have you tried turning it off and on again?]", - "[SomewhereOnEarth™]", - "[Undisclosed location]", - "[The void]", - "[Mind your own business.exe]", - "[This information self-destructed]", - "[The FBI knows, but they won't tell you either]", - "[Schrodinger's Endpoint: both here and not here]", - "[Currently unavailable due to something idk]", - "[My other endpoint is a Porsche]", - "[Would you like to know more? Too bad :)]", - "[Somewhere between 0.0.0.0 and 255.255.255.255]", - "[Location redacted by the order of the cats]", - "[Lost in the abyss that lies in-between the couch cushions]", - "[In a parallel universe, slightly to the left]", - "[sudo show address -> Permission denied]", - "[This endpoint does not exist. Never did. Move along.]", - "[Carrier pigeon lost en route]", - "[The address is a lie]", - "[Currently on vacation. Please try again never.]", - "[I am not the endpoint you are looking for]", - "[¿Qué? No hablo 'your concern'.]", - "[It's giving... nothing. IT'S GIVING NOTHING!]", - "[Endpoint entered witness protection]", - "[We asked it nicely to stay hidden]", - "[Loading... just kidding]", - "[no]", - "[This message will self-destruct in... oh wait, it already did]", - "[Somewhere, where the WiFi is better]", - "[Behind seven proxies]", - "[In the cloud (no, not that cloud, the fluffy kind)]", - "[Ask Clippy]", - "[Encrypted with vibes]", - "[This field intentionally left blank - lol no it isn't]", - "[IP? More like... never mind.]", - "[Gone fishing 🎣]", - "[The address got up and walked away]", - "[We hid it really well this time]", - "[Not on this network. Possibly not on this planet.]", - "[git blame won't help you here]", - "[According to my lawyer, no comment]", - "[Why do you ask? 👀]", - "[Hidden in plain sight... except not plain, and not in sight]", - "[Bounced through 47 VPNs and counting...48..49]" - ]; - /// /// Gets a redaction placeholder string. Returns a random humorous variant with /// 1% probability; otherwise returns [Redacted]. /// - private static string RedactedPlaceholder => - Random.Shared.NextDouble() < 0.50 - ? RedactedVariants[Random.Shared.Next(RedactedVariants.Length)] - : "[Redacted]"; + private static string RedactedPlaceholder => "[Redacted]"; /// /// Formats an for logging. From 6568832eeb5993652b37d45e7a51787cdf96940e Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 4 Apr 2026 14:42:43 +0300 Subject: [PATCH 5/6] fix: Applied requested fixes --- MMS/Features/Lobbies/LobbyEndpointHandlers.cs | 16 ++-- MMS/Features/Lobbies/LobbyEndpoints.cs | 16 ++-- MMS/Services/Lobbies/LobbyCleanupService.cs | 95 ++++--------------- MMS/Services/Lobbies/LobbyService.cs | 31 +++--- 4 files changed, 50 insertions(+), 108 deletions(-) diff --git a/MMS/Features/Lobbies/LobbyEndpointHandlers.cs b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs index 2cf73ae..c32ce46 100644 --- a/MMS/Features/Lobbies/LobbyEndpointHandlers.cs +++ b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs @@ -15,12 +15,14 @@ namespace MMS.Features.Lobbies; /// /// Contains handler and validation logic for lobby-oriented MMS endpoints. +/// Pair this with , which owns route registration +/// and delegates each mapped route to the handlers in this class. /// -internal static partial class LobbyEndpoints { +internal static class LobbyEndpointHandlers { /// /// Returns all lobbies, optionally filtered by type. /// - private static Ok> GetLobbies(LobbyService lobbyService, string? type = null) { + internal static Ok> GetLobbies(LobbyService lobbyService, string? type = null) { var lobbies = lobbyService.GetLobbies(type) .Select(l => new LobbyResponse( l.AdvertisedConnectionData, @@ -35,7 +37,7 @@ private static Ok> GetLobbies(LobbyService lobbyServi /// /// Creates a new lobby (Steam or Matchmaking). /// - private static IResult CreateLobby( + internal static IResult CreateLobby( CreateLobbyRequest request, LobbyService lobbyService, LobbyNameService lobbyNameService, @@ -87,7 +89,7 @@ HttpContext context /// Retained for compatibility. The active matchmaking client flow uses the WebSocket /// rendezvous instead of polling this endpoint. /// - private static IResult VerifyDiscovery(string token, JoinSessionService joinService) { + internal static IResult VerifyDiscovery(string token, JoinSessionService joinService) { var port = joinService.GetDiscoveredPort(token); return port is null ? TypedResults.Ok(new StatusResponse("pending")) @@ -97,7 +99,7 @@ private static IResult VerifyDiscovery(string token, JoinSessionService joinServ /// /// Closes a lobby by host token. /// - private static Results> CloseLobby( + internal static Results> CloseLobby( string token, LobbyService lobbyService, JoinSessionService joinService @@ -112,7 +114,7 @@ JoinSessionService joinService /// /// Refreshes the lobby heartbeat to prevent expiration. /// - private static Results, NotFound> Heartbeat( + internal static Results, NotFound> Heartbeat( string token, HeartbeatRequest request, LobbyService lobbyService @@ -125,7 +127,7 @@ LobbyService lobbyService /// /// Registers a client join attempt, returning host connection info and rendezvous metadata. /// - private static IResult JoinLobby( + internal static IResult JoinLobby( string connectionData, JoinLobbyRequest request, LobbyService lobbyService, diff --git a/MMS/Features/Lobbies/LobbyEndpoints.cs b/MMS/Features/Lobbies/LobbyEndpoints.cs index bbff42a..fe51416 100644 --- a/MMS/Features/Lobbies/LobbyEndpoints.cs +++ b/MMS/Features/Lobbies/LobbyEndpoints.cs @@ -5,8 +5,10 @@ namespace MMS.Features.Lobbies; /// /// Maps lobby-oriented MMS HTTP endpoints. +/// Pair this with , which contains the +/// corresponding handler and validation logic for the routes defined here. /// -internal static partial class LobbyEndpoints { +internal static class LobbyEndpoints { /// /// Maps lobby management and matchmaking HTTP endpoints. /// @@ -15,39 +17,39 @@ internal static partial class LobbyEndpoints { public static void MapLobbyEndpoints(this WebApplication app, RouteGroupBuilder lobby) { app.Endpoint() .Get("/lobbies") - .Handler(GetLobbies) + .Handler(LobbyEndpointHandlers.GetLobbies) .WithName("ListLobbies") .RequireRateLimiting("search") .Build(); lobby.Endpoint() .Post("") - .Handler(CreateLobby) + .Handler(LobbyEndpointHandlers.CreateLobby) .WithName("CreateLobby") .RequireRateLimiting("create") .Build(); lobby.Endpoint() .Delete("/{token}") - .Handler(CloseLobby) + .Handler(LobbyEndpointHandlers.CloseLobby) .WithName("CloseLobby") .Build(); lobby.Endpoint() .Post("/heartbeat/{token}") - .Handler(Heartbeat) + .Handler(LobbyEndpointHandlers.Heartbeat) .WithName("Heartbeat") .Build(); lobby.Endpoint() .Post("/discovery/verify/{token}") - .Handler(VerifyDiscovery) + .Handler(LobbyEndpointHandlers.VerifyDiscovery) .WithName("VerifyDiscovery") .Build(); lobby.Endpoint() .Post("/{connectionData}/join") - .Handler(JoinLobby) + .Handler(LobbyEndpointHandlers.JoinLobby) .WithName("JoinLobby") .RequireRateLimiting("join") .Build(); diff --git a/MMS/Services/Lobbies/LobbyCleanupService.cs b/MMS/Services/Lobbies/LobbyCleanupService.cs index 0476816..c5bfadd 100644 --- a/MMS/Services/Lobbies/LobbyCleanupService.cs +++ b/MMS/Services/Lobbies/LobbyCleanupService.cs @@ -9,89 +9,28 @@ public class LobbyCleanupService( ILogger logger ) : BackgroundService { /// How often the cleanup pass runs. - private static readonly TimeSpan CleanupInterval - = TimeSpan.FromSeconds(30); - - /// Accumulated lobby removals not yet written to the log. - private int _pendingLobbies; - - /// Accumulated session removals not yet written to the log. - private int _pendingSessions; + private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(30); /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - if (!await WaitForNextCycle(stoppingToken)) + try { + await Task.Delay(CleanupInterval, stoppingToken); + + var removedLobbies = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby); + var removedSessions = joinSessionService.CleanupExpiredSessions(); + + if (removedLobbies > 0 || removedSessions > 0) { + logger.LogInformation( + "Cleanup removed {Lobbies} expired lobbies and {Sessions} orphaned sessions", removedLobbies, + removedSessions + ); + } + } catch (OperationCanceledException) { return; - - RunCleanup(); - } - } - - /// - /// Delays execution for one . - /// - /// - /// if the delay completed normally; - /// if the service is stopping and the loop should exit. - /// - private static async Task WaitForNextCycle(CancellationToken stoppingToken) { - try { - await Task.Delay(CleanupInterval, stoppingToken); - return true; - } catch (OperationCanceledException) { - return false; - } - } - - /// - /// Runs one cleanup pass, removing dead lobbies and expired sessions, - /// then delegates logging to . - /// - private void RunCleanup() { - try { - var removedLobbies - = lobbyService.CleanupDeadLobbies(joinSessionService.CleanupSessionsForLobby); - var removedSessions - = joinSessionService.CleanupExpiredSessions(); - - AccumulateAndLog(removedLobbies, removedSessions); - } catch (Exception ex) { - logger.LogError(ex, "Lobby cleanup iteration failed"); + } catch (Exception ex) { + logger.LogError(ex, "Lobby cleanup iteration failed"); + } } } - - /// - /// Accumulates removal counts across consecutive active cycles. - /// Calls once a quiet cycle (nothing removed) is observed, - /// emitting at most one log line per burst regardless of how many cycles it spans. - /// - /// Lobbies removed during this cycle. - /// Sessions removed during this cycle. - private void AccumulateAndLog(int removedLobbies, int removedSessions) { - if (removedLobbies > 0 || removedSessions > 0) { - _pendingLobbies += removedLobbies; - _pendingSessions += removedSessions; - return; - } - - FlushPendingLogIfAny(); - } - - /// - /// Writes accumulated removal counts to the log and resets the pending totals. - /// Does nothing if there is nothing to report. - /// - private void FlushPendingLogIfAny() { - if (_pendingLobbies == 0 && _pendingSessions == 0) - return; - - logger.LogInformation( - "Cleanup removed {Lobbies} expired lobbies and {Sessions} orphaned sessions", - _pendingLobbies, _pendingSessions - ); - - _pendingLobbies = 0; - _pendingSessions = 0; - } } diff --git a/MMS/Services/Lobbies/LobbyService.cs b/MMS/Services/Lobbies/LobbyService.cs index 64c2196..b40996a 100644 --- a/MMS/Services/Lobbies/LobbyService.cs +++ b/MMS/Services/Lobbies/LobbyService.cs @@ -3,7 +3,6 @@ using MMS.Models.Lobby; using MMS.Services.Matchmaking; using MMS.Services.Utility; -using _Lobby = MMS.Models.Lobby.Lobby; namespace MMS.Services.Lobbies; @@ -17,7 +16,7 @@ public class LobbyService { /// /// The backing store for all active lobbies, keyed by their unique connection data. /// - private readonly ConcurrentDictionary _lobbies = new(); + private readonly ConcurrentDictionary _lobbies = new(); /// /// Map for looking up lobbies by their host authentication token. @@ -61,7 +60,7 @@ public LobbyService(LobbyNameService lobbyNameService) { /// Optional LAN address of the host, used for same-network fast-path. /// Whether the lobby appears in the public browser. /// The newly created instance. - public _Lobby CreateLobby( + public Lobby CreateLobby( string connectionData, string lobbyName, string lobbyType = "matchmaking", @@ -86,7 +85,7 @@ public _Lobby CreateLobby( ? "" : ReserveLobbyCode(connectionData); - var lobby = new _Lobby( + var lobby = new Lobby( connectionData, hostToken, lobbyCode, @@ -108,7 +107,7 @@ public _Lobby CreateLobby( /// Expired lobbies are removed lazily on access. /// /// The connection identifier the lobby was registered under. - public _Lobby? GetLobby(string connectionData) { + public Lobby? GetLobby(string connectionData) { if (!_lobbies.TryGetValue(connectionData, out var lobby)) return null; if (!lobby.IsDead) return lobby; @@ -120,7 +119,7 @@ public _Lobby CreateLobby( /// Returns the lobby owned by , or if absent or expired. /// /// The host authentication token issued at lobby creation. - public _Lobby? GetLobbyByToken(string token) { + public Lobby? GetLobbyByToken(string token) { if (!_tokenToConnectionData.TryGetValue(token, out var connData)) return null; @@ -133,7 +132,7 @@ public _Lobby CreateLobby( /// The lookup is case-insensitive. /// /// The player-facing lobby code (e.g. ABC123). - public _Lobby? GetLobbyByCode(string code) { + public Lobby? GetLobbyByCode(string code) { var normalizedCode = code.ToUpperInvariant(); if (!_codeToConnectionData.TryGetValue(normalizedCode, out var connData)) return null; @@ -150,7 +149,7 @@ public _Lobby CreateLobby( /// Optional case-insensitive filter (e.g. "matchmaking" or "steam"). /// Pass to return all types. /// - public IEnumerable<_Lobby> GetLobbies(string? lobbyType = null) { + public IEnumerable GetLobbies(string? lobbyType = null) { var active = _lobbies.Values.Where(l => l is { IsDead: false, IsPublic: true }); return string.IsNullOrEmpty(lobbyType) ? active @@ -163,7 +162,7 @@ public IEnumerable<_Lobby> GetLobbies(string? lobbyType = null) { /// /// /// When drops to zero on a matchmaking lobby, - /// is cleared so that stale NAT mappings are not + /// is cleared so that stale NAT mappings are not /// reused for subsequent joins. /// /// The host authentication token. @@ -190,17 +189,17 @@ public bool Heartbeat(string token, int connectedPlayers) { /// The host authentication token issued at lobby creation. /// Optional callback invoked with the lobby instance before it is removed from all indexes. /// if the lobby was found and removed; otherwise. - public bool RemoveLobbyByToken(string token, Action<_Lobby>? onRemoving = null) { + public bool RemoveLobbyByToken(string token, Action? onRemoving = null) { var lobby = GetLobbyByToken(token); return lobby != null && RemoveLobby(lobby, onRemoving); } /// - /// Removes all lobbies whose flag is set. + /// Removes all lobbies whose flag is set. /// /// Optional callback invoked with each lobby instance before it is removed. /// The number of lobbies removed. - public int CleanupDeadLobbies(Action<_Lobby>? onRemoving = null) { + public int CleanupDeadLobbies(Action? onRemoving = null) { var dead = _lobbies.Values.Where(l => l.IsDead).ToList(); return dead.Count(lobby => RemoveLobby(lobby, onRemoving)); } @@ -211,8 +210,8 @@ public int CleanupDeadLobbies(Action<_Lobby>? onRemoving = null) { /// The specific lobby instance to remove. /// Optional callback invoked with the lobby instance before it is removed from all indexes. /// if the lobby was not found (already removed). - private bool RemoveLobby(_Lobby lobby, Action<_Lobby>? onRemoving = null) { - if (!_lobbies.TryRemove(new KeyValuePair(lobby.ConnectionData, lobby))) + private bool RemoveLobby(Lobby lobby, Action? onRemoving = null) { + if (!_lobbies.TryRemove(new KeyValuePair(lobby.ConnectionData, lobby))) return false; try { @@ -253,7 +252,7 @@ private string ReserveLobbyCode(string connectionData) { /// and lobby code if one was assigned. /// /// The lobby whose indexes should be removed. - private void RemoveLobbyIndexes(_Lobby lobby) { + private void RemoveLobbyIndexes(Lobby lobby) { _tokenToConnectionData.TryRemove(lobby.HostToken, out _); if (!string.IsNullOrEmpty(lobby.LobbyCode)) _codeToConnectionData.TryRemove(lobby.LobbyCode, out _); @@ -264,6 +263,6 @@ private static bool IsMatchmakingLobbyType(string lobbyType) => lobbyType.Equals("matchmaking", StringComparison.OrdinalIgnoreCase); /// Returns if is a matchmaking lobby. - private static bool IsMatchmakingLobby(_Lobby lobby) => + private static bool IsMatchmakingLobby(Lobby lobby) => IsMatchmakingLobbyType(lobby.LobbyType); } From 1e4a2e68990d4758d03fccc3239bc86eec126b9b Mon Sep 17 00:00:00 2001 From: Extremelyd1 <10898310+Extremelyd1@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:13:20 +0200 Subject: [PATCH 6/6] Add explanation of certificate re-import, refactor namespace to prevent name conflicts --- MMS/Bootstrap/HttpsCertificateConfigurator.cs | 6 ++++-- MMS/Features/Lobbies/LobbyEndpointHandlers.cs | 2 +- MMS/Models/{Lobby => Lobbies}/Lobby.cs | 2 +- MMS/Services/Lobbies/LobbyService.cs | 2 +- MMS/Services/Matchmaking/JoinSessionCoordinator.cs | 2 +- MMS/Services/Matchmaking/JoinSessionMessenger.cs | 2 +- MMS/Services/Matchmaking/JoinSessionService.cs | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) rename MMS/Models/{Lobby => Lobbies}/Lobby.cs (98%) diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs index 35d2d44..718ee1a 100644 --- a/MMS/Bootstrap/HttpsCertificateConfigurator.cs +++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs @@ -72,8 +72,10 @@ private static bool TryReadPemFiles(out string pem, out string key) { } /// - /// Attempts to construct an from PEM-encoded certificate - /// and key material. + /// Attempts to construct an from PEM-encoded certificate and key material. + /// We export and re-import to force the private key into Windows-backed key storage (if using Windows). On Windows + /// PEM-created certificates can have the ephemeral private key that works in code but is unreliable for TLS. + /// Re-importing as `PKCS#12` with key storage flags transforms the key in a form Kestrel can use without issues. /// /// The PEM-encoded certificate. /// The PEM-encoded private key. diff --git a/MMS/Features/Lobbies/LobbyEndpointHandlers.cs b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs index c32ce46..e90d1b4 100644 --- a/MMS/Features/Lobbies/LobbyEndpointHandlers.cs +++ b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs @@ -3,7 +3,7 @@ using MMS.Bootstrap; using MMS.Features.Matchmaking; using MMS.Models; -using MMS.Models.Lobby; +using MMS.Models.Lobbies; using MMS.Services.Lobbies; using MMS.Services.Matchmaking; using MMS.Services.Utility; diff --git a/MMS/Models/Lobby/Lobby.cs b/MMS/Models/Lobbies/Lobby.cs similarity index 98% rename from MMS/Models/Lobby/Lobby.cs rename to MMS/Models/Lobbies/Lobby.cs index 06e26e4..54b338c 100644 --- a/MMS/Models/Lobby/Lobby.cs +++ b/MMS/Models/Lobbies/Lobby.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace MMS.Models.Lobby; +namespace MMS.Models.Lobbies; /// /// Game lobby. ConnectionData serves as both identifier and connection info. diff --git a/MMS/Services/Lobbies/LobbyService.cs b/MMS/Services/Lobbies/LobbyService.cs index b40996a..0ae12e7 100644 --- a/MMS/Services/Lobbies/LobbyService.cs +++ b/MMS/Services/Lobbies/LobbyService.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; using MMS.Bootstrap; -using MMS.Models.Lobby; +using MMS.Models.Lobbies; using MMS.Services.Matchmaking; using MMS.Services.Utility; diff --git a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs index 7c52684..88d6dce 100644 --- a/MMS/Services/Matchmaking/JoinSessionCoordinator.cs +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; using MMS.Models; -using MMS.Models.Lobby; +using MMS.Models.Lobbies; using MMS.Models.Matchmaking; using MMS.Services.Lobbies; using MMS.Services.Utility; diff --git a/MMS/Services/Matchmaking/JoinSessionMessenger.cs b/MMS/Services/Matchmaking/JoinSessionMessenger.cs index da50ea3..00e87f1 100644 --- a/MMS/Services/Matchmaking/JoinSessionMessenger.cs +++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs @@ -1,5 +1,5 @@ using System.Net.WebSockets; -using MMS.Models.Lobby; +using MMS.Models.Lobbies; using MMS.Models.Matchmaking; using MMS.Services.Lobbies; using MMS.Services.Network; diff --git a/MMS/Services/Matchmaking/JoinSessionService.cs b/MMS/Services/Matchmaking/JoinSessionService.cs index ba94cbd..595742d 100644 --- a/MMS/Services/Matchmaking/JoinSessionService.cs +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -1,5 +1,5 @@ using System.Net.WebSockets; -using MMS.Models.Lobby; +using MMS.Models.Lobbies; using MMS.Models.Matchmaking; namespace MMS.Services.Matchmaking;