Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions MMS/Bootstrap/HttpsCertificateConfigurator.cs
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);
Copy link
Copy Markdown
Owner

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateFromPem already returns the right X509Certificate2. 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 as PKCS#12 with key storage flags transforms the key in a form Kestrel without potential issues. Sources: CreateFromPemFile docs, LoadPkcs12 docs, dotnet/runtime#93319.

Copy link
Copy Markdown
Owner

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.

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;
}
}
}
23 changes: 23 additions & 0 deletions MMS/Bootstrap/ProgramState.cs
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;
}
206 changes: 206 additions & 0 deletions MMS/Bootstrap/ServiceCollectionExtensions.cs
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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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, HTTPS handling and IP-based rate limiting work correctly and cannot be spoofed by direct requests.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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;
}
}
35 changes: 35 additions & 0 deletions MMS/Bootstrap/WebApplicationExtensions.cs
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);
}
Loading