Two issues caught in the real-client smoke: 1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and viewerId on HTTP request HEADERS for WebSocket-only transport (NOT on the URL query string as the in-battle/transport.md spec says). Real clients therefore send them as headers; our handler was reading from query and rejecting every connect with "Unknown battle/viewer pair: <bid>/<garbage>". Fix: header-first, query- fallback (so the integration test still works against TestServer). 2) The Steam auth handler was running on every WS upgrade and throwing NotSupportedException on Request.Body.Seek (Kestrel's HttpRequestStream doesn't support Seek, and a WS upgrade is GET with Content-Length: 0 anyway). It flooded logs and added no value — the battle node has its own per-connection credentials. Skip auth when IsWebSocketRequest is true. Spec correction for in-battle/transport.md to follow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
77 lines
2.9 KiB
C#
77 lines
2.9 KiB
C#
// SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using SVSim.BattleNode.Sessions;
|
|
using SVSim.BattleNode.Wire;
|
|
|
|
namespace SVSim.BattleNode.Hosting;
|
|
|
|
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>();
|
|
}
|
|
|
|
public async Task HandleAsync(HttpContext ctx)
|
|
{
|
|
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.Status400BadRequest;
|
|
return;
|
|
}
|
|
|
|
var pending = _store.TryGetPending(battleId);
|
|
if (pending is null || pending.ViewerId != viewerId)
|
|
{
|
|
_log.LogWarning("Unknown battle/viewer pair: {Bid}/{Vid}", battleId, viewerId);
|
|
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
return;
|
|
}
|
|
|
|
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
|
_store.RemovePending(battleId);
|
|
var session = new BattleSession(ws, battleId, viewerId, _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();
|
|
}
|
|
}
|