feat(MMS): add WebSocket endpoints and restructure service layer#41
feat(MMS): add WebSocket endpoints and restructure service layer#41Liparakis wants to merge 4 commits intoExtremelyd1:mainfrom
Conversation
- Swapped to join sessions - Add WebSocket endpoint and connection management - Reorganize lobby/matchmaking services into subfolders - Update bootstrap, contracts, and endpoint routing - Remove obsolete top-level service files (moved to subfolders)
…eanup logs feat: add PrivacyFormatter utility class chore: redact sensitive data from logs in production
Extremelyd1
left a comment
There was a problem hiding this comment.
Thanks for the PR, I haven't tested it functionally yet, but have left a bunch of comments for either requested changes or questions on why some things are the way they are.
Didn't we discuss you were going to deliver the changes in 3 PRs? If so, is this only the first PR or is this just everything. Because it feels like everything is in here.
| private static bool TryCreateCertificate(string pem, string key, out X509Certificate2? certificate) { | ||
| certificate = null; | ||
| try { | ||
| using var ephemeralCertificate = X509Certificate2.CreateFromPem(pem, key); |
There was a problem hiding this comment.
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.
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.
| /// Initializes static members of the <see cref="HttpsCertificateConfigurator"/> class. | ||
| /// </summary> | ||
| static HttpsCertificateConfigurator() { | ||
| using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(o => { |
There was a problem hiding this comment.
Why don't we use the logger from ProgramState? It is not set yet when this class is used to configure the HTTPS certificate, but see the other comments about how to can be better centralized.
There was a problem hiding this comment.
but see the other comments about how this can be better centralized.
I’m not entirely sure what you mean here.
I’ve moved the small factory from HttpCert... into a helper in Program.cs. We can't use the "final" logger anyway, since it’s only assigned after the app is built.
At this point, this approach felt like the simplest and most practical fix, rather than introducing a more complex solution for something this small.
| foreach (var proxy in configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? []) { | ||
| if (IPAddress.TryParse(proxy, out var address)) | ||
| options.KnownProxies.Add(address); | ||
| } | ||
|
|
||
| foreach (var network in configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? | ||
| []) { | ||
| if (!TryParseNetwork(network, out var ipNetwork)) | ||
| continue; | ||
|
|
||
| options.KnownNetworks.Add(ipNetwork); | ||
| } |
There was a problem hiding this comment.
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.
| internal static class WebApplicationExtensions | ||
| { |
There was a problem hiding this comment.
Incorrect style formatting for brackets in this class.
MMS/Contracts/Requests.cs
Outdated
| internal static class Requests | ||
| { |
There was a problem hiding this comment.
Incorrect style formatting for brackets.
|
|
||
| /// <summary> | ||
| /// Secondary index for efficient lookup of join sessions by lobby connection data. | ||
| /// Valus are dummy bytes to use <see cref="ConcurrentDictionary{TKey, TValue}"/> as a set. |
There was a problem hiding this comment.
| /// Valus are dummy bytes to use <see cref="ConcurrentDictionary{TKey, TValue}"/> as a set. | |
| /// Values are dummy bytes to use <see cref="ConcurrentDictionary{TKey, TValue}"/> as a set. |
| /// <summary> | ||
| /// Secondary index for efficient lookup of expired sessions. | ||
| /// </summary> | ||
| private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = new(); |
There was a problem hiding this comment.
| private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = new(); | |
| private readonly SortedSet<(DateTime expiresAtUtc, string joinId)> _expiryIndex = []; |
|
|
||
| /// <summary> | ||
| /// Binds a <see cref="UdpClient"/> to <see cref="Port"/> and enters a receive loop | ||
| /// until <paramref name="stoppingToken"/> is cancelled by the hosting infrastructure. |
There was a problem hiding this comment.
| /// until <paramref name="stoppingToken"/> is cancelled by the hosting infrastructure. | |
| /// until <paramref name="stoppingToken"/> is canceled by the hosting infrastructure. |
| internal static class WebSocketMessenger | ||
| { |
There was a problem hiding this comment.
Incorrect style formatting for brackets in this file.
| /// <summary> | ||
| /// A collection of humorous placeholder strings used as redaction substitutes. | ||
| /// </summary> | ||
| private static readonly string[] RedactedVariants = [ | ||
| "[None of your business]", | ||
| "[Nice try]", | ||
| "[A place far, far away]", | ||
| "[A galaxy far, far away]", | ||
| "[Over the rainbow]", | ||
| "[Somewhere out there]", | ||
| "[If I told you, I'd have to delete you]", | ||
| "[Ask your mom]", | ||
| "[Classified]", | ||
| "[Behind you]", | ||
| "[¯\\_(ツ)_/¯]", | ||
| "[What are you looking for?]", | ||
| "[Error 404: Address Not Found]", | ||
| "[It's a secret... to everybody]", | ||
| "[Have you tried turning it off and on again?]", | ||
| "[SomewhereOnEarth™]", | ||
| "[Undisclosed location]", | ||
| "[The void]", | ||
| "[Mind your own business.exe]", | ||
| "[This information self-destructed]", | ||
| "[The FBI knows, but they won't tell you either]", | ||
| "[Schrodinger's Endpoint: both here and not here]", | ||
| "[Currently unavailable due to something idk]", | ||
| "[My other endpoint is a Porsche]", | ||
| "[Would you like to know more? Too bad :)]", | ||
| "[Somewhere between 0.0.0.0 and 255.255.255.255]", | ||
| "[Location redacted by the order of the cats]", | ||
| "[Lost in the abyss that lies in-between the couch cushions]", | ||
| "[In a parallel universe, slightly to the left]", | ||
| "[sudo show address -> Permission denied]", | ||
| "[This endpoint does not exist. Never did. Move along.]", | ||
| "[Carrier pigeon lost en route]", | ||
| "[The address is a lie]", | ||
| "[Currently on vacation. Please try again never.]", | ||
| "[I am not the endpoint you are looking for]", | ||
| "[¿Qué? No hablo 'your concern'.]", | ||
| "[It's giving... nothing. IT'S GIVING NOTHING!]", | ||
| "[Endpoint entered witness protection]", | ||
| "[We asked it nicely to stay hidden]", | ||
| "[Loading... just kidding]", | ||
| "[no]", | ||
| "[This message will self-destruct in... oh wait, it already did]", | ||
| "[Somewhere, where the WiFi is better]", | ||
| "[Behind seven proxies]", | ||
| "[In the cloud (no, not that cloud, the fluffy kind)]", | ||
| "[Ask Clippy]", | ||
| "[Encrypted with vibes]", | ||
| "[This field intentionally left blank - lol no it isn't]", | ||
| "[IP? More like... never mind.]", | ||
| "[Gone fishing 🎣]", | ||
| "[The address got up and walked away]", | ||
| "[We hid it really well this time]", | ||
| "[Not on this network. Possibly not on this planet.]", | ||
| "[git blame won't help you here]", | ||
| "[According to my lawyer, no comment]", | ||
| "[Why do you ask? 👀]", | ||
| "[Hidden in plain sight... except not plain, and not in sight]", | ||
| "[Bounced through 47 VPNs and counting...48..49]" | ||
| ]; | ||
|
|
||
| /// <summary> | ||
| /// Gets a redaction placeholder string. Returns a random humorous variant with | ||
| /// 1% probability; otherwise returns <c>[Redacted]</c>. | ||
| /// </summary> | ||
| private static string RedactedPlaceholder => | ||
| Random.Shared.NextDouble() < 0.50 | ||
| ? RedactedVariants[Random.Shared.Next(RedactedVariants.Length)] | ||
| : "[Redacted]"; |
There was a problem hiding this comment.
Nah, this only clutters up log files. Currently the probability isn't even set to 1%, but still I don't like this.
If this was anything client-facing, where users would see this I'd appreciate the effort and allowed the change, but most likely, I'm the only one going to see this.
Matchmaking changes (the important part)
The main change is introducing
JoinSession.Before, a join attempt was spread across multiple places:
There was no single place responsible for a join attempt.
Now there is.
A
JoinSessionrepresents a single join attempt and keeps everything together:Bootstrap
Pulled all the startup logic out of
Program.csand split it into focused pieces:ServiceCollectionExtensionshandles all Dependencies injection registration.WebApplicationExtensionssets up the middlewareHttpsCertificateConfiguratordeals with HTTPS and Kestrel configProgramStateholds shared runtime state that used to be scatteredProgram.csis now just wiring things together instead of doing everything itself.Features
Routing and handler logic are no longer mixed:
EndpointRouteBuilderExtensionsregisters routesHealthEndpointsexposes/healthLobbyEndpointsdefines routesLobbyEndpointHandlerscontains the actual logicVersion validation is now centralized:
MatchmakingVersionValidationNew WebSocket endpoints are:
/ws/{token}/ws/join/{joinId}Contracts
All request and response DTOs are grouped:
Requests.csResponses.csHTTP helpers
EndpointBuilderwraps commonMapGetandMapPostpatterns to reduce repetition and keep validation consistentNetwork
UdpDiscoveryServicehandles UDP discovery and feeds into join sessionsWebSocketManagertracks active connectionsLobby
Lobby responsibilities are split cleanly:
LobbyServicecontains core logicLobbyCleanupServicehandles background cleanupLobbyNameServicemanages namingNote
AI helped with extracting parts of the code and documentation, but the design and decisions were done manually.
Some comments might be a bit misleading but i am happy to correct them any.