Old single-WS BattleSession + its dispatch/pump/ClipAckArg tests are obsolete after the Task 9 handler cutover. ClipAckArg overflow + boundary coverage moved into RealParticipantTests. BattleSessionV2 renamed back to BattleSession; the V2 suffix was a placeholder during the parallel -build refactor.
133 lines
5.8 KiB
C#
133 lines
5.8 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using SVSim.BattleNode.Sessions;
|
|
using SVSim.BattleNode.Sessions.Participants;
|
|
using SVSim.BattleNode.Wire;
|
|
|
|
namespace SVSim.BattleNode.Hosting;
|
|
|
|
/// <summary>
|
|
/// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh
|
|
/// <see cref="BattleSession"/>. Singleton; no per-request state.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>The validation chain — cheapest checks first, crypto only after both params are
|
|
/// present, WS accept only after the store lookup confirms the credentials match an outstanding
|
|
/// pending battle:</para>
|
|
/// <list type="number">
|
|
/// <item>Reject non-WS requests with 400 (someone hit <c>/socket.io/</c> via plain HTTP).</item>
|
|
/// <item>Read <c>BattleId</c> and encrypted <c>viewerId</c> from request headers, falling back
|
|
/// to query string. The real client puts them on headers despite BestHTTP's
|
|
/// <c>AdditionalQueryParams</c> API name — see project README §Wire-format gotchas.</item>
|
|
/// <item>Decrypt the viewerId with <see cref="NodeCrypto.DecryptForNode"/>; reject on
|
|
/// parse/decrypt failure.</item>
|
|
/// <item>Look up the <see cref="PendingBattle"/> in the store and verify the decrypted viewer
|
|
/// matches the one the <see cref="Bridge.IMatchingBridge"/> registered.</item>
|
|
/// <item>AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct
|
|
/// <see cref="BattleSession"/>, await <see cref="BattleSession.RunAsync"/> until the WS
|
|
/// closes.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public sealed class BattleNodeWebSocketHandler
|
|
{
|
|
private readonly IBattleSessionStore _store;
|
|
private readonly ILogger<BattleNodeWebSocketHandler> _log;
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
|
|
public BattleNodeWebSocketHandler(IBattleSessionStore store, ILoggerFactory loggerFactory)
|
|
{
|
|
_store = store;
|
|
_loggerFactory = loggerFactory;
|
|
_log = loggerFactory.CreateLogger<BattleNodeWebSocketHandler>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Endpoint entry point. Sets <see cref="HttpContext.Response"/> to 400 on any validation
|
|
/// failure; otherwise upgrades to a WebSocket and awaits
|
|
/// <see cref="BattleSession.RunAsync"/> until the connection closes.
|
|
/// </summary>
|
|
public async Task HandleAsync(HttpContext ctx)
|
|
{
|
|
// Status code mapping: 400 protocol violations (not WS, missing creds);
|
|
// 401 credential validation failures (decrypt, viewer mismatch); 404 unknown
|
|
// BattleId. Log messages carry the diagnostic detail; the wire code gives the
|
|
// client class of failure.
|
|
if (!ctx.WebSockets.IsWebSocketRequest)
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
return;
|
|
}
|
|
|
|
// BestHTTP's SocketOptions.AdditionalQueryParams puts these on HTTP request HEADERS
|
|
// for the WebSocket-only transport (not on the URL query string). Real clients
|
|
// therefore send BattleId/viewerId as headers; the integration test sends them as
|
|
// query params for convenience. Check headers first, fall back to query.
|
|
var battleId = ReadCredential(ctx, "BattleId");
|
|
var encryptedViewerId = ReadCredential(ctx, "viewerId");
|
|
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
|
|
{
|
|
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
|
|
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
return;
|
|
}
|
|
|
|
long viewerId;
|
|
try
|
|
{
|
|
var plain = NodeCrypto.DecryptForNode(encryptedViewerId);
|
|
viewerId = long.Parse(plain);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogWarning(ex, "viewerId failed to decrypt (encryptedLen={Len})", encryptedViewerId.Length);
|
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return;
|
|
}
|
|
|
|
var pending = _store.TryGetPending(battleId);
|
|
if (pending is null)
|
|
{
|
|
_log.LogWarning(
|
|
"WS upgrade for unknown BattleId={Bid} (decrypted viewerId={Vid}). " +
|
|
"Bridge may not have minted this battle, or it was already consumed/expired.",
|
|
battleId, viewerId);
|
|
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
if (pending.P1.ViewerId != viewerId)
|
|
{
|
|
_log.LogWarning(
|
|
"WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={Expected}, decrypted={Got}.",
|
|
battleId, pending.P1.ViewerId, viewerId);
|
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return;
|
|
}
|
|
|
|
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
|
_store.RemovePending(battleId);
|
|
|
|
// Phase 1: only Scripted is wired; Pvp + Bot land in subsequent phases.
|
|
if (pending.Type != BattleType.Scripted)
|
|
{
|
|
_log.LogWarning(
|
|
"WS upgrade for BattleId={Bid} with unsupported type={Type} (Phase 1 only handles Scripted).",
|
|
battleId, pending.Type);
|
|
return;
|
|
}
|
|
|
|
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
|
_loggerFactory.CreateLogger<RealParticipant>());
|
|
var scriptedBot = new ScriptedBotParticipant();
|
|
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
|
_loggerFactory.CreateLogger<BattleSession>());
|
|
await session.RunAsync(ctx.RequestAborted);
|
|
}
|
|
|
|
private static string ReadCredential(HttpContext ctx, string name)
|
|
{
|
|
var header = ctx.Request.Headers[name].ToString();
|
|
if (!string.IsNullOrEmpty(header)) return header;
|
|
return ctx.Request.Query[name].ToString();
|
|
}
|
|
}
|