using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; namespace NATS.Server.WebSocket; /// /// WebSocket frame construction, masking, and control message creation. /// Ported from golang/nats-server/server/websocket.go lines 543-726. /// public static class WsFrameWriter { /// /// Creates a complete frame header for a single-frame message (first=true, final=true). /// Returns (header bytes, mask key or null). /// 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); } /// /// Fills a pre-allocated frame header buffer. /// Returns (bytes written, mask key or null). /// public static (int written, byte[]? key) FillFrameHeader( Span 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); } /// /// XOR masks a buffer with a 4-byte key. Applies in-place. /// public static void MaskBuf(ReadOnlySpan key, Span buf) { for (int i = 0; i < buf.Length; i++) buf[i] ^= key[i & 3]; } /// /// XOR masks multiple contiguous buffers as if they were one. /// public static void MaskBufs(ReadOnlySpan key, List bufs) { int pos = 0; foreach (var buf in bufs) { for (int j = 0; j < buf.Length; j++) { buf[j] ^= key[pos & 3]; pos++; } } } /// /// Creates a close message payload: 2-byte status code + optional UTF-8 body. /// Body truncated to fit MaxControlPayloadSize with "..." suffix. /// 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; } /// /// Builds a complete control frame (header + payload, optional masking). /// public static byte[] BuildControlFrame(int opcode, ReadOnlySpan 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; } /// /// Maps a ClientClosedReason to a WebSocket close status code. /// Matches Go wsEnqueueCloseMessage in websocket.go lines 668-694. /// 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, }; }