-
-
Notifications
You must be signed in to change notification settings - Fork 9
feat(MMS): add WebSocket endpoints and restructure service layer #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
197b3ce
fbf4cef
9e4531e
7b6200d
6568832
1e4a2e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| using System.Security.Cryptography; | ||
| using System.Security.Cryptography.X509Certificates; | ||
|
|
||
| namespace MMS.Bootstrap; | ||
|
|
||
| /// <summary> | ||
| /// Configures Kestrel HTTPS bindings from PEM certificate files in the working directory. | ||
| /// </summary> | ||
| internal static class HttpsCertificateConfigurator { | ||
| /// <summary> | ||
| /// The filename of the PEM-encoded certificate. | ||
| /// </summary> | ||
| private const string CertFile = "cert.pem"; | ||
|
|
||
| /// <summary> | ||
| /// The filename of the PEM-encoded private key. | ||
| /// </summary> | ||
| private const string KeyFile = "key.pem"; | ||
|
|
||
| /// <summary> | ||
| /// Reads <c>cert.pem</c> and <c>key.pem</c> from the working directory and configures | ||
| /// Kestrel to terminate TLS with that certificate on port 5000. | ||
| /// </summary> | ||
| /// <param name="builder">The web application builder to configure.</param> | ||
| /// <returns> | ||
| /// <see langword="true"/> if the certificate was loaded and Kestrel was configured; | ||
| /// <see langword="false"/> if either file is missing, unreadable, or malformed. | ||
| /// </returns> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Reads the PEM certificate and key files from the working directory. | ||
| /// </summary> | ||
| /// <param name="pem">The contents of <c>cert.pem</c> if successful.</param> | ||
| /// <param name="key">The contents of <c>key.pem</c> if successful.</param> | ||
| /// <returns> | ||
| /// <see langword="true"/> if both files were read successfully; otherwise <see langword="false"/>. | ||
| /// </returns> | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Attempts to construct an <see cref="X509Certificate2"/> 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. | ||
| /// </summary> | ||
| /// <param name="pem">The PEM-encoded certificate.</param> | ||
| /// <param name="key">The PEM-encoded private key.</param> | ||
| /// <param name="certificate">The resulting certificate if successful; otherwise <see langword="null"/>.</param> | ||
| /// <returns> | ||
| /// <see langword="true"/> if the certificate was created successfully otherwise <see langword="false"/>. | ||
| /// </returns> | ||
| 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; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| namespace MMS.Bootstrap; | ||
|
|
||
| /// <summary> | ||
| /// Stores runtime application state that needs to be shared across startup helpers and endpoint mappings. | ||
| /// </summary> | ||
| internal static class ProgramState { | ||
| /// <summary> | ||
| /// Gets or sets a value indicating whether the application is running in a development environment. | ||
| /// </summary> | ||
| public static bool IsDevelopment { get; internal set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the shared application logger. | ||
| /// Assigned by <see cref="Program"/> before HTTPS configuration runs and | ||
| /// later replaced with the built host logger after application startup completes. | ||
| /// </summary> | ||
| public static ILogger Logger { get; internal set; } = null!; | ||
|
|
||
| /// <summary> | ||
| /// Gets the fixed UDP port used for discovery packets. | ||
| /// </summary> | ||
| public static int DiscoveryPort => 5001; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for registering MMS services and infrastructure concerns. | ||
| /// </summary> | ||
| internal static class ServiceCollectionExtensions { | ||
| /// <summary> | ||
| /// Registers MMS application services and hosted background services. | ||
| /// </summary> | ||
| /// <param name="services">The service collection being configured.</param> | ||
| public static void AddMmsCoreServices(this IServiceCollection services) { | ||
| services.AddSingleton<LobbyNameService>(); | ||
| services.AddSingleton<LobbyService>(); | ||
| services.AddSingleton<JoinSessionStore>(); | ||
| services.AddSingleton<JoinSessionMessenger>(); | ||
| services.AddSingleton<JoinSessionCoordinator>(); | ||
| services.AddSingleton<JoinSessionService>(); | ||
| services.AddHostedService<LobbyCleanupService>(); | ||
| services.AddHostedService<UdpDiscoveryService>(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Registers logging, forwarded headers, HTTP logging, and rate limiting for MMS. | ||
| /// </summary> | ||
| /// <param name="services">The service collection being configured.</param> | ||
| /// <param name="configuration">The application configuration, used to bind infrastructure settings such as forwarded header options.</param> | ||
| /// <param name="isDevelopment">Whether the app is running in development.</param> | ||
| public static void AddMmsInfrastructure( | ||
| this IServiceCollection services, | ||
| IConfiguration configuration, | ||
| bool isDevelopment | ||
| ) { | ||
| services.AddMmsLogging(isDevelopment); | ||
| services.AddMmsForwardedHeaders(configuration); | ||
| services.AddMmsRateLimiting(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures structured console logging. | ||
| /// Enables HTTP request logging when running in development. | ||
| /// </summary> | ||
| /// <param name="services">The service collection being configured.</param> | ||
| /// <param name="isDevelopment">Whether the app is running in development.</param> | ||
| 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(_ => { }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures forwarded header processing for reverse proxy support. | ||
| /// Enables forwarding of <c>X-Forwarded-For</c>, <c>X-Forwarded-Host</c>, | ||
| /// and <c>X-Forwarded-Proto</c> headers. | ||
| /// </summary> | ||
| /// <param name="services">The service collection being configured.</param> | ||
| /// <param name="configuration"> | ||
| /// The application configuration. Reads <c>ForwardedHeaders:KnownProxies</c> as an array of IP address strings | ||
| /// and <c>ForwardedHeaders:KnownNetworks</c> as an array of CIDR notation strings to populate | ||
| /// <see cref="ForwardedHeadersOptions.KnownProxies"/> and <see cref="ForwardedHeadersOptions.KnownNetworks"/> respectively. | ||
| /// </param> | ||
| private static void AddMmsForwardedHeaders(this IServiceCollection services, IConfiguration configuration) { | ||
| services.Configure<ForwardedHeadersOptions>(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<string[]>() ?? []) { | ||
| 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<string[]>() ?? | ||
| []) { | ||
| if (!TryParseNetwork(network, out var ipNetwork)) | ||
| continue; | ||
|
|
||
| options.KnownNetworks.Add(ipNetwork); | ||
| } | ||
|
Comment on lines
+89
to
+102
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this for?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is for trusting forwarded headers only from your proxy (i assumed there is one.. perhaps NginX). Since MMS sits behind proxy, ASP.NET Core needs to know which proxy IPs/networks are allowed to supply X-Forwarded-For and related headers, so client IP detection,
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess this is an extra measure to prevent direct request spoofing, but in a proper setup (the current deployment for example), there is no possible way to make direct request to the MMS without going through the proxy. The question now is: do I need to add configuration for my proxy so that Kestrel recognises it?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes you definetely do want a config. You should create an appsettings.json ( if you don't have one already ) and add the following field. {
"ForwardedHeaders": {
// Use when you know Exact proxy IP
"KnownProxies": [ "127.0.0.1" ],
// Use when the proxy is running inside a Docker container,
// where IP addresses may vary depending on container/network configuration.
// Note: This could be far fetched thus, the code that scans the CIDR Blocks can be removed.
"KnownNetworks": ["172.18.0.0/16"]
}
} |
||
| } | ||
| ); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Attempts to parse a CIDR notation string into an <see cref="Microsoft.AspNetCore.HttpOverrides.IPNetwork"/>. | ||
| /// </summary> | ||
| /// <param name="value">The CIDR string to parse, expected in the format <c>address/prefixLength</c> (e.g. <c>192.168.1.0/24</c>).</param> | ||
| /// <param name="network"> | ||
| /// When this method returns <see langword="true"/>, contains the parsed <see cref="Microsoft.AspNetCore.HttpOverrides.IPNetwork"/>; | ||
| /// otherwise, the default value. | ||
| /// </param> | ||
| /// <returns> | ||
| /// <see langword="true"/> if <paramref name="value"/> was successfully parsed; otherwise, <see langword="false"/>. | ||
| /// </returns> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Registers IP-based fixed-window rate limiting policies for all MMS endpoints. | ||
| /// Rejected requests receive a <c>429 Too Many Requests</c> response. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Policies: | ||
| /// <list type="bullet"> | ||
| /// <item><term>create</term><description>5 requests per 30 seconds.</description></item> | ||
| /// <item><term>search</term><description>10 requests per 10 seconds.</description></item> | ||
| /// <item><term>join</term><description>5 requests per 30 seconds.</description></item> | ||
| /// </list> | ||
| /// </remarks> | ||
| /// <param name="services">The service collection being configured.</param> | ||
| 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); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Adds a named IP-keyed fixed-window rate limiter policy to the rate limiter options. | ||
| /// </summary> | ||
| /// <param name="options">The rate limiter options to configure.</param> | ||
| /// <param name="policyName">The name used to reference this policy on endpoints.</param> | ||
| /// <param name="permitLimit">Maximum number of requests allowed per window.</param> | ||
| /// <param name="windowSeconds">Duration of the rate limit window in seconds.</param> | ||
| 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 | ||
| } | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Determines the rate limit partition key for an HTTP request. | ||
| /// Uses the first IP address from the <c>X-Forwarded-For</c> header when present, | ||
| /// falling back to <see cref="ConnectionInfo.RemoteIpAddress"/>, then <see cref="ConnectionInfo.Id"/>. | ||
| /// </summary> | ||
| /// <param name="context">The current HTTP context.</param> | ||
| /// <returns>A string key identifying the client for rate limiting purposes.</returns> | ||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| namespace MMS.Bootstrap; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for configuring the MMS middleware pipeline. | ||
| /// </summary> | ||
| internal static class WebApplicationExtensions { | ||
| /// <summary> | ||
| /// Applies the MMS middleware pipeline and binds the listener URL. | ||
| /// </summary> | ||
| /// <param name="app">The web application to configure.</param> | ||
| /// <param name="isDevelopment">Whether the app is running in development.</param> | ||
| /// <returns>The same web application for chaining.</returns> | ||
| 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"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures HTTPS for MMS when not running in development. | ||
| /// </summary> | ||
| /// <param name="builder">The web application builder to configure.</param> | ||
| /// <param name="isDevelopment">Whether the app is running in development.</param> | ||
| /// <returns> | ||
| /// <see langword="true"/> when startup can continue; otherwise <see langword="false"/>. | ||
| /// </returns> | ||
| public static bool TryConfigureMmsHttps(this WebApplicationBuilder builder, bool isDevelopment) => | ||
| isDevelopment || HttpsCertificateConfigurator.TryConfigure(builder); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This already returns a
X509Certificate2, which is the type we need for the certificate. Why do we then export it and load it again through another class?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CreateFromPemalready returns the rightX509Certificate2. The reason we export and re-import is not to change the type, but to force the private key into Windows-backed key storage. On Windows PEM-created certificates can have an ephemeral private key that works in code but is unreliable for server TLS. Re-importing asPKCS#12with key storage flags transforms the key in a form Kestrel without potential issues. Sources: CreateFromPemFile docs, LoadPkcs12 docs, dotnet/runtime#93319.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great explanation, which would be nice to have in the code as well. If people are reading this in the future, they might have the same question, and won't be able to find this comment here.