docs: add WebSocket support design document

Full port design for WebSocket connections from Go NATS server,
including HTTP upgrade handshake, custom frame parser, compression,
origin checking, and cookie-based auth.
This commit is contained in:
Joseph Doherty
2026-02-23 04:17:56 -05:00
parent f533bf0945
commit 30ae67f613

View File

@@ -0,0 +1,322 @@
# WebSocket Support Design
## Overview
Port WebSocket connection support from the Go NATS server (`golang/nats-server/server/websocket.go`, ~1,550 lines) to the .NET solution. Full feature parity: client/leaf/MQTT paths, HTTP upgrade handshake, custom frame parser with masking, permessage-deflate compression, browser compatibility, origin checking, cookie-based auth, and close frame handling.
## Approach
**Raw socket with manual HTTP upgrade** and **custom frame parser** — no ASP.NET Core WebSocket middleware, no `System.Net.WebSockets`. Direct port of Go's frame-level implementation for full control over masking negotiation, compression, fragmentation, and browser quirks.
**Self-contained WebSocket module** under `src/NATS.Server/WebSocket/` with a `WsConnection` Stream wrapper that integrates transparently with the existing `NatsClient` read/write loops.
## Go Reference
- `server/websocket.go` — Main implementation (1,550 lines)
- `server/websocket_test.go` — Tests (4,982 lines)
- `server/opts.go` lines 518-610 — `WebsocketOpts` struct
- `server/client.go` — Integration points (`c.ws` field, `wsRead`, `wsCollapsePtoNB`)
## File Structure
```
src/NATS.Server/WebSocket/
WsConstants.cs — Opcodes, frame limits, close codes, compression magic bytes
WsReadInfo.cs — Per-connection frame reader state machine
WsFrameWriter.cs — Frame construction, masking, compression, fragmentation
WsUpgrade.cs — HTTP upgrade handshake validation and 101 response
WsConnection.cs — Stream wrapper bridging WS frames <-> NatsClient read/write
WsOriginChecker.cs — Same-origin and allowed-origins validation
WsCompression.cs — permessage-deflate via DeflateStream
tests/NATS.Server.Tests/WebSocket/
WsUpgradeTests.cs
WsFrameTests.cs
WsCompressionTests.cs
WsOriginCheckerTests.cs
WsIntegrationTests.cs
```
Modified existing files:
- `NatsOptions.cs` — Add `WebSocketOptions` class
- `NatsServer.cs` — Second accept loop for WebSocket port
- `NatsClient.cs``IsWebSocket` flag, `WsInfo` metadata property
## Constants (WsConstants.cs)
Direct port of Go constants:
| Constant | Value | Purpose |
|----------|-------|---------|
| `WsTextMessage` | 1 | Text frame opcode |
| `WsBinaryMessage` | 2 | Binary frame opcode |
| `WsCloseMessage` | 8 | Close frame opcode |
| `WsPingMessage` | 9 | Ping frame opcode |
| `WsPongMessage` | 10 | Pong frame opcode |
| `WsFinalBit` | 0x80 | FIN bit in byte 0 |
| `WsRsv1Bit` | 0x40 | RSV1 (compression) in byte 0 |
| `WsMaskBit` | 0x80 | Mask bit in byte 1 |
| `WsMaxFrameHeaderSize` | 14 | Max frame header bytes |
| `WsMaxControlPayloadSize` | 125 | Max control frame payload |
| `WsFrameSizeForBrowsers` | 4096 | Browser fragmentation limit |
| `WsCompressThreshold` | 64 | Min payload size to compress |
| Close codes | 1000-1015 | RFC 6455 Section 11.7 |
| Paths | `/`, `/leafnode`, `/mqtt` | Client type routing |
## HTTP Upgrade Handshake (WsUpgrade.cs)
### Input
Raw `Stream` (TCP or TLS) after socket accept.
### Validation (RFC 6455 Section 4.2.1)
1. Parse HTTP request line — must be `GET <path> HTTP/1.1`
2. Parse headers into dictionary
3. Host header required
4. `Upgrade` header must contain `"websocket"` (case-insensitive)
5. `Connection` header must contain `"Upgrade"` (case-insensitive)
6. `Sec-WebSocket-Version` must be `"13"`
7. `Sec-WebSocket-Key` must be present
8. Path routing: `/` -> Client, `/leafnode` -> Leaf, `/mqtt` -> Mqtt
9. Origin checking via `WsOriginChecker` if configured
10. Compression: parse `Sec-WebSocket-Extensions` for `permessage-deflate`
11. No-masking: check `Nats-No-Masking` header (for leaf nodes)
12. Browser detection: `User-Agent` contains `"Mozilla/"`, Safari for `nocompfrag`
13. Cookie extraction: map configured cookie names to values
14. X-Forwarded-For: extract client IP
### Response
```
HTTP/1.1 101 Switching Protocols\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Accept: <base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))>\r\n
[Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n]
[Nats-No-Masking: true\r\n]
[Custom headers\r\n]
\r\n
```
### Result Type
```csharp
public readonly record struct WsUpgradeResult(
bool Success,
bool Compress,
bool Browser,
bool NoCompFrag,
bool MaskRead,
bool MaskWrite,
string? CookieJwt,
string? CookieUsername,
string? CookiePassword,
string? CookieToken,
string? ClientIp,
WsClientKind Kind);
public enum WsClientKind { Client, Leaf, Mqtt }
```
### Error Handling
Return standard HTTP error responses (400, 403, etc.) with body text for invalid requests.
## Frame Reading (WsReadInfo.cs)
### State Machine
```csharp
public struct WsReadInfo
{
public int Remaining; // Bytes left in current frame payload
public bool FrameStart; // Reading new frame header
public bool FirstFrame; // First frame of fragmented message
public bool FrameCompressed; // Message is compressed (RSV1)
public bool ExpectMask; // Client frames should be masked
public byte MaskKeyPos; // Position in 4-byte mask key
public byte[] MaskKey; // 4-byte XOR mask
public List<byte[]>? CompressedBuffers;
public int CompressedOffset;
}
```
### Flow
1. Parse frame header: FIN, RSV1, opcode, mask, length, mask key
2. Handle control frames in-band (PING -> PONG, CLOSE -> close response)
3. Unmask payload bytes (XOR with cycling 4-byte key, optimized for 8-byte chunks)
4. If compressed: collect payloads across frames, decompress on final frame
5. Return unframed NATS protocol bytes
### Decompression
- Append magic trailer `[0x00, 0x00, 0xff, 0xff]` before decompressing
- Use `DeflateStream` for decompression
- Validate decompressed size against `MaxPayload`
## Frame Writing (WsFrameWriter.cs)
### Per-Connection State
```csharp
public sealed class WsFrameWriter
{
private readonly bool _compress;
private readonly bool _maskWrite;
private readonly bool _browser;
private readonly bool _noCompFrag;
private DeflateStream? _compressor;
}
```
### Flow
1. If compression enabled and payload > 64 bytes: compress via `DeflateStream`
2. If browser client and payload > 4096 bytes: fragment into chunks
3. Build frame headers: FIN | RSV1 | opcode | MASK | length | mask key
4. If masking: generate random 4-byte key, XOR payload
5. Control frame helpers: `EnqueuePing`, `EnqueuePong`, `EnqueueClose`
### Close Frame
- 2-byte status code (big-endian) + optional UTF-8 reason
- Reason truncated to 125 bytes with `"..."` suffix
- Status code mapping from `ClientClosedReason`:
- ClientClosed -> 1000 (Normal)
- Auth failure -> 1008 (Policy Violation)
- Parse error -> 1002 (Protocol Error)
- Payload too big -> 1009 (Message Too Big)
- Other -> 1001 (Going Away) or 1011 (Server Error)
## WsConnection Stream Wrapper (WsConnection.cs)
Extends `Stream` to transparently wrap WebSocket framing around raw I/O:
- `ReadAsync`: Calls `WsRead` to decode frames, buffers decoded payloads, returns NATS bytes to caller
- `WriteAsync`: Wraps payload in WS frames via `WsFrameWriter`, writes to inner stream
- `EnqueueControlMessage`: Called by read loop for PING responses and close frames
NatsClient's `FillPipeAsync` and `RunWriteLoopAsync` work unchanged because `WsConnection` is a `Stream`.
## NatsServer Integration
### Second Accept Loop
```
StartAsync:
if WebSocket.Port > 0:
create _wsListener socket
bind to WebSocket.Host:WebSocket.Port
start RunWebSocketAcceptLoopAsync
```
### WebSocket Accept Flow
```
Accept socket
-> TLS negotiation (reuse TlsConnectionWrapper)
-> WsUpgrade.TryUpgradeAsync (HTTP upgrade)
-> Create WsConnection wrapping stream
-> Create NatsClient with WsConnection as stream
-> Set IsWebSocket = true, attach WsUpgradeResult
-> RunClientAsync (same as TCP from here)
```
### NatsClient Changes
- `public bool IsWebSocket { get; set; }` flag
- `public WsUpgradeResult? WsInfo { get; set; }` metadata
- No changes to read/write loops — `WsConnection` handles framing transparently
### Shutdown
- `ShutdownAsync` also closes `_wsListener`
- WebSocket clients receive close frames before TCP disconnect
- `LameDuckShutdownAsync` includes WS clients in stagger-close
## Configuration (WebSocketOptions)
```csharp
public sealed class WebSocketOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } // 0 = disabled
public string? Advertise { get; set; }
public string? NoAuthUser { get; set; }
public string? JwtCookie { get; set; }
public string? UsernameCookie { get; set; }
public string? PasswordCookie { get; set; }
public string? TokenCookie { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Token { get; set; }
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
public bool NoTls { get; set; }
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }
public bool SameOrigin { get; set; }
public List<string>? AllowedOrigins { get; set; }
public bool Compression { get; set; }
public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan? PingInterval { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}
```
### Validation Rules
- Port 0 = disabled, skip remaining validation
- TLS required unless `NoTls = true`
- AllowedOrigins must be valid URLs
- Custom headers must not use reserved names
- NoAuthUser must match existing user if specified
## Origin Checking (WsOriginChecker.cs)
1. No Origin header -> accept (per RFC for non-browser clients)
2. If `SameOrigin`: compare origin host:port with request Host header
3. If `AllowedOrigins`: check host against list, match scheme and port
Parsed from config URLs, stored as `Dictionary<string, (string scheme, int port)>`.
## Testing
### Unit Tests
**WsUpgradeTests.cs**
- Valid upgrade -> 101
- Missing/invalid headers -> error codes
- Origin checking (same-origin, allowed, blocked)
- Compression negotiation
- No-masking header
- Browser/Safari detection
- Cookie extraction
- Path routing
- Handshake timeout
- Custom/reserved headers
**WsFrameTests.cs**
- Read uncompressed frames (various sizes)
- Length encoding (7-bit, 16-bit, 64-bit)
- Masking/unmasking round-trip
- Control frames (PING, PONG, CLOSE)
- Close frame status code and reason
- Invalid frames (missing FIN on control, oversized control)
- Fragmented messages
- Compressed frame round-trip
- Browser fragmentation at 4096
- Safari no-compressed-fragmentation
**WsCompressionTests.cs**
- Compress/decompress round-trip
- Below threshold not compressed
- Large payload compression
- MaxPayload limit on decompression
**WsOriginCheckerTests.cs**
- No origin -> accepted
- Same-origin match/mismatch
- Allowed origins match/mismatch
- Scheme and port matching
### Integration Tests
**WsIntegrationTests.cs**
- Connect via raw WebSocket, CONNECT/INFO exchange
- PUB/SUB over WebSocket
- Multiple WS clients
- Mixed TCP + WS clients interoperating
- WS with compression
- Graceful close (close frame exchange)
- Server shutdown sends close frames
## Post-Implementation
- Update `differences.md` to mark WebSocket support as implemented
- Update ports file to include WebSocket port