diff --git a/MMS/Bootstrap/HttpsCertificateConfigurator.cs b/MMS/Bootstrap/HttpsCertificateConfigurator.cs new file mode 100644 index 0000000..718ee1a --- /dev/null +++ b/MMS/Bootstrap/HttpsCertificateConfigurator.cs @@ -0,0 +1,104 @@ +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 { + /// + /// 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"; + + /// + /// 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)) { + ProgramState.Logger.LogError("Certificate file '{File}' does not exist", CertFile); + return false; + } + + if (!File.Exists(KeyFile)) { + ProgramState.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) { + ProgramState.Logger.LogError(e, "Could not read '{CertFile}' or '{KeyFile}'", CertFile, KeyFile); + return false; + } + } + + /// + /// 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. + /// 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: null, + X509KeyStorageFlags.PersistKeySet | + X509KeyStorageFlags.MachineKeySet | + X509KeyStorageFlags.Exportable + ); + return true; + } catch (CryptographicException e) { + 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 new file mode 100644 index 0000000..a4a8b5d --- /dev/null +++ b/MMS/Bootstrap/ProgramState.cs @@ -0,0 +1,23 @@ +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 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!; + + /// + /// Gets the fixed UDP port used for discovery packets. + /// + public static int DiscoveryPort => 5001; +} diff --git a/MMS/Bootstrap/ServiceCollectionExtensions.cs b/MMS/Bootstrap/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a557c2d --- /dev/null +++ b/MMS/Bootstrap/ServiceCollectionExtensions.cs @@ -0,0 +1,206 @@ +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using MMS.Services.Lobbies; +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; + + // 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)) + 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..13e53b8 --- /dev/null +++ b/MMS/Bootstrap/WebApplicationExtensions.cs @@ -0,0 +1,35 @@ +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..c731bff --- /dev/null +++ b/MMS/Contracts/Requests.cs @@ -0,0 +1,45 @@ +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..730ddc5 --- /dev/null +++ b/MMS/Contracts/Responses.cs @@ -0,0 +1,92 @@ +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..579fd0d --- /dev/null +++ b/MMS/Features/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,24 @@ +using MMS.Features.Health; +using MMS.Features.Lobbies; +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/Lobbies/LobbyEndpointHandlers.cs b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs new file mode 100644 index 0000000..e90d1b4 --- /dev/null +++ b/MMS/Features/Lobbies/LobbyEndpointHandlers.cs @@ -0,0 +1,296 @@ +using System.Net; +using Microsoft.AspNetCore.Http.HttpResults; +using MMS.Bootstrap; +using MMS.Features.Matchmaking; +using MMS.Models; +using MMS.Models.Lobbies; +using MMS.Services.Lobbies; +using MMS.Services.Matchmaking; +using MMS.Services.Utility; +using static MMS.Contracts.Requests; +using static MMS.Contracts.Responses; + +// ReSharper disable once CheckNamespace +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 class LobbyEndpointHandlers { + /// + /// Returns all lobbies, optionally filtered by type. + /// + internal 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). + /// + internal static IResult CreateLobby( + CreateLobbyRequest request, + LobbyService lobbyService, + LobbyNameService lobbyNameService, + HttpContext context + ) { + var lobbyType = request.LobbyType ?? "matchmaking"; + + if (string.Equals(lobbyType, "matchmaking", 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", + PrivacyFormatter.Format(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. + /// + internal 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. + /// + internal 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. + /// + internal 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. + /// + internal 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] {ClientEndPoint} -> {LobbyEndPoint}", + $"{PrivacyFormatter.Format(clientIp)}:{request.ClientPort}", + PrivacyFormatter.Format(lobby.AdvertisedConnectionData) + ); + + 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. + /// + /// 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, + 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}", + PrivacyFormatter.Format(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 + ) + ); + +} diff --git a/MMS/Features/Lobbies/LobbyEndpoints.cs b/MMS/Features/Lobbies/LobbyEndpoints.cs new file mode 100644 index 0000000..fe51416 --- /dev/null +++ b/MMS/Features/Lobbies/LobbyEndpoints.cs @@ -0,0 +1,57 @@ +using MMS.Http; + +// ReSharper disable once CheckNamespace +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 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(LobbyEndpointHandlers.GetLobbies) + .WithName("ListLobbies") + .RequireRateLimiting("search") + .Build(); + + lobby.Endpoint() + .Post("") + .Handler(LobbyEndpointHandlers.CreateLobby) + .WithName("CreateLobby") + .RequireRateLimiting("create") + .Build(); + + lobby.Endpoint() + .Delete("/{token}") + .Handler(LobbyEndpointHandlers.CloseLobby) + .WithName("CloseLobby") + .Build(); + + lobby.Endpoint() + .Post("/heartbeat/{token}") + .Handler(LobbyEndpointHandlers.Heartbeat) + .WithName("Heartbeat") + .Build(); + + lobby.Endpoint() + .Post("/discovery/verify/{token}") + .Handler(LobbyEndpointHandlers.VerifyDiscovery) + .WithName("VerifyDiscovery") + .Build(); + + lobby.Endpoint() + .Post("/{connectionData}/join") + .Handler(LobbyEndpointHandlers.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..0670594 --- /dev/null +++ b/MMS/Features/Matchmaking/MatchmakingVersionValidation.cs @@ -0,0 +1,24 @@ +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) && + Validate(parsedVersion); +} diff --git a/MMS/Features/WebSockets/WebSocketEndpoints.cs b/MMS/Features/WebSockets/WebSocketEndpoints.cs new file mode 100644 index 0000000..12ebee6 --- /dev/null +++ b/MMS/Features/WebSockets/WebSocketEndpoints.cs @@ -0,0 +1,292 @@ +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.Lobbies; +using MMS.Services.Matchmaking; +using MMS.Services.Utility; +using static MMS.Contracts.Responses; + +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, PrivacyFormatter.Format(lobby.ConnectionData), context.RequestAborted); + + lobby.HostWebSocket = webSocket; + + ProgramState.Logger.LogInformation( + "[WS] Host connected for lobby {LobbyIdentifier}", + PrivacyFormatter.Format(lobby.ConnectionData) + ); + + try { + 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}", + PrivacyFormatter.Format(lobby.ConnectionData) + ); + } + } + + /// + /// 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 400 Bad Request 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.Status400BadRequest; + 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); + } + } + +} diff --git a/MMS/Http/EndpointBuilder.cs b/MMS/Http/EndpointBuilder.cs new file mode 100644 index 0000000..050b0ad --- /dev/null +++ b/MMS/Http/EndpointBuilder.cs @@ -0,0 +1,127 @@ +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/Lobbies/Lobby.cs similarity index 83% rename from MMS/Models/Lobby.cs rename to MMS/Models/Lobbies/Lobby.cs index 1093d57..54b338c 100644 --- a/MMS/Models/Lobby.cs +++ b/MMS/Models/Lobbies/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.Lobbies; /// /// 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..feb45ba 100644 --- a/MMS/Program.cs +++ b/MMS/Program.cs @@ -1,597 +1,52 @@ -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(); + using var startupLoggerFactory = CreateStartupLoggerFactory(); - IsDevelopment = builder.Environment.IsDevelopment(); + ProgramState.IsDevelopment = isDevelopment; + ProgramState.Logger = startupLoggerFactory.CreateLogger(nameof(Program)); - builder.Logging.ClearProviders(); - builder.Logging.AddSimpleConsole(options => { - options.SingleLine = true; - options.IncludeScopes = false; - options.TimestampFormat = "HH:mm:ss "; - } - ); + builder.Services.AddMmsCoreServices(); + builder.Services.AddMmsInfrastructure(builder.Configuration, isDevelopment); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - - 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 - } - ) - ); - - 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)) { + ProgramState.Logger.LogCritical("MMS HTTPS configuration failed, exiting"); + return; } - ); var app = builder.Build(); + 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. + /// Creates the temporary logger factory used before the ASP.NET Core host logger is available. /// - 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; + /// 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 "; } - - 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/Lobbies/LobbyCleanupService.cs b/MMS/Services/Lobbies/LobbyCleanupService.cs new file mode 100644 index 0000000..c5bfadd --- /dev/null +++ b/MMS/Services/Lobbies/LobbyCleanupService.cs @@ -0,0 +1,36 @@ +namespace MMS.Services.Lobbies; + +using Matchmaking; + +/// Background service that removes expired lobbies and matchmaking sessions every 30 seconds. +public class LobbyCleanupService( + LobbyService lobbyService, + JoinSessionService joinSessionService, + ILogger logger +) : BackgroundService { + /// How often the cleanup pass runs. + private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(30); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + 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; + } catch (Exception ex) { + logger.LogError(ex, "Lobby cleanup iteration failed"); + } + } + } +} diff --git a/MMS/Services/LobbyNameService.cs b/MMS/Services/Lobbies/LobbyNameService.cs similarity index 84% rename from MMS/Services/LobbyNameService.cs rename to MMS/Services/Lobbies/LobbyNameService.cs index fe9f576..adc26ed 100644 --- a/MMS/Services/LobbyNameService.cs +++ b/MMS/Services/Lobbies/LobbyNameService.cs @@ -4,7 +4,7 @@ using System.Runtime.Serialization; using System.Text.Json; -namespace MMS.Services; +namespace MMS.Services.Lobbies; /// /// Lobby name providing service that randomly generates lobby names from words in an embedded JSON. @@ -32,21 +32,24 @@ 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); 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/Lobbies/LobbyService.cs b/MMS/Services/Lobbies/LobbyService.cs new file mode 100644 index 0000000..0ae12e7 --- /dev/null +++ b/MMS/Services/Lobbies/LobbyService.cs @@ -0,0 +1,268 @@ +using System.Collections.Concurrent; +using MMS.Bootstrap; +using MMS.Models.Lobbies; +using MMS.Services.Matchmaking; +using MMS.Services.Utility; + +namespace MMS.Services.Lobbies; + +/// +/// 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 { + /// + /// 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. + /// + /// + /// 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 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? 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? 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? 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}", PrivacyFormatter.Format(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) => + IsMatchmakingLobbyType(lobby.LobbyType); +} 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..88d6dce --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionCoordinator.cs @@ -0,0 +1,415 @@ +using System.Net.WebSockets; +using MMS.Models; +using MMS.Models.Lobbies; +using MMS.Models.Matchmaking; +using MMS.Services.Lobbies; +using MMS.Services.Utility; + +namespace MMS.Services.Matchmaking; + +/// +/// Coordinates join-session lifecycle, discovery routing, and NAT punch orchestration. +/// +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 . + /// + /// 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. + /// + /// 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; + } + + /// + /// 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..00e87f1 --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionMessenger.cs @@ -0,0 +1,195 @@ +using System.Net.WebSockets; +using MMS.Models.Lobbies; +using MMS.Models.Matchmaking; +using MMS.Services.Lobbies; +using MMS.Services.Network; + +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..595742d --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionService.cs @@ -0,0 +1,85 @@ +using System.Net.WebSockets; +using MMS.Models.Lobbies; +using MMS.Models.Matchmaking; + +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 { + /// + /// 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); + + /// 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 int 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..90bbd12 --- /dev/null +++ b/MMS/Services/Matchmaking/JoinSessionStore.cs @@ -0,0 +1,173 @@ +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 { + /// + /// 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. + /// 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 = []; + + /// + /// Lock for thread-safe access to . + /// + 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(); + + /// + /// 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() + ); + lobbyJoinIds[session.JoinId] = 0; + + lock (_indexLock) { + _expiryIndex.Add((session.ExpiresAtUtc, session.JoinId)); + } + } + + /// + /// 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 _); + + lock (_indexLock) { + _expiryIndex.Remove((session.ExpiresAtUtc, session.JoinId)); + } + } + + /// + /// 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, + 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..cfdf40a --- /dev/null +++ b/MMS/Services/Network/UdpDiscoveryService.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using MMS.Bootstrap; +using MMS.Services.Matchmaking; +using MMS.Services.Utility; + +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 { + /// + /// 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; + + /// + /// Valid discovery packets must be exactly this many bytes. + /// Packets of any other length are dropped before string decoding. + /// + 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; + } + + /// + /// Binds a to and enters a receive loop + /// until is canceled 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})", + PrivacyFormatter.Format(remoteEndPoint), + buffer.Length + ); + return; + } + + var token = Encoding.UTF8.GetString(buffer); + + _logger.LogDebug( + "Received discovery packet {TokenFingerprint} from {EndPoint}", + GetTokenFingerprint(token), + PrivacyFormatter.Format(remoteEndPoint) + ); + + await _joinSessionService.SetDiscoveredPortAsync(token, remoteEndPoint.Port, cancellationToken); + } + + /// + /// 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/Network/WebSocketManager.cs b/MMS/Services/Network/WebSocketManager.cs new file mode 100644 index 0000000..281e4b2 --- /dev/null +++ b/MMS/Services/Network/WebSocketManager.cs @@ -0,0 +1,24 @@ +using System.Net.WebSockets; +using System.Text.Json; +using MMS.Services.Lobbies; +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/PrivacyFormatter.cs b/MMS/Services/Utility/PrivacyFormatter.cs new file mode 100644 index 0000000..2682806 --- /dev/null +++ b/MMS/Services/Utility/PrivacyFormatter.cs @@ -0,0 +1,44 @@ +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; + + /// + /// Gets a redaction placeholder string. Returns a random humorous variant with + /// 1% probability; otherwise returns [Redacted]. + /// + private static string RedactedPlaceholder => "[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; +} diff --git a/MMS/Services/Utility/TokenGenerator.cs b/MMS/Services/Utility/TokenGenerator.cs new file mode 100644 index 0000000..0338ab0 --- /dev/null +++ b/MMS/Services/Utility/TokenGenerator.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; + +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. + 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; + } +}