- CreateCloseMessage now operates on UTF-8 byte length (matching Go's len(body) behavior) instead of character length, with proper UTF-8 boundary detection during truncation - WsCompression.Compress now uses try/finally for exception-safe disposal of DeflateStream and MemoryStream
172 lines
6.2 KiB
C#
172 lines
6.2 KiB
C#
using System.Buffers.Binary;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace NATS.Server.WebSocket;
|
|
|
|
/// <summary>
|
|
/// WebSocket frame construction, masking, and control message creation.
|
|
/// Ported from golang/nats-server/server/websocket.go lines 543-726.
|
|
/// </summary>
|
|
public static class WsFrameWriter
|
|
{
|
|
/// <summary>
|
|
/// Creates a complete frame header for a single-frame message (first=true, final=true).
|
|
/// Returns (header bytes, mask key or null).
|
|
/// </summary>
|
|
public static (byte[] header, byte[]? key) CreateFrameHeader(
|
|
bool useMasking, bool compressed, int opcode, int payloadLength)
|
|
{
|
|
var fh = new byte[WsConstants.MaxFrameHeaderSize];
|
|
var (n, key) = FillFrameHeader(fh, useMasking,
|
|
first: true, final: true, compressed: compressed, opcode: opcode, payloadLength: payloadLength);
|
|
return (fh[..n], key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills a pre-allocated frame header buffer.
|
|
/// Returns (bytes written, mask key or null).
|
|
/// </summary>
|
|
public static (int written, byte[]? key) FillFrameHeader(
|
|
Span<byte> fh, bool useMasking, bool first, bool final, bool compressed, int opcode, int payloadLength)
|
|
{
|
|
byte b0 = first ? (byte)opcode : (byte)0;
|
|
if (final) b0 |= WsConstants.FinalBit;
|
|
if (compressed) b0 |= WsConstants.Rsv1Bit;
|
|
|
|
byte b1 = 0;
|
|
if (useMasking) b1 |= WsConstants.MaskBit;
|
|
|
|
int n;
|
|
switch (payloadLength)
|
|
{
|
|
case <= 125:
|
|
n = 2;
|
|
fh[0] = b0;
|
|
fh[1] = (byte)(b1 | (byte)payloadLength);
|
|
break;
|
|
case < 65536:
|
|
n = 4;
|
|
fh[0] = b0;
|
|
fh[1] = (byte)(b1 | 126);
|
|
BinaryPrimitives.WriteUInt16BigEndian(fh[2..], (ushort)payloadLength);
|
|
break;
|
|
default:
|
|
n = 10;
|
|
fh[0] = b0;
|
|
fh[1] = (byte)(b1 | 127);
|
|
BinaryPrimitives.WriteUInt64BigEndian(fh[2..], (ulong)payloadLength);
|
|
break;
|
|
}
|
|
|
|
byte[]? key = null;
|
|
if (useMasking)
|
|
{
|
|
key = new byte[4];
|
|
RandomNumberGenerator.Fill(key);
|
|
key.CopyTo(fh[n..]);
|
|
n += 4;
|
|
}
|
|
|
|
return (n, key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// XOR masks a buffer with a 4-byte key. Applies in-place.
|
|
/// </summary>
|
|
public static void MaskBuf(ReadOnlySpan<byte> key, Span<byte> buf)
|
|
{
|
|
for (int i = 0; i < buf.Length; i++)
|
|
buf[i] ^= key[i & 3];
|
|
}
|
|
|
|
/// <summary>
|
|
/// XOR masks multiple contiguous buffers as if they were one.
|
|
/// </summary>
|
|
public static void MaskBufs(ReadOnlySpan<byte> key, List<byte[]> bufs)
|
|
{
|
|
int pos = 0;
|
|
foreach (var buf in bufs)
|
|
{
|
|
for (int j = 0; j < buf.Length; j++)
|
|
{
|
|
buf[j] ^= key[pos & 3];
|
|
pos++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a close message payload: 2-byte status code + optional UTF-8 body.
|
|
/// Body truncated to fit MaxControlPayloadSize with "..." suffix.
|
|
/// </summary>
|
|
public static byte[] CreateCloseMessage(int status, string body)
|
|
{
|
|
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
|
int maxBody = WsConstants.MaxControlPayloadSize - WsConstants.CloseStatusSize;
|
|
|
|
if (bodyBytes.Length > maxBody)
|
|
{
|
|
var suffix = "..."u8;
|
|
int truncLen = maxBody - suffix.Length;
|
|
// Find a valid UTF-8 boundary by walking back from truncation point
|
|
while (truncLen > 0 && (bodyBytes[truncLen] & 0xC0) == 0x80)
|
|
truncLen--;
|
|
var buf = new byte[WsConstants.CloseStatusSize + truncLen + suffix.Length];
|
|
BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)status);
|
|
bodyBytes.AsSpan(0, truncLen).CopyTo(buf.AsSpan(WsConstants.CloseStatusSize));
|
|
suffix.CopyTo(buf.AsSpan(WsConstants.CloseStatusSize + truncLen));
|
|
return buf;
|
|
}
|
|
|
|
var result = new byte[WsConstants.CloseStatusSize + bodyBytes.Length];
|
|
BinaryPrimitives.WriteUInt16BigEndian(result, (ushort)status);
|
|
bodyBytes.CopyTo(result.AsSpan(WsConstants.CloseStatusSize));
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a complete control frame (header + payload, optional masking).
|
|
/// </summary>
|
|
public static byte[] BuildControlFrame(int opcode, ReadOnlySpan<byte> payload, bool useMasking)
|
|
{
|
|
int headerSize = 2 + (useMasking ? 4 : 0);
|
|
var frame = new byte[headerSize + payload.Length];
|
|
var span = frame.AsSpan();
|
|
var (n, key) = FillFrameHeader(span, useMasking,
|
|
first: true, final: true, compressed: false, opcode: opcode, payloadLength: payload.Length);
|
|
if (payload.Length > 0)
|
|
{
|
|
payload.CopyTo(span[n..]);
|
|
if (useMasking && key != null)
|
|
MaskBuf(key, span[n..]);
|
|
}
|
|
|
|
return frame;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a ClientClosedReason to a WebSocket close status code.
|
|
/// Matches Go wsEnqueueCloseMessage in websocket.go lines 668-694.
|
|
/// </summary>
|
|
public static int MapCloseStatus(ClientClosedReason reason) => reason switch
|
|
{
|
|
ClientClosedReason.ClientClosed => WsConstants.CloseStatusNormalClosure,
|
|
ClientClosedReason.AuthenticationTimeout or
|
|
ClientClosedReason.AuthenticationViolation or
|
|
ClientClosedReason.SlowConsumerPendingBytes or
|
|
ClientClosedReason.SlowConsumerWriteDeadline or
|
|
ClientClosedReason.MaxSubscriptionsExceeded or
|
|
ClientClosedReason.AuthenticationExpired => WsConstants.CloseStatusPolicyViolation,
|
|
ClientClosedReason.TlsHandshakeError => WsConstants.CloseStatusTlsHandshake,
|
|
ClientClosedReason.ParseError or
|
|
ClientClosedReason.ProtocolViolation => WsConstants.CloseStatusProtocolError,
|
|
ClientClosedReason.MaxPayloadExceeded => WsConstants.CloseStatusMessageTooBig,
|
|
ClientClosedReason.WriteError or
|
|
ClientClosedReason.ReadError or
|
|
ClientClosedReason.StaleConnection or
|
|
ClientClosedReason.ServerShutdown => WsConstants.CloseStatusGoingAway,
|
|
_ => WsConstants.CloseStatusInternalSrvError,
|
|
};
|
|
}
|