Full port design for WebSocket connections from Go NATS server, including HTTP upgrade handshake, custom frame parser, compression, origin checking, and cookie-based auth.
323 lines
11 KiB
Markdown
323 lines
11 KiB
Markdown
# 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
|