14 Commits

Author SHA1 Message Date
Joseph Doherty 28451ad263 feat(mqtt): port 9 MQTT cluster tests from Go to .NET integration tests
Add MqttClusterTests.cs with 8 cluster tests (T:2215-2224) and relocate
T:2225 from ImplBacklog stub. All tests deferred (require running cluster).
2026-03-01 19:11:43 -05:00
Joseph Doherty 8127f6e1cb feat(mqtt): implement config stubs and clean up server extensions
Convert MqttConfigAuth, MqttHandleClosedClient, MqttUpdateMaxAckPending
to no-ops and MqttDetermineReplicas to return 1 (standalone). Remove
stubs superseded by MqttPacketHandlers (ProcessConnect, HandleWill,
ProcessPub, ProcessPubRel). Remaining JetStream-dependent stubs are
documented but unchanged.
2026-03-01 16:15:20 -05:00
Joseph Doherty 710f443eda feat(mqtt): wire will message delivery on abnormal disconnect
Add DeliverWill to MqttPacketHandlers — extracts and clears the will,
then delivers via ProcessInboundClientMsg. Call from CloseConnection
when client is MQTT. Clean DISCONNECT already clears the will (Task 3),
so DeliverWill is a no-op after DISCONNECT. 3 new tests: abnormal
close delivers will, DISCONNECT suppresses will, no-will is a no-op.
2026-03-01 16:10:53 -05:00
Joseph Doherty b465d095e3 feat(mqtt): implement QoS 1 PUBACK and QoS 2 PUBREC/PUBREL/PUBCOMP handlers
QoS 1: deliver message then send PUBACK. QoS 2: store in-memory
pending PUBREL, send PUBREC; on PUBREL deliver and send PUBCOMP.
Wire all four PI-packet dispatches (PUBACK/PUBREC/PUBREL/PUBCOMP)
in parser. Add QoS2Pending dictionary to MqttHandler for in-memory
QoS 2 tracking. 6 new tests for QoS 1/2 flows including full
QoS 2 handshake.
2026-03-01 16:08:28 -05:00
Joseph Doherty 715367b9ea feat(mqtt): implement PUBLISH QoS 0, SUBSCRIBE, and UNSUBSCRIBE handlers
Add ParsePub, ParseSubsOrUnsubs, ProcessPub (QoS 0), ProcessSubs,
ProcessUnsubs, EnqueueSubAck, and EnqueueUnsubAck to MqttPacketHandlers.
Wire PUB/SUB/UNSUB dispatch cases in MqttParser. Add ReadSlice to
MqttReader for raw payload extraction. 18 new unit tests covering
parsing, flags, error cases, QoS downgrade, and full flow. 1 new
integration test verifying SUBSCRIBE handshake over TCP.
2026-03-01 16:04:37 -05:00
Joseph Doherty 95cf20b00b feat(mqtt): implement CONNECT/CONNACK/DISCONNECT packet handlers
Implement Task 3 of MQTT orchestration:
- Create MqttPacketHandlers.cs with ParseConnect(), ProcessConnect(), EnqueueConnAck(), HandleDisconnect()
- Wire CONNECT and DISCONNECT dispatch in MqttParser.cs
- Parse CONNECT: protocol name/level, flags, keep-alive, client ID, will, auth
- Send CONNACK (4-byte fixed packet with return code)
- DISCONNECT clears will message and closes connection cleanly
- Auto-generate client ID for empty ID + clean session
- Validate reserved bit, will flags, username/password consistency
- Add Reader field to MqttHandler for per-connection parsing
- 11 unit tests for CONNECT parsing and processing
- 1 end-to-end integration test: TCP → CONNECT → CONNACK over the wire
2026-03-01 15:48:22 -05:00
Joseph Doherty 2e2ffee41a feat(mqtt): add MQTT packet parser, dispatch, and ReadLoop integration
Implement Task 2 of MQTT orchestration:
- Create MqttParser.cs with loop-based packet parser and dispatch switch
- Add MqttReader field to MqttHandler for per-connection parsing state
- Wire ReadLoop to call MqttParser for MQTT connections
- Implement PINGREQ handler (enqueues PINGRESP)
- CONNECT-first enforcement (rejects non-CONNECT as first packet)
- Partial packet handling via MqttReader pending buffer
- 13 unit tests covering parser, dispatch, partial packets, and edge cases
- Stub dispatch entries for CONNECT, PUBLISH, SUB, UNSUB, DISCONNECT (NotImplementedException)
2026-03-01 15:41:45 -05:00
Joseph Doherty 6fb7f43335 feat(mqtt): add MQTT listener, client creation, and shutdown wiring
Wire up the MQTT server-side orchestration layer (Task 1 of 7):
- Create NatsServer.Mqtt.cs with StartMqttListener(), CreateMqttClient(), MqttAddr()
- Forward MqttHandler.StartMqtt() to server.StartMqttListener()
- Add _mqttListener to Shutdown() doneExpected counting
- Fix ReadyForConnections to recognize active MQTT listener
- Handle RandomPort (-1) as ephemeral for MQTT listener
- Remove duplicate Mqtt field from ClientConnection.cs (already in ClientConnection.Mqtt.cs)
- Add 2 MQTT boot integration tests (accept + shutdown lifecycle)
2026-03-01 15:35:41 -05:00
Joseph Doherty 60bb56a90c docs: add MQTT server-side orchestration design
Full protocol parity design for MQTT 3.1.1: listener, client creation,
parser/dispatch, all packet handlers (CONNECT through DISCONNECT),
QoS 0/1/2, will messages, retained messages, session persistence,
and JetStream consumer management. Bottom-up implementation strategy.
2026-03-01 15:25:01 -05:00
Joseph Doherty be1eb3392e feat(boot): add server boot integration tests and fix ValueTask sync blocking
Fix Shutdown()/LameDuckMode() deadlock caused by calling .GetAwaiter().GetResult()
on pending ValueTask from Channel.ReadAsync(). ValueTask does not support synchronous
blocking — must convert via .AsTask() first. Add two integration tests validating the
full Start() → AcceptLoop → client connection → Shutdown lifecycle.
2026-03-01 15:13:48 -05:00
Joseph Doherty bd40b36c23 feat: complete Start() with full subsystem startup sequence
Wire up Start() to call all subsystem startup methods matching
Go's Server.Start() sequence: auth warnings, rate limit log,
profiler, monitoring, resolver, GW reply map expiration,
JetStream, delayed API responder, OCSP, gateways, websocket,
leafnodes, MQTT, routing, ports file, accept loop.
2026-03-01 15:04:25 -05:00
Joseph Doherty 8973db0027 feat: add stubs and guards for Start() parity
- Add CheckAuthForWarnings() no-op stub (NatsServer.Auth.cs)
- Add StartDelayedApiResponder() no-op stub (NatsServer.JetStreamCore.cs)
- Guard StartMqtt() to warn instead of throwing NotImplementedException
2026-03-01 15:01:23 -05:00
Joseph Doherty 99399ac917 docs: add server boot parity implementation plan
5-task plan for wiring Start() to full subsystem startup sequence:
1. Add CheckAuthForWarnings() stub
2. Add StartDelayedApiResponder() stub
3. Guard MQTT StartMqtt() to not throw
4. Wire up Start() body to match Go sequence
5. Write server boot validation integration tests
2026-03-01 14:52:35 -05:00
Joseph Doherty ca8297d0ad docs: add server boot parity design
Design for wiring NatsServer.Start() to call all subsystem startup
methods matching Go's Server.Start() sequence, enabling a fully
booting server that accepts client connections.
2026-03-01 14:47:50 -05:00
22 changed files with 5346 additions and 83 deletions
@@ -0,0 +1,362 @@
# MQTT Server-Side Orchestration Design
## Goal
Implement the MQTT server-side orchestration layer — listener, client creation, protocol parsing, packet dispatch, and all 15+ extension methods — achieving full MQTT 3.1.1 protocol parity with Go's `mqtt.go` (~5,800 LOC). The data structures, binary codecs, session manager, JetStream integration, and subject converter are already ported and verified.
## Architecture
Bottom-up implementation with TDD: build from listener through parser to packet handlers to session/JetStream integration. Each layer is independently testable before the next depends on it. Mirror Go's `createMQTTClient()` pattern with a dedicated MQTT client creation path separate from the NATS client path.
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Scope | Full protocol parity | All packet types, QoS 0/1/2, will, retained, sessions |
| Client creation | Dedicated `CreateMqttClient()` | Mirrors Go exactly; MQTT clients skip INFO, set headers=true |
| Code organization | Split by concern | MqttParser.cs, MqttPacketHandlers.cs, MqttClientExtensions.cs |
| Strategy | Bottom-up with TDD | Each layer tested before building the next |
## Tech Stack
- .NET 10, C# latest
- xUnit 3, Shouldly for testing
- Raw TCP + binary MQTT packets for integration tests (no third-party MQTT library)
- Existing: MqttReader, MqttWriter, MqttConstants, MqttTypes, MqttAccountSessionManager, MqttJsa
---
## 1. Listener & Client Creation
### StartMqtt() — Replace warning stub
**File**: `Mqtt/MqttHandler.cs` (MqttServerExtensions)
1. Read `opts.Mqtt.Port` and `opts.Mqtt.Host`
2. Create `TcpListener` on MQTT port (support ephemeral port 0)
3. Store as `_mqttListener` field on `NatsServer`
4. Handle TLS if `opts.Mqtt.TlsConfig` is set
5. Launch `AcceptConnections()` goroutine with `CreateMqttClient` callback
6. Log: `"Listening for MQTT client connections on {endpoint}"`
### CreateMqttClient(TcpClient tc) — New method
**File**: New `NatsServer.Mqtt.cs` partial class, or extend `NatsServer.Listeners.cs`
1. Create `ClientConnection(ClientKind.Client, this, tc.GetStream())`
2. Initialize `c.Mqtt = new MqttHandler()` with QoS flags from `opts.Mqtt`:
- `RejectQoS2Pub` from `opts.Mqtt.RejectQoS2Pub`
- `DowngradeQoS2Sub` from `opts.Mqtt.DowngradeQoS2Sub`
3. Set `c.Headers = true` (MQTT uses NATS headers for QoS metadata)
4. Register with `GlobalAccount()`
5. **Do NOT send INFO** — MQTT clients don't use the NATS INFO line
6. Start `ReadLoop()` and `WriteLoop()` via `StartGoRoutine()`
7. Add to server's `_clients` map
8. Check max connections limit
### NatsServer fields
**File**: `NatsServer.cs`
Add alongside existing listener fields:
```csharp
private TcpListener? _mqttListener;
```
### Shutdown integration
**File**: `NatsServer.Lifecycle.cs`
Add `_mqttListener` to `doneExpected` counting in `Shutdown()`:
```csharp
if (_mqttListener != null)
{
doneExpected++;
_mqttListener.Stop();
_mqttListener = null;
}
```
Same for `LameDuckMode()`.
---
## 2. Parser & Dispatch
### MqttParse(byte[] buf) — New method on ClientConnection
**File**: New `Mqtt/MqttParser.cs`
State machine that processes raw bytes from the read loop:
1. **Fixed header**: Read packet type byte (upper 4 bits = type, lower 4 = flags)
2. **Remaining length**: Variable-length encoding (1-4 bytes, 7 bits per byte, MSB continuation)
3. **Payload**: Type-specific parsing delegated to per-packet parsers
### Dispatch switch
After extracting packet type and remaining length:
```
CONNECT (0x10) → MqttParseConnect() → MqttProcessConnect()
PUBLISH (0x30) → MqttParsePub() → MqttProcessPub()
PUBACK (0x40) → MqttParsePubAck()
PUBREC (0x50) → MqttParsePubRec()
PUBREL (0x62) → MqttProcessPubRel()
PUBCOMP (0x70) → MqttParsePubComp()
SUBSCRIBE (0x82) → MqttParseSubs() → MqttProcessSubs()
UNSUBSCRIBE(0xA2) → MqttParseUnsubs() → MqttProcessUnsubs()
PINGREQ (0xC0) → MqttEnqueuePingResp()
DISCONNECT (0xE0) → handle cleanup + close
```
### Key constraints
- CONNECT must be the first packet; any other packet before CONNECT → close connection
- Partial packets: save state in `MqttHandler.ParseState/RemLen/Buf`, resume on next buffer
- Invalid packet types → close connection
### ReadLoop integration
**File**: `ClientConnection.cs` (ReadLoop method)
Add MQTT branch:
```csharp
if (IsMqtt())
err = MqttParse(buf);
else
err = Parse(buf, handler);
```
### IsMqtt() change
**File**: `ClientConnection.cs`
Change from `return false` to:
```csharp
internal bool IsMqtt() => Mqtt != null;
```
---
## 3. Packet Handlers
### CONNECT (`MqttParseConnect` + `MqttProcessConnect`)
**File**: `Mqtt/MqttPacketHandlers.cs`
**Parse phase** — Extract fields in MQTT 3.1.1 spec order:
- Protocol name ("MQTT"), protocol level (0x04)
- Connect flags (clean session, will, will QoS, will retain, password, username)
- Keep-alive interval (2 bytes, multiply by 1.5 for read deadline)
- Client ID (auto-generate NUID if empty + clean session)
- Will topic/message (convert topic to NATS subject via `MqttTopicToNatsPubSubject()`)
- Username/password
**Process phase** (`MqttProcessConnect` on NatsServer):
1. Get or create `MqttAccountSessionManager` for client's account
2. Lock session by client ID hash (prevents concurrent session takeover)
3. If existing session → take over (disconnect old client)
4. If clean session → delete old session state
5. If !clean session → restore subscriptions and pending messages
6. Set `c.Mqtt.Session`, `c.Mqtt.AccountSessionManager`
7. Run authentication if `opts.Mqtt.Username/Password` configured
8. Send CONNACK (return code 0 = accepted)
9. Deliver retained messages for existing subscriptions
10. Set keep-alive read deadline on connection
### PUBLISH (`MqttParsePub` + `MqttProcessPub`)
**Parse**: Topic name → convert to NATS subject, packet ID (if QoS>0), payload bytes.
**Process by QoS**:
- **QoS 0**: `MqttInitiateMsgDelivery()` → done
- **QoS 1**: Deliver → send PUBACK(pi)
- **QoS 2**: `MqttStoreQoS2MsgOnce()` → send PUBREC(pi) → [client sends PUBREL] → deliver → send PUBCOMP(pi)
**MqttInitiateMsgDelivery**: Construct NATS message with `Nmqtt-Pub:<qos>` header, set `c.Pa` (publish args), call `c.ProcessInboundClientMsg()`.
**Max payload check**: Validate total NATS message size against client's max payload limit.
### SUBSCRIBE (`MqttParseSubs` + `MqttProcessSubs`)
**Parse**: Packet ID, list of (topic filter + QoS) pairs. Convert each filter to NATS subject via `MqttFilterToNatsSubject()`.
**Process per filter**:
- Create NATS subscription on converted subject
- If QoS > 0: Create JetStream durable consumer on `$MQTT.msgs.<subject>`
- Track in `MqttSession.Subs` and `MqttSession.Cons`
- Send SUBACK with granted QoS per filter (downgrade QoS 2 → 1 if configured)
### UNSUBSCRIBE (`MqttParseUnsubs` + `MqttProcessUnsubs`)
Mirror of SUBSCRIBE:
- Remove NATS subscriptions
- Delete JetStream consumers
- Update session state
- Send UNSUBACK
### PING
Queue PINGRESP (2 bytes: `0xD0 0x00`).
### DISCONNECT
1. Clear will message (graceful disconnect suppresses will)
2. Call `MqttHandleClosedClient()` for session cleanup
3. Close connection with `ClosedState.ClientClosed`
### Will Message (`MqttHandleWill`)
On abnormal disconnect (connection drop, not DISCONNECT):
- Publish will topic/message to NATS with configured QoS and retain flag
- Only if will was set in CONNECT and not cleared by DISCONNECT
### PUBREL Processing (`MqttProcessPubRel`)
For QoS 2 inbound flow:
1. Look up stored QoS 2 message by packet ID
2. Deliver via `MqttInitiateMsgDelivery()`
3. Send PUBCOMP(pi)
4. Remove from pending
---
## 4. Session Management & JetStream Integration
### Session Lifecycle
**Creation** (during CONNECT):
1. `GetOrCreateMqttAccountSessionManager()` — Lazy per-account ASM initialization
2. `MqttCreateAccountSessionManager()` — Creates ASM with JetStream streams:
- `$MQTT.msgs.<account>` — QoS 1/2 message persistence
- `$MQTT.sess.<account>` — Session state persistence
- `$MQTT.rmsgs.<account>` — Retained messages (MaxMsgsPerSubject=1)
3. Lock session by client ID hash
4. Create or restore `MqttSession`
**Persistence** (via `MqttJsa`):
- Serialize session state to `$MQTT.sess` stream keyed by client ID hash
- Clean session: delete all session data
- Reconnect (!clean): reload from JetStream, rebind subscriptions
**Takeover** (same client ID reconnects):
- Lock old session → disconnect old client → transfer to new client → resume pending
### JetStream Consumer Management
QoS 1/2 subscriptions create durable consumers:
- Durable name: `<sessionIdHash>_<nuid>`
- Filter: `$MQTT.msgs.<converted_subject>`
- Delivery subject: `$MQTT.sub.<nuid>`
- Ack policy: Explicit
- Max ack pending: From account config (default 1024)
### Retained Messages
- PUBLISH with retain flag → store in `$MQTT.rmsgs.<subject>` (MaxMsgsPerSubject=1)
- New SUBSCRIBE → `LoadLastMsgForAsync()` to find matching retained messages
- PUBLISH with retain + empty payload → delete retained message
### Extension Methods to Implement
| Method | Purpose |
|--------|---------|
| `StartMqtt()` | Listener startup |
| `MqttConfigAuth()` | Wire MQTT auth overrides |
| `MqttHandleClosedClient()` | Session cleanup on disconnect |
| `MqttUpdateMaxAckPending()` | Propagate config to consumers |
| `MqttGetJsaForAccount()` | Get/create JSA for account |
| `MqttStoreQosMsgForAccountOnNewSubject()` | Store QoS msg on new subject |
| `GetOrCreateMqttAccountSessionManager()` | Lazy ASM creation |
| `MqttCreateAccountSessionManager()` | ASM + stream creation |
| `MqttDetermineReplicas()` | Cluster-aware replica count |
| `MqttProcessConnect()` | CONNECT handling |
| `MqttHandleWill()` | Will message delivery |
| `MqttProcessPub()` | PUBLISH handling |
| `MqttInitiateMsgDelivery()` | Inject into NATS substrate |
| `MqttStoreQoS2MsgOnce()` | QoS 2 idempotent storage |
| `MqttProcessPubRel()` | PUBREL handling |
| `MqttCheckPubRetainedPerms()` | Retained message permission audit |
---
## 5. New Files
| File | Purpose | Est. LOC |
|------|---------|----------|
| `Mqtt/MqttParser.cs` | Parse + dispatch state machine | 400-500 |
| `Mqtt/MqttPacketHandlers.cs` | CONNECT/PUB/SUB/UNSUB/PING/DISCONNECT processing | 800-1000 |
| `Mqtt/MqttClientExtensions.cs` | ClientConnection MQTT methods (IsMqtt, enqueue helpers) | 200-300 |
| `NatsServer.Mqtt.cs` | Server-side MQTT partial (CreateMqttClient, listener) | 300-400 |
**Modified files:**
- `Mqtt/MqttHandler.cs` — Replace stubs with real implementations
- `NatsServer.cs` — Add `_mqttListener` field
- `NatsServer.Lifecycle.cs` — Add MQTT listener to shutdown
- `ClientConnection.cs` — Change `IsMqtt()`, add `Mqtt` property, ReadLoop branch
**Estimated total**: 2,000-3,000 LOC new code
---
## 6. Testing Strategy
### Unit Tests
**Parser tests** (`Tests/Server/Mqtt/MqttParserTests.cs`):
- Valid CONNECT packet → all fields extracted
- CONNECT with will message → will topic/message parsed
- Partial CONNECT (fragmented) → state saved, resume on next buffer
- Invalid first packet (not CONNECT) → error
- PUBLISH QoS 0/1/2 → topic conversion, payload, PI
- SUBSCRIBE with multiple filters → all converted
- UNSUBSCRIBE → filters parsed
- Remaining length edge cases (1-byte, 2-byte, 4-byte encodings)
**Dispatch tests** (`Tests/Server/Mqtt/MqttDispatchTests.cs`):
- Each packet type dispatches to correct handler
- CONNECT-first enforcement
- Invalid packet type → error
**Handler tests** (mocked server/session):
- CONNECT: clean session, session resumption, client ID generation
- PUBLISH QoS 0/1/2 flows
- SUBSCRIBE: subscription + consumer creation
- UNSUBSCRIBE: cleanup
- DISCONNECT: will cleared, session cleanup
- Will delivery on abnormal close
### Integration Tests
70 existing deferred tests in `Mqtt/MqttTests.cs` become runnable progressively.
New boot tests:
- `MqttBoot_AcceptsConnection_ShouldSucceed`
- `MqttBoot_StartAndShutdown_ShouldSucceed`
- End-to-end: client A publishes → client B receives
### Test approach
- Unit tests: mocked dependencies, no server boot
- Integration tests: `NatsServer.Start()` with `opts.Mqtt.Port = 0` (ephemeral)
- Raw TCP + binary MQTT packets for protocol tests (no third-party MQTT client)
---
## 7. Implementation Order (Bottom-Up)
1. **Listener + client creation**`StartMqtt()`, `CreateMqttClient()`, shutdown wiring
2. **Parser + dispatch**`MqttParse()`, ReadLoop integration, `IsMqtt()`
3. **CONNECT** — Parse + process, CONNACK response, auth
4. **PING** — Simplest packet handler (proves dispatch works)
5. **DISCONNECT** — Graceful close, will suppression
6. **PUBLISH QoS 0** — Basic message flow through NATS substrate
7. **SUBSCRIBE + UNSUBSCRIBE** — NATS subscription management
8. **PUBLISH QoS 1** — PUBACK flow, JetStream consumer creation
9. **PUBLISH QoS 2** — PUBREC/PUBREL/PUBCOMP handshake
10. **Will messages** — Abnormal disconnect handling
11. **Retained messages** — Store/retrieve/delete retained
12. **Session persistence** — Clean/dirty session lifecycle
13. **Session takeover** — Same client ID reconnect
14. **Config integration**`MqttConfigAuth`, `MqttUpdateMaxAckPending`, `MqttDetermineReplicas`, `MqttCheckPubRetainedPerms`
@@ -0,0 +1,102 @@
# Server Boot Parity Design
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Wire up `NatsServer.Start()` to call all subsystem startup methods in the same order as Go's `Server.Start()`, enabling a fully booting server that accepts client connections.
**Architecture:** The .NET port already has every subsystem startup method implemented (`AcceptLoop`, `StartMonitoring`, `EnableJetStream`, `StartRouting`, `StartGateways`, `StartLeafNodeAcceptLoop`, `StartWebsocketServer`, etc.). The only gap is that `Start()` (in `NatsServer.Init.cs:9761037`) stops after system account setup instead of continuing through the full startup sequence. The fix is wiring — calling existing methods in the correct order — plus 3 trivial no-op stubs for missing minor methods and guarding the MQTT `NotImplementedException`.
**Tech Stack:** .NET 10, C# latest, xUnit, NATS.Client.Core (for validation test)
---
## Problem Statement
The .NET `Start()` method currently:
1. Logs version/name info
2. Sets `_running = 1`
3. Enables leafnode flag
4. Writes PID file
5. Sets up system account
6. Starts OCSP response cache
7. Signals `_startupComplete` and logs "Server is ready"
It is missing ~15 subsystem calls that Go's `Start()` makes between steps 5 and 7.
## Design
### 1. Complete `Start()` Body
Insert the following calls in `NatsServer.Init.cs` between the system account setup and the "Server is ready" log, matching Go `server.go:22632575`:
```
CheckAuthForWarnings() -- new no-op stub
StartRateLimitLogExpiration() -- exists in Lifecycle.cs
StartProfiler() if ProfPort != 0 -- exists (stub) in Listeners.cs
Log config file info -- opts.ConfigFile notice
Log trusted operators -- opts.TrustedOperators loop
StartMonitoring() -- exists in Listeners.cs
AccountResolver.Start() if resolver != null -- exists
StartGWReplyMapExpiration() -- exists in Gateways.ReplyMap.cs
EnableJetStream(cfg) if opts.JetStream -- exists in JetStreamCore.cs
else: check accounts for JS limits -- exists (EnableJetStreamAccounts pattern)
StartDelayedApiResponder() -- new no-op stub
StartOCSPMonitoring() -- exists in Ocsp.cs
InitOCSPResponseCache() -- exists (StartOCSPResponseCache)
StartGateways() if Gateway.Port != 0 -- exists in Gateways.ConfigAndStartup.cs
StartWebsocketServer() if Websocket.Port != 0 -- exists in WebSocket.cs
StartLeafNodeAcceptLoop() if LeafNode.Port != 0 -- exists in LeafNodes.ConfigAndConnect.cs
SolicitLeafNodeRemotes() if remotes.Count > 0 -- exists in LeafNodes.ConfigAndConnect.cs
StartMqtt() if MQTT.Port != 0 -- guard NotImplementedException
StartRouting() if Cluster.Port != 0 -- exists in Routes.Connections.cs
LogPorts() if PortsFileDir != empty -- new no-op stub
Signal _startupComplete -- move to here (from current position)
AcceptLoop() if !DontListen -- exists in Listeners.cs
StartOCSPResponseCache() -- exists (move to after AcceptLoop)
Noticef("Server is ready") -- move to end
```
### 2. New Method Stubs
**`CheckAuthForWarnings()`** — `NatsServer.Auth.cs`
- No-op. In Go this logs warnings about insecure auth configs. Can be implemented later.
**`StartDelayedApiResponder()`** — `NatsServer.JetStreamCore.cs`
- Starts a background task that will process delayed JetStream API responses.
- Initial implementation: no-op goroutine that exits when `_quitCts` cancels.
**`LogPorts()`** — `NatsServer.Init.cs`
- No-op. Writes ports info to file when `PortsFileDir` configured. Minor utility.
### 3. MQTT Guard
Change `MqttServerExtensions.StartMqtt()` from throwing `NotImplementedException` to logging a warning and returning. This prevents `Start()` from crashing when MQTT port is configured.
### 4. Validation Test
New test file: `dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ServerBootTests.cs`
Test: `ServerBoot_AcceptsClientConnection_ShouldSucceed`
- Creates server with ephemeral port, JetStream enabled, temp store dir
- Calls `Start()`
- Connects with `NATS.Client.Core.NatsConnection`
- Publishes to `test.subject` and verifies subscribe receives message
- Calls `Shutdown()`
### 5. Files Changed
| File | Change |
|------|--------|
| `NatsServer.Init.cs` | Complete `Start()` body, add `LogPorts()` stub |
| `NatsServer.Auth.cs` | Add `CheckAuthForWarnings()` stub |
| `NatsServer.JetStreamCore.cs` | Add `StartDelayedApiResponder()` stub |
| `Mqtt/MqttHandler.cs` | Guard `StartMqtt()` to not throw |
| `ServerBootTests.cs` | New integration test |
### 6. Success Criteria
- `dotnet build` passes with 0 errors
- Existing unit tests still pass (2659 pass, 53 skip)
- Existing integration tests still pass (113 pass, 854 skip, 0 fail)
- New `ServerBoot_AcceptsClientConnection_ShouldSucceed` test passes
- Server logs show full startup sequence matching Go's output pattern
@@ -0,0 +1,601 @@
# Server Boot Parity Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Wire up `NatsServer.Start()` to call all subsystem startup methods matching Go's `Server.Start()` sequence, enabling a fully booting server that accepts client connections.
**Architecture:** The .NET port already has every subsystem startup method ported. The gap is that `Start()` stops after system account setup instead of continuing through the full startup sequence. The fix is pure wiring — calling existing methods in order — plus 2 trivial no-op stubs and an MQTT guard.
**Tech Stack:** .NET 10, C# latest, xUnit, Shouldly, NATS.Client.Core 2.7.2
---
## Task 1: Add `CheckAuthForWarnings()` stub
**Files:**
- Modify: `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs` (add method at end of class, before closing `}`)
**Step 1: Write the failing test**
No dedicated test needed — this is a no-op stub. The build itself will verify the method exists when Task 3 calls it from `Start()`.
**Step 2: Add the stub method**
Add the following to `NatsServer.Auth.cs`, at the end of the partial class body (before the final `}`):
```csharp
// =========================================================================
// CheckAuthForWarnings (feature 3049 — Start parity)
// =========================================================================
/// <summary>
/// Checks for insecure auth configurations and logs warnings.
/// Mirrors Go <c>Server.checkAuthforWarnings()</c> in server/server.go.
/// Stub — full implementation deferred.
/// </summary>
internal void CheckAuthForWarnings()
{
// No-op stub. Go logs warnings about:
// - Password auth without TLS
// - Token auth without TLS
// - NKey auth without TLS
// These are informational warnings only.
}
```
**Step 3: Build to verify it compiles**
Run: `dotnet build dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
Expected: Build succeeded. 0 Error(s)
**Step 4: Commit**
```bash
git add dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
git commit -m "feat: add CheckAuthForWarnings stub for Start() parity"
```
---
## Task 2: Add `StartDelayedApiResponder()` stub
**Files:**
- Modify: `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs` (add method)
**Step 1: Add the stub method**
Add the following to `NatsServer.JetStreamCore.cs`, inside the partial class:
```csharp
// =========================================================================
// Delayed API responder (feature 3049 — Start parity)
// =========================================================================
/// <summary>
/// Starts the delayed JetStream API response handler goroutine.
/// Started regardless of JetStream being enabled (can be enabled via config reload).
/// Mirrors Go <c>Server.delayedAPIResponder()</c> in server/jetstream_api.go.
/// Stub — full implementation deferred.
/// </summary>
internal void StartDelayedApiResponder()
{
StartGoRoutine(() =>
{
// No-op: exits when quit is signaled.
_quitCts.Token.WaitHandle.WaitOne();
});
}
```
**Step 2: Build to verify it compiles**
Run: `dotnet build dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
Expected: Build succeeded. 0 Error(s)
**Step 3: Commit**
```bash
git add dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.JetStreamCore.cs
git commit -m "feat: add StartDelayedApiResponder stub for Start() parity"
```
---
## Task 3: Guard MQTT `StartMqtt()` to not throw
**Files:**
- Modify: `dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs:136137`
**Step 1: Change StartMqtt from throwing to logging a warning**
Replace the existing line at `MqttHandler.cs:136-137`:
```csharp
public static void StartMqtt(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
```
With:
```csharp
public static void StartMqtt(this NatsServer server)
{
server.Warnf("MQTT listener not yet implemented; skipping MQTT startup");
}
```
Note: `Warnf` is a public method on `NatsServer` (verified in `NatsServer.Logging.cs`).
**Step 2: Build to verify it compiles**
Run: `dotnet build dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
Expected: Build succeeded. 0 Error(s)
**Step 3: Commit**
```bash
git add dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs
git commit -m "fix: guard StartMqtt to warn instead of throwing NotImplementedException"
```
---
## Task 4: Wire up `Start()` body to match Go's sequence
This is the core task. Replace the body of `Start()` in `NatsServer.Init.cs:9761037` with the full Go-parity startup sequence.
**Files:**
- Modify: `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs:9701037`
**Step 1: Replace the Start() method**
Replace lines 9701037 (the `/// <summary>` through the closing `}` of `Start()`) with the complete implementation below. The new code mirrors Go `server.go:22632575` line-by-line:
```csharp
/// <summary>
/// Starts the server (non-blocking). Writes startup log lines, starts all
/// subsystems (monitoring, JetStream, gateways, websocket, leafnodes, routes,
/// MQTT), then begins the client accept loop.
/// Mirrors Go <c>Server.Start</c> in server.go:22632575.
/// </summary>
public void Start()
{
Noticef("Starting nats-server");
var gc = string.IsNullOrEmpty(ServerConstants.GitCommit) ? "not set" : ServerConstants.GitCommit;
var opts = GetOpts();
_mu.EnterReadLock();
var leafNoCluster = _leafNoCluster;
_mu.ExitReadLock();
var clusterName = leafNoCluster ? string.Empty : ClusterName();
Noticef(" Version: {0}", ServerConstants.Version);
Noticef(" Git: [{0}]", gc);
if (!string.IsNullOrEmpty(clusterName))
Noticef(" Cluster: {0}", clusterName);
Noticef(" Name: {0}", _info.Name);
if (opts.JetStream)
Noticef(" Node: {0}", GetHash(_info.Name));
Noticef(" ID: {0}", _info.Id);
// Check for insecure configurations.
CheckAuthForWarnings();
// Avoid RACE between Start() and Shutdown().
Interlocked.Exchange(ref _running, 1);
_mu.EnterWriteLock();
_leafNodeEnabled = opts.LeafNode.Port != 0 || opts.LeafNode.Remotes.Count > 0;
_mu.ExitWriteLock();
lock (_grMu) { _grRunning = true; }
StartRateLimitLogExpiration();
// Pprof http endpoint for the profiler.
if (opts.ProfPort != 0)
{
StartProfiler();
}
if (!string.IsNullOrEmpty(opts.ConfigFile))
{
Noticef("Using configuration file: {0}", opts.ConfigFile);
}
var hasOperators = opts.TrustedOperators.Count > 0;
if (hasOperators)
{
Noticef("Trusted Operators");
}
if (hasOperators && string.IsNullOrEmpty(opts.SystemAccount))
{
Warnf("Trusted Operators should utilize a System Account");
}
if (opts.MaxPayload > ServerConstants.MaxPayloadMaxSize)
{
Warnf("Maximum payloads over {0} are generally discouraged and could lead to poor performance",
ServerConstants.MaxPayloadMaxSize);
}
// Log the pid to a file.
if (!string.IsNullOrEmpty(opts.PidFile))
{
var pidErr = LogPid();
if (pidErr != null)
{
Fatalf("Could not write pidfile: {0}", pidErr);
return;
}
}
// Setup system account which will start the eventing stack.
if (!string.IsNullOrEmpty(opts.SystemAccount))
{
var saErr = SetSystemAccount(opts.SystemAccount);
if (saErr != null)
{
Fatalf("Can't set system account: {0}", saErr);
return;
}
}
else if (!opts.NoSystemAccount)
{
SetDefaultSystemAccount();
}
// Start monitoring before enabling other subsystems.
var monErr = StartMonitoring();
if (monErr != null)
{
Fatalf("Can't start monitoring: {0}", monErr);
return;
}
// Start up resolver machinery.
var ar = AccountResolver();
if (ar != null)
{
var arErr = ar.Start(this);
if (arErr != null)
{
Fatalf("Could not start resolver: {0}", arErr);
return;
}
}
// Start expiration of mapped GW replies.
StartGWReplyMapExpiration();
// Check if JetStream has been enabled.
if (opts.JetStream)
{
var sa = SystemAccount();
if (sa != null && sa.JsLimitsCount() > 0)
{
Fatalf("Not allowed to enable JetStream on the system account");
return;
}
var cfg = new JetStreamConfig
{
StoreDir = opts.StoreDir,
SyncInterval = opts.SyncInterval,
SyncAlways = opts.SyncAlways,
MaxMemory = opts.JetStreamMaxMemory,
MaxStore = opts.JetStreamMaxStore,
Domain = opts.JetStreamDomain,
CompressOk = true,
UniqueTag = opts.JetStreamUniqueTag,
};
var jsErr = EnableJetStream(cfg);
if (jsErr != null)
{
Fatalf("Can't start JetStream: {0}", jsErr);
return;
}
}
// Delayed API response handling.
StartDelayedApiResponder();
// Start OCSP Stapling monitoring.
StartOCSPMonitoring();
// Configure OCSP Response Cache.
StartOCSPResponseCache();
// Start up gateway if needed.
if (opts.Gateway.Port != 0)
{
var gwErr = StartGateways();
if (gwErr != null)
{
Fatalf("Can't start gateways: {0}", gwErr);
return;
}
}
// Start websocket server if needed.
if (opts.Websocket.Port != 0)
{
StartWebsocketServer();
}
// Start up listen if we want to accept leaf node connections.
if (opts.LeafNode.Port != 0)
{
var lnErr = StartLeafNodeAcceptLoop();
if (lnErr != null)
{
Fatalf("Can't start leaf node listener: {0}", lnErr);
return;
}
}
// Solicit remote servers for leaf node connections.
if (opts.LeafNode.Remotes.Count > 0)
{
SolicitLeafNodeRemotes(opts.LeafNode.Remotes);
}
// The Routing routine needs to wait for the client listen
// port to be opened and potential ephemeral port selected.
var clientListenReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// MQTT
if (opts.Mqtt.Port != 0)
{
this.StartMqtt();
}
// Start up routing as well if needed.
if (opts.Cluster.Port != 0)
{
StartGoRoutine(() =>
{
StartRouting();
});
}
if (!string.IsNullOrEmpty(opts.PortsFileDir))
{
LogPorts();
}
// We've finished starting up.
_startupComplete.TrySetResult();
// Wait for clients.
if (!opts.DontListen)
{
AcceptLoop(clientListenReady);
}
// Bring OCSP Response cache online after accept loop started.
StartOCSPResponseCache();
Noticef("Server is ready");
}
```
**Important notes for the implementer:**
- `GetHash` is a static method in `NatsServer` — verify it exists. If not found, skip the `Noticef(" Node: ...")` line.
- `SystemAccount()` is a public method on `NatsServer` — verified exists in `NatsServer.Accounts.cs`.
- `JsLimitsCount()` must exist on `Account`. If not, use a check like `sa.HasJsLimits()` or skip that guard with a TODO comment.
- `StartRouting()` in .NET does not take a `clientListenReady` parameter (Go's does). The `clientListenReady` TCS is created for forward-compatibility but is currently only passed to `AcceptLoop`.
- The `CompressOk` property on `JetStreamConfig` — verify exact property name. May be `CompressOK`.
- The `UniqueTag` property on `JetStreamConfig` — verify exists. If not, omit.
**Step 2: Build to verify it compiles**
Run: `dotnet build dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
Expected: Build succeeded. 0 Error(s)
If there are compilation errors (e.g., missing properties like `CompressOk`, `UniqueTag`, `JsLimitsCount`), fix by:
- Using the correct property name (check the class definition)
- Adding a stub property if it's truly missing
- Commenting out the line with a `// TODO` if it references unavailable API
**Step 3: Run existing unit tests to verify no regressions**
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ --no-build`
Expected: 2659 passed, ~53 skipped, 0 failed
**Step 4: Run existing integration tests to verify no regressions**
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ --no-build`
Expected: ~113 passed, ~854 skipped, 0 failed
**Step 5: Commit**
```bash
git add dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
git commit -m "feat: complete Start() with full subsystem startup sequence
Wire up Start() to call all subsystem startup methods matching
Go's Server.Start() sequence: monitoring, resolver, JetStream,
gateways, websocket, leafnodes, MQTT, routing, accept loop."
```
---
## Task 5: Write server boot validation integration test
**Files:**
- Create: `dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ServerBootTests.cs`
**Step 1: Write the test**
Create `ServerBootTests.cs`:
```csharp
// Copyright 2013-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Net;
using System.Text;
using NATS.Client.Core;
using Shouldly;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
/// <summary>
/// End-to-end server boot tests that validate the full Start() → AcceptLoop → client connection lifecycle.
/// </summary>
[Trait("Category", "Integration")]
public sealed class ServerBootTests : IDisposable
{
private readonly string _storeDir;
public ServerBootTests()
{
_storeDir = Path.Combine(Path.GetTempPath(), $"natsnet-test-{Guid.NewGuid():N}");
}
public void Dispose()
{
try { Directory.Delete(_storeDir, true); } catch { }
}
/// <summary>
/// Validates that a server can boot, accept a TCP connection, and exchange
/// a NATS protocol handshake (INFO line). This proves the full Start() →
/// AcceptLoop → CreateClient pipeline works end-to-end.
/// </summary>
[Fact]
public async Task ServerBoot_AcceptsClientConnection_ShouldSucceed()
{
// Arrange — create server with ephemeral port
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0, // ephemeral
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull("NewServer should succeed");
server.ShouldNotBeNull();
try
{
// Act — start the server
server!.Start();
// Get the actual bound port
var addr = server.Addr() as IPEndPoint;
addr.ShouldNotBeNull("Server should have a listener address after Start()");
addr!.Port.ShouldBeGreaterThan(0);
// Connect a raw TCP client and read the INFO line
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(addr.Address, addr.Port);
using var stream = tcp.GetStream();
stream.ReadTimeout = 5000;
var buffer = new byte[4096];
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
bytesRead.ShouldBeGreaterThan(0, "Should receive data from server");
var response = Encoding.UTF8.GetString(buffer, 0, bytesRead);
response.ShouldStartWith("INFO ", "Server should send INFO line on connect");
}
finally
{
server!.Shutdown();
}
}
/// <summary>
/// Validates that Shutdown() after Start() completes cleanly without errors.
/// </summary>
[Fact]
public void ServerBoot_StartAndShutdown_ShouldSucceed()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
DontListen = true, // Don't open TCP listener — just test lifecycle
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
// Should not throw
server!.Start();
server.Running().ShouldBeTrue();
server.Shutdown();
server.Running().ShouldBeFalse();
}
}
```
**Step 2: Build the integration tests**
Run: `dotnet build dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/`
Expected: Build succeeded. 0 Error(s)
**Step 3: Run the new tests**
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ --filter "FullyQualifiedName~ServerBootTests" --no-build -v normal`
Expected: 2 passed, 0 failed
If `ServerBoot_AcceptsClientConnection_ShouldSucceed` fails:
- Check if `AcceptLoop` is actually being called (add a `Noticef` before the call)
- Check if the listener is null after `AcceptLoop` returns
- Check if `CreateClient` throws — look at the error in the test output
- If the INFO line isn't received, the client-side protocol handshake may be hanging — check `CreateClient` and the initial write path
If `ServerBoot_StartAndShutdown_ShouldSucceed` fails:
- Check if `Start()` throws an exception from one of the subsystem calls
- The `DontListen = true` flag should skip `AcceptLoop` — verify the `if (!opts.DontListen)` guard
**Step 4: Run the full test suite to confirm no regressions**
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/`
Expected: 2659 passed, ~53 skipped, 0 failed
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/`
Expected: ~115 passed (113 old + 2 new), ~854 skipped, 0 failed
**Step 5: Commit**
```bash
git add dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ServerBootTests.cs
git commit -m "test: add server boot validation integration tests
Two tests verifying full Start() lifecycle:
- ServerBoot_AcceptsClientConnection: connects TCP, reads INFO line
- ServerBoot_StartAndShutdown: verifies clean lifecycle with DontListen"
```
---
## Verification Checklist
After all tasks are complete:
- [ ] `dotnet build` — 0 errors
- [ ] Unit tests — 2659 pass, ~53 skip, 0 fail
- [ ] Integration tests — ~115 pass, ~854 skip, 0 fail (2 new tests pass)
- [ ] New `ServerBoot_AcceptsClientConnection_ShouldSucceed` — PASSES
- [ ] New `ServerBoot_StartAndShutdown_ShouldSucceed` — PASSES
- [ ] `Start()` method body matches Go's `Server.Start()` call sequence
@@ -0,0 +1,11 @@
{
"planPath": "docs/plans/2026-03-01-server-boot-parity-plan.md",
"tasks": [
{"id": 33, "subject": "Task 1: Add CheckAuthForWarnings() stub", "status": "pending"},
{"id": 34, "subject": "Task 2: Add StartDelayedApiResponder() stub", "status": "pending"},
{"id": 35, "subject": "Task 3: Guard MQTT StartMqtt() to not throw", "status": "pending"},
{"id": 36, "subject": "Task 4: Wire up Start() body to match Go sequence", "status": "pending", "blockedBy": [33, 34, 35]},
{"id": 37, "subject": "Task 5: Write server boot validation integration tests", "status": "pending", "blockedBy": [36]}
],
"lastUpdated": "2026-03-01T00:00:00Z"
}
@@ -25,6 +25,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
using ZB.MOM.NatsNet.Server.Mqtt;
using ZB.MOM.NatsNet.Server.Protocol;
using ZB.MOM.NatsNet.Server.WebSocket;
@@ -1329,6 +1330,10 @@ public sealed partial class ClientConnection
ClearPingTimer();
}
// Deliver MQTT will message on abnormal disconnect.
if (IsMqtt())
MqttPacketHandlers.DeliverWill(this);
// Close the underlying network connection.
try { _nc?.Close(); } catch { /* ignore */ }
_nc = null;
@@ -1405,8 +1410,59 @@ public sealed partial class ClientConnection
internal void ReadLoop(byte[]? pre)
{
LastIn = DateTime.UtcNow;
// Process any pre-read bytes first.
if (pre is { Length: > 0 })
{
TraceInOp("PRE", pre);
if (IsMqtt())
{
var preErr = MqttParser.Parse(this, pre, pre.Length);
if (preErr != null)
{
CloseConnection(ClosedState.ParseError);
return;
}
}
}
// MQTT clients use the MqttParser; NATS clients use ProtocolParser (not yet wired).
if (!IsMqtt())
return;
// Main read loop — read from network stream until closed.
var buf = new byte[32768]; // 32 KB read buffer
try
{
while (true)
{
int n;
try
{
n = _nc!.Read(buf, 0, buf.Length);
}
catch
{
break; // Connection closed or errored.
}
if (n <= 0)
break; // Connection closed.
LastIn = DateTime.UtcNow;
var err = MqttParser.Parse(this, buf, n);
if (err != null)
{
CloseConnection(ClosedState.ParseError);
return;
}
}
}
finally
{
CloseConnection(ClosedState.ClientClosed);
}
}
// =========================================================================
@@ -1803,7 +1859,7 @@ public sealed partial class ClientConnection
// IsMqtt / IsWebSocket helpers (used by clientType, not separately tracked)
// =========================================================================
internal bool IsMqtt() => false; // Deferred to session 22 (MQTT).
internal bool IsMqtt() => Mqtt != null;
internal bool IsWebSocket() => Ws != null;
internal bool IsHubLeafNode() => Kind == ClientKind.Leaf && Leaf?.IsSpoke != true;
internal string RemoteCluster() => Leaf?.RemoteCluster ?? string.Empty;
@@ -91,6 +91,16 @@ internal sealed class MqttHandler
/// </summary>
public bool DowngradeQoS2Sub { get; set; }
// ------------------------------------------------------------------
// QoS 2 in-memory pending store (full JetStream persistence in Task 6)
// ------------------------------------------------------------------
/// <summary>
/// In-memory store for QoS 2 inbound messages awaiting PUBREL.
/// Keyed by packet identifier. Replaces JetStream $MQTT_qos2in stream.
/// </summary>
public Dictionary<ushort, MqttPublishInfo> QoS2Pending { get; } = new();
// ------------------------------------------------------------------
// Parse state (used by the read-loop MQTT byte-stream parser)
// ------------------------------------------------------------------
@@ -110,6 +120,16 @@ internal sealed class MqttHandler
/// <summary>Multiplier accumulator used during multi-byte remaining-length decoding.</summary>
public int RemLenMult { get; set; }
// ------------------------------------------------------------------
// Reader
// ------------------------------------------------------------------
/// <summary>
/// Per-connection MQTT reader for binary packet parsing.
/// Created lazily on first use.
/// </summary>
public MqttReader Reader { get; } = new();
// ------------------------------------------------------------------
// Thread safety
// ------------------------------------------------------------------
@@ -119,13 +139,18 @@ internal sealed class MqttHandler
}
// ============================================================================
// Server-side MQTT extension methods (stubs)
// Server-side MQTT extension methods
// ============================================================================
/// <summary>
/// Stub extension methods on <see cref="NatsServer"/> for MQTT server operations.
/// Extension methods on <see cref="NatsServer"/> for MQTT server operations.
/// Mirrors the server-receiver MQTT functions in server/mqtt.go.
/// All methods throw <see cref="NotImplementedException"/> until session 22 is complete.
/// <para>
/// Methods that only require config/state inspection are implemented as no-ops
/// or minimal stubs. Methods that require a running JetStream subsystem remain
/// as <see cref="NotImplementedException"/> stubs — they are not called from any
/// active code path and will be completed when JetStream MQTT persistence is wired.
/// </para>
/// </summary>
internal static class MqttServerExtensions
{
@@ -133,120 +158,118 @@ internal static class MqttServerExtensions
/// Start listening for MQTT client connections.
/// Mirrors Go <c>(*Server).startMQTT()</c>.
/// </summary>
public static void StartMqtt(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
public static void StartMqtt(this NatsServer server)
{
server.StartMqttListener();
}
// ------------------------------------------------------------------
// Config / state — implemented as minimal stubs
// ------------------------------------------------------------------
/// <summary>
/// Configure MQTT authentication overrides from the MQTT options block.
/// No-op: auth overrides are applied through the standard auth pipeline.
/// Mirrors Go <c>(*Server).mqttConfigAuth()</c>.
/// </summary>
public static void MqttConfigAuth(this NatsServer server, object mqttOpts) =>
throw new NotImplementedException("TODO: session 22");
public static void MqttConfigAuth(this NatsServer _server, object _mqttOpts)
{
// No-op: MQTT auth merges into the server's standard auth configuration.
}
/// <summary>
/// Handle cleanup when an MQTT client connection closes.
/// Will delivery and subscription cleanup are handled inline by
/// <see cref="MqttPacketHandlers.HandleDisconnect"/> and
/// <see cref="MqttPacketHandlers.DeliverWill"/>.
/// Mirrors Go <c>(*Server).mqttHandleClosedClient()</c>.
/// </summary>
public static void MqttHandleClosedClient(this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
public static void MqttHandleClosedClient(this NatsServer _server, ClientConnection _client)
{
// No-op: cleanup is handled by HandleDisconnect/DeliverWill in MqttPacketHandlers.
}
/// <summary>
/// Propagate a change to the maximum ack-pending limit to all MQTT sessions.
/// No-op: ack-pending limits are enforced at the JetStream consumer level.
/// Mirrors Go <c>(*Server).mqttUpdateMaxAckPending()</c>.
/// </summary>
public static void MqttUpdateMaxAckPending(this NatsServer server, ushort maxp) =>
throw new NotImplementedException("TODO: session 22");
public static void MqttUpdateMaxAckPending(this NatsServer _server, ushort _maxp)
{
// No-op: will be wired when JetStream consumer ack-pending is implemented.
}
/// <summary>
/// Determine how many JetStream replicas to use for MQTT streams.
/// Returns 1 (standalone mode). Cluster-aware replica selection requires
/// the clustering subsystem.
/// Mirrors Go <c>(*Server).mqttDetermineReplicas()</c>.
/// </summary>
public static int MqttDetermineReplicas(this NatsServer _server) => 1;
// ------------------------------------------------------------------
// JetStream-dependent — stubs (not called from any active code path)
// ------------------------------------------------------------------
/// <summary>
/// Retrieve or lazily-create the JSA for the named account.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttGetJSAForAccount()</c>.
/// </summary>
public static MqttJsa MqttGetJsaForAccount(this NatsServer server, string account) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Store a QoS message for an account on a (possibly new) NATS subject.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttStoreQoSMsgForAccountOnNewSubject()</c>.
/// </summary>
public static void MqttStoreQosMsgForAccountOnNewSubject(
this NatsServer server,
int hdr, byte[] msg, string account, string subject) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Get or create the <see cref="MqttAccountSessionManager"/> for the client's account.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).getOrCreateMQTTAccountSessionManager()</c>.
/// </summary>
public static MqttAccountSessionManager GetOrCreateMqttAccountSessionManager(
this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Create a new <see cref="MqttAccountSessionManager"/> for the given account.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttCreateAccountSessionManager()</c>.
/// </summary>
public static MqttAccountSessionManager MqttCreateAccountSessionManager(
this NatsServer server, object account, System.Threading.CancellationToken cancel) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Determine how many JetStream replicas to use for MQTT streams.
/// Mirrors Go <c>(*Server).mqttDetermineReplicas()</c>.
/// </summary>
public static int MqttDetermineReplicas(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an MQTT CONNECT packet after parsing.
/// Mirrors Go <c>(*Server).mqttProcessConnect()</c>.
/// </summary>
public static void MqttProcessConnect(
this NatsServer server, object client, MqttConnectProto cp, bool trace) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Send the Will message for a client that disconnected unexpectedly.
/// Mirrors Go <c>(*Server).mqttHandleWill()</c>.
/// </summary>
public static void MqttHandleWill(this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an inbound MQTT PUBLISH packet.
/// Mirrors Go <c>(*Server).mqttProcessPub()</c>.
/// </summary>
public static void MqttProcessPub(
this NatsServer server, object client, MqttPublishInfo pp, bool trace) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Initiate delivery of a PUBLISH message via JetStream.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttInitiateMsgDelivery()</c>.
/// </summary>
public static void MqttInitiateMsgDelivery(
this NatsServer server, object client, MqttPublishInfo pp) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Store a QoS-2 PUBLISH exactly once (idempotent).
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttStoreQoS2MsgOnce()</c>.
/// </summary>
public static void MqttStoreQoS2MsgOnce(
this NatsServer server, object client, MqttPublishInfo pp) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an inbound MQTT PUBREL packet.
/// Mirrors Go <c>(*Server).mqttProcessPubRel()</c>.
/// </summary>
public static void MqttProcessPubRel(
this NatsServer server, object client, ushort pi, bool trace) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
/// <summary>
/// Audit retained-message permissions after a configuration reload.
/// Requires JetStream subsystem.
/// Mirrors Go <c>(*Server).mqttCheckPubRetainedPerms()</c>.
/// </summary>
public static void MqttCheckPubRetainedPerms(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
throw new NotImplementedException("requires JetStream subsystem");
}
@@ -0,0 +1,668 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go in the NATS server Go source.
using System.Text;
namespace ZB.MOM.NatsNet.Server.Mqtt;
/// <summary>
/// MQTT packet parsing and processing handlers.
/// Mirrors the mqttParseConnect / mqttProcessConnect / mqttEnqueueConnAck
/// functions in server/mqtt.go.
/// </summary>
internal static class MqttPacketHandlers
{
/// <summary>
/// Parses an MQTT CONNECT packet from the reader.
/// Returns (returnCode, connectProto, error).
/// returnCode == 0 means success; non-zero is a CONNACK return code.
/// Mirrors Go <c>mqttParseConnect()</c>.
/// </summary>
public static (byte rc, MqttConnectProto? cp, Exception? err) ParseConnect(MqttReader r)
{
// --- Protocol Name ---
string protoName;
try { protoName = r.ReadString("protocol name"); }
catch (Exception ex) { return (0, null, ex); }
if (protoName != "MQTT")
return (MqttConnAckRc.UnacceptableProtocol, null,
new InvalidOperationException($"unsupported MQTT protocol: \"{protoName}\""));
// --- Protocol Level ---
byte level;
try { level = r.ReadByte("protocol level"); }
catch (Exception ex) { return (0, null, ex); }
if (level != 0x04)
return (MqttConnAckRc.UnacceptableProtocol, null,
new InvalidOperationException($"unsupported MQTT protocol level: {level}"));
// --- Connect Flags ---
byte flags;
try { flags = r.ReadByte("connect flags"); }
catch (Exception ex) { return (0, null, ex); }
if ((flags & MqttConnectFlag.Reserved) != 0)
return (0, null, new InvalidOperationException("CONNECT flags reserved bit must be 0"));
bool cleanSession = (flags & MqttConnectFlag.CleanSession) != 0;
bool willFlag = (flags & MqttConnectFlag.WillFlag) != 0;
byte willQos = (byte)((flags & MqttConnectFlag.WillQoS) >> 3);
bool willRetain = (flags & MqttConnectFlag.WillRetain) != 0;
bool hasPassword = (flags & MqttConnectFlag.PasswordFlag) != 0;
bool hasUsername = (flags & MqttConnectFlag.UsernameFlag) != 0;
// Validate Will flags.
if (!willFlag)
{
if (willQos != 0)
return (0, null, new InvalidOperationException("Will QoS must be 0 when Will Flag is 0"));
if (willRetain)
return (0, null, new InvalidOperationException("Will Retain must be 0 when Will Flag is 0"));
}
else
{
if (willQos > 2)
return (0, null, new InvalidOperationException($"invalid Will QoS: {willQos}"));
}
// Username/password consistency.
if (hasPassword && !hasUsername)
return (0, null, new InvalidOperationException("password flag without username flag"));
// --- Keep Alive ---
ushort keepAlive;
try { keepAlive = r.ReadUInt16("keep alive"); }
catch (Exception ex) { return (0, null, ex); }
// --- Client ID ---
string clientId;
try { clientId = r.ReadString("client id"); }
catch (Exception ex) { return (0, null, ex); }
if (string.IsNullOrEmpty(clientId))
{
if (!cleanSession)
return (MqttConnAckRc.IdentifierRejected, null,
new InvalidOperationException("empty client ID requires clean session flag"));
// Generate a unique client ID.
clientId = Guid.NewGuid().ToString("N");
}
// --- Will Topic & Message ---
MqttWill? will = null;
if (willFlag)
{
string willTopic;
try { willTopic = r.ReadString("will topic"); }
catch (Exception ex) { return (0, null, ex); }
if (string.IsNullOrEmpty(willTopic))
return (0, null, new InvalidOperationException("empty will topic"));
byte[] willMsg;
try { willMsg = r.ReadBytes("will message", copy: true); }
catch (Exception ex) { return (0, null, ex); }
// Convert MQTT topic to NATS subject.
var topicBytes = Encoding.UTF8.GetBytes(willTopic);
byte[] subjectBytes;
try { subjectBytes = MqttSubjectConverter.MqttTopicToNatsPubSubject(topicBytes); }
catch (Exception ex) { return (0, null, ex); }
will = new MqttWill
{
Topic = willTopic,
Subject = Encoding.UTF8.GetString(subjectBytes),
Msg = willMsg.Length > 0 ? willMsg : null,
Qos = willQos,
Retain = willRetain,
};
}
// --- Username ---
string username = string.Empty;
if (hasUsername)
{
try { username = r.ReadString("username"); }
catch (Exception ex) { return (0, null, ex); }
if (string.IsNullOrEmpty(username))
return (0, null, new InvalidOperationException("empty username"));
}
// --- Password ---
byte[]? password = null;
if (hasPassword)
{
try { password = r.ReadBytes("password", copy: true); }
catch (Exception ex) { return (0, null, ex); }
}
var cp = new MqttConnectProto
{
ClientId = clientId,
Will = will,
Username = username,
Password = password,
CleanSession = cleanSession,
KeepAlive = keepAlive,
};
return (MqttConnAckRc.Accepted, cp, null);
}
/// <summary>
/// Processes a parsed CONNECT packet: sets client state, sends CONNACK.
/// Minimal implementation — full session management deferred to Task 6.
/// Mirrors Go <c>mqttProcessConnect()</c>.
/// </summary>
public static Exception? ProcessConnect(ClientConnection c, MqttConnectProto cp)
{
var mqtt = c.Mqtt!;
// Store client identity.
mqtt.ClientId = cp.ClientId;
mqtt.CleanSession = cp.CleanSession;
mqtt.KeepAlive = cp.KeepAlive;
mqtt.Will = cp.Will;
// Store auth credentials on client options.
if (!string.IsNullOrEmpty(cp.Username))
c.Opts.Username = cp.Username;
if (cp.Password != null)
c.Opts.Password = Encoding.UTF8.GetString(cp.Password);
// Mark as connected.
c.Flags |= ClientFlags.ConnectReceived;
// Set keep-alive read deadline.
if (cp.KeepAlive > 0)
{
// MQTT spec: server MUST disconnect if no packet within 1.5x keep-alive.
var deadline = TimeSpan.FromSeconds(cp.KeepAlive * 1.5);
mqtt.KeepAlive = cp.KeepAlive;
// TODO: set read deadline on connection stream (Task 7)
}
// Send CONNACK (accepted, no session present for now).
EnqueueConnAck(c, MqttConnAckRc.Accepted, sessionPresent: false);
return null;
}
/// <summary>
/// Enqueues a CONNACK packet to the client.
/// Mirrors Go <c>mqttEnqueueConnAck()</c>.
/// </summary>
public static void EnqueueConnAck(ClientConnection c, byte rc, bool sessionPresent)
{
byte sp = 0;
if (rc == MqttConnAckRc.Accepted && sessionPresent)
sp = 1;
ReadOnlySpan<byte> connack = [MqttPacket.ConnectAck, 0x02, sp, rc];
lock (c)
{
c.EnqueueProto(connack);
}
}
/// <summary>
/// Handles DISCONNECT: clears the will message and closes the connection.
/// Mirrors Go DISCONNECT case in mqttParse().
/// </summary>
public static void HandleDisconnect(ClientConnection c)
{
// Per MQTT spec 3.1.2-8: discard the will message on clean disconnect.
lock (c)
{
if (c.Mqtt != null)
c.Mqtt.Will = null;
}
// Close the connection cleanly.
c.CloseConnection(ClosedState.ClientClosed);
}
// =========================================================================
// Will message delivery
// =========================================================================
/// <summary>
/// Delivers the will message if one is set and the connection was not cleanly
/// disconnected (i.e., DISCONNECT was not received, so will is still non-null).
/// Called from CloseConnection(). Mirrors Go <c>mqttHandleWill()</c>.
/// </summary>
public static void DeliverWill(ClientConnection c)
{
MqttWill? will;
lock (c)
{
if (c.Mqtt == null) return;
will = c.Mqtt.Will;
c.Mqtt.Will = null; // Prevent duplicate delivery.
}
if (will == null)
return;
// Deliver the will message via internal NATS routing.
var payload = will.Msg ?? [];
c.ProcessInboundClientMsg(payload);
}
// =========================================================================
// PUBLISH parsing + processing
// =========================================================================
/// <summary>
/// Parses an MQTT PUBLISH packet from the reader.
/// <paramref name="pl"/> is the remaining length, <paramref name="flags"/> is the lower
/// nibble of the first byte (DUP/QoS/RETAIN).
/// Mirrors Go <c>mqttParsePub()</c>.
/// </summary>
public static (MqttPublishInfo? pp, Exception? err) ParsePub(
MqttReader r, int pl, byte flags, bool rejectQoS2)
{
var start = r.Position;
// Extract QoS from flags bits 1-2.
var qos = (byte)((flags & MqttPubFlag.QoS) >> 1);
if (qos > 2)
return (null, new InvalidOperationException($"QoS value must be 0, 1, or 2, got {qos}"));
if (qos == 2 && rejectQoS2)
return (null, new InvalidOperationException("QoS-2 PUBLISH rejected by server policy"));
// Topic name (2-byte length prefix + UTF-8 bytes).
byte[] topicBytes;
try { topicBytes = r.ReadBytes("topic", copy: false); }
catch (Exception ex) { return (null, ex); }
if (topicBytes.Length == 0)
return (null, new InvalidOperationException("empty topic in PUBLISH"));
// Convert MQTT topic to NATS subject.
byte[] subjectBytes;
try { subjectBytes = MqttSubjectConverter.MqttTopicToNatsPubSubject(topicBytes); }
catch (Exception ex) { return (null, ex); }
// Packet identifier (QoS > 0 only).
ushort pi = 0;
if (qos > 0)
{
try { pi = r.ReadUInt16("packet identifier"); }
catch (Exception ex) { return (null, ex); }
if (pi == 0)
return (null, new InvalidOperationException("packet identifier must not be 0 for QoS > 0"));
}
// Payload = remaining bytes in the packet.
var consumed = r.Position - start;
var payloadLen = pl - consumed;
byte[]? payload = null;
if (payloadLen > 0)
{
try { payload = r.ReadSlice(payloadLen, "publish payload"); }
catch (Exception ex) { return (null, ex); }
}
else if (payloadLen < 0)
{
return (null, new InvalidOperationException("PUBLISH packet payload length underflow"));
}
var pp = new MqttPublishInfo
{
Topic = Encoding.UTF8.GetString(topicBytes),
Subject = Encoding.UTF8.GetString(subjectBytes),
Msg = payload,
Qos = qos,
Retain = (flags & MqttPubFlag.Retain) != 0,
Dup = (flags & MqttPubFlag.Dup) != 0,
Pi = pi,
};
return (pp, null);
}
/// <summary>
/// Processes an inbound MQTT PUBLISH packet.
/// QoS 0: routes immediately. QoS 1: deliver + PUBACK. QoS 2: store + PUBREC.
/// Mirrors Go <c>mqttProcessPub()</c>.
/// </summary>
public static Exception? ProcessPub(ClientConnection c, MqttPublishInfo pp)
{
var payload = pp.Msg ?? [];
switch (pp.Qos)
{
case 0:
c.ProcessInboundClientMsg(payload);
return null;
case 1:
// QoS 1: deliver immediately, then send PUBACK.
c.ProcessInboundClientMsg(payload);
EnqueuePubResponse(c, MqttPacket.PubAck, pp.Pi);
return null;
case 2:
// QoS 2: store message pending PUBREL, then send PUBREC.
// Full JetStream persistence deferred to Task 6 — uses in-memory store.
lock (c)
{
c.Mqtt!.QoS2Pending[pp.Pi] = pp;
}
EnqueuePubResponse(c, MqttPacket.PubRec, pp.Pi);
return null;
default:
return new InvalidOperationException($"invalid QoS: {pp.Qos}");
}
}
// =========================================================================
// PUBACK / PUBREC / PUBREL / PUBCOMP handling
// =========================================================================
/// <summary>
/// Parses a packet-identifier-only packet (PUBACK, PUBREC, PUBREL, PUBCOMP).
/// Returns (pi, error). Mirrors Go <c>mqttParsePIPacket()</c>.
/// </summary>
public static (ushort pi, Exception? err) ParsePiPacket(MqttReader r)
{
ushort pi;
try { pi = r.ReadUInt16("packet identifier"); }
catch (Exception ex) { return (0, ex); }
if (pi == 0)
return (0, new InvalidOperationException("packet identifier must not be 0"));
return (pi, null);
}
/// <summary>
/// Processes an inbound PUBREL packet (QoS 2 phase 2).
/// Retrieves the stored QoS 2 message, delivers it, and sends PUBCOMP.
/// Mirrors Go <c>mqttProcessPubRel()</c>.
/// </summary>
public static Exception? ProcessPubRel(ClientConnection c, ushort pi)
{
// Always send PUBCOMP, even if message not found (idempotency).
MqttPublishInfo? pp;
lock (c)
{
c.Mqtt!.QoS2Pending.Remove(pi, out pp);
}
// Send PUBCOMP.
EnqueuePubResponse(c, MqttPacket.PubComp, pi);
// Deliver the stored message if found.
if (pp != null)
{
var payload = pp.Msg ?? [];
c.ProcessInboundClientMsg(payload);
}
return null;
}
/// <summary>
/// Processes an inbound PUBACK (client acknowledges QoS 1 message from server).
/// Removes from pending tracking. Mirrors Go <c>mqttProcessPubAck()</c>.
/// </summary>
public static Exception? ProcessPubAck(ClientConnection c, ushort pi)
{
lock (c)
{
c.Mqtt!.Pending.Remove(pi);
}
return null;
}
/// <summary>
/// Processes an inbound PUBREC (client acknowledges QoS 2 message from server, phase 1).
/// Transitions to PUBREL phase. Mirrors Go <c>mqttProcessPubRec()</c>.
/// </summary>
public static Exception? ProcessPubRec(ClientConnection c, ushort pi)
{
// In the full implementation, this would store a PUBREL in JetStream.
// For now, send PUBREL immediately and remove from pending.
lock (c)
{
c.Mqtt!.Pending.Remove(pi);
}
EnqueuePubResponse(c, MqttPacket.PubRel, pi);
return null;
}
/// <summary>
/// Processes an inbound PUBCOMP (client acknowledges PUBREL, QoS 2 complete).
/// Mirrors Go <c>mqttProcessPubComp()</c>.
/// </summary>
public static Exception? ProcessPubComp(ClientConnection c, ushort pi)
{
// Final cleanup — remove any tracking for this PI.
lock (c)
{
c.Mqtt!.Pending.Remove(pi);
}
return null;
}
/// <summary>
/// Enqueues a PUBACK/PUBREC/PUBREL/PUBCOMP response packet.
/// All four share the same 4-byte format: [type] [0x02] [PI high] [PI low].
/// PUBREL has bit 1 set in byte 0 per MQTT spec [3.6.1-1].
/// Mirrors Go <c>mqttEnqueuePubResponse()</c>.
/// </summary>
public static void EnqueuePubResponse(ClientConnection c, byte packetType, ushort pi)
{
var b0 = packetType;
// PUBREL requires fixed header bits 0010 per MQTT spec.
if (packetType == MqttPacket.PubRel)
b0 |= 0x02;
ReadOnlySpan<byte> packet = [b0, 0x02, (byte)(pi >> 8), (byte)(pi & 0xFF)];
lock (c)
{
c.EnqueueProto(packet);
}
}
// =========================================================================
// SUBSCRIBE / UNSUBSCRIBE parsing + processing
// =========================================================================
/// <summary>
/// Parses SUBSCRIBE or UNSUBSCRIBE packet filters from the reader.
/// Returns (packetId, filters, error).
/// Mirrors Go <c>mqttParseSubsOrUnsubs()</c>.
/// </summary>
public static (ushort pi, List<MqttFilter>? filters, Exception? err) ParseSubsOrUnsubs(
MqttReader r, byte firstByte, int pl, bool isSub)
{
var kind = isSub ? "SUBSCRIBE" : "UNSUBSCRIBE";
// Validate reserved flags (must be 0x02 per spec).
var expectedFlags = isSub ? MqttConst.SubscribeFlags : MqttConst.UnsubscribeFlags;
var actualFlags = (byte)(firstByte & MqttPacket.FlagMask);
if (actualFlags != expectedFlags)
return (0, null, new InvalidOperationException(
$"{kind} reserved flags must be 0x{expectedFlags:X2}, got 0x{actualFlags:X2}"));
var end = r.Position + pl;
// Packet identifier.
ushort pi;
try { pi = r.ReadUInt16("packet identifier"); }
catch (Exception ex) { return (0, null, ex); }
if (pi == 0)
return (0, null, new InvalidOperationException("packet identifier must not be 0"));
var filters = new List<MqttFilter>();
while (r.Position < end)
{
// Topic filter (2-byte length + UTF-8 bytes).
byte[] topicBytes;
try { topicBytes = r.ReadBytes("topic filter", copy: false); }
catch (Exception ex) { return (0, null, ex); }
if (topicBytes.Length == 0)
return (0, null, new InvalidOperationException("empty topic filter"));
// Convert MQTT filter to NATS subject.
string natsSubject;
try
{
var subjBytes = MqttSubjectConverter.MqttFilterToNatsSubject(topicBytes);
natsSubject = Encoding.UTF8.GetString(subjBytes);
}
catch (Exception ex) { return (0, null, ex); }
byte qos = 0;
if (isSub)
{
try { qos = r.ReadByte("QoS"); }
catch (Exception ex) { return (0, null, ex); }
if (qos > 2)
return (0, null, new InvalidOperationException($"invalid QoS value: {qos}"));
}
filters.Add(new MqttFilter
{
Filter = natsSubject,
Qos = qos,
Ttopic = topicBytes,
});
}
if (filters.Count == 0)
return (0, null, new InvalidOperationException(
$"{kind} must contain at least one topic filter"));
return (pi, filters, null);
}
/// <summary>
/// Processes parsed SUBSCRIBE filters: creates NATS subscriptions and sends SUBACK.
/// Mirrors Go <c>mqttProcessSubs()</c> (minimal — session/JetStream deferred to Task 5-6).
/// </summary>
public static Exception? ProcessSubs(ClientConnection c, ushort pi, List<MqttFilter> filters)
{
var mqtt = c.Mqtt!;
foreach (var f in filters)
{
// Downgrade QoS 2 if configured.
if (f.Qos == 2 && mqtt.DowngradeQoS2Sub)
f.Qos = 1;
// Create NATS subscription using the filter as SID.
var sid = Encoding.UTF8.GetBytes(f.Filter);
var subject = Encoding.UTF8.GetBytes(f.Filter);
var (_, err) = c.ProcessSub(subject, queue: null, sid: sid, noForward: false);
if (err != null)
{
// Mark as failure in SUBACK.
f.Qos = MqttConst.SubAckFailure;
}
}
EnqueueSubAck(c, pi, filters);
return null;
}
/// <summary>
/// Processes parsed UNSUBSCRIBE filters: removes subscriptions and sends UNSUBACK.
/// Mirrors Go <c>mqttProcessUnsubs()</c> (minimal — session cleanup deferred to Task 6).
/// </summary>
public static Exception? ProcessUnsubs(ClientConnection c, ushort pi, List<MqttFilter> filters)
{
foreach (var f in filters)
{
var sid = Encoding.UTF8.GetBytes(f.Filter);
c.RemoveSubBySid(sid);
}
EnqueueUnsubAck(c, pi);
return null;
}
// =========================================================================
// SUBACK / UNSUBACK encoding
// =========================================================================
/// <summary>
/// Enqueues a SUBACK packet. Mirrors Go <c>mqttEnqueueSubAck()</c>.
/// Format: [0x90] [var-int length] [PI high] [PI low] [QoS per filter...]
/// </summary>
public static void EnqueueSubAck(ClientConnection c, ushort pi, List<MqttFilter> filters)
{
var payloadLen = 2 + filters.Count;
var packet = new byte[1 + VarIntSize(payloadLen) + payloadLen];
var pos = 0;
packet[pos++] = MqttPacket.SubAck;
pos += WriteVarInt(packet.AsSpan(pos), payloadLen);
packet[pos++] = (byte)(pi >> 8);
packet[pos++] = (byte)(pi & 0xFF);
foreach (var f in filters)
packet[pos++] = f.Qos;
lock (c)
{
c.EnqueueProto(packet.AsSpan(0, pos));
}
}
/// <summary>
/// Enqueues an UNSUBACK packet. Mirrors Go <c>mqttEnqueueUnsubAck()</c>.
/// Format: [0xB0] [0x02] [PI high] [PI low]
/// </summary>
public static void EnqueueUnsubAck(ClientConnection c, ushort pi)
{
ReadOnlySpan<byte> unsuback =
[MqttPacket.UnsubAck, 0x02, (byte)(pi >> 8), (byte)(pi & 0xFF)];
lock (c)
{
c.EnqueueProto(unsuback);
}
}
// =========================================================================
// Helpers
// =========================================================================
private static int VarIntSize(int value)
{
if (value < 0x80) return 1;
if (value < 0x4000) return 2;
if (value < 0x200000) return 3;
return 4;
}
private static int WriteVarInt(Span<byte> buf, int value)
{
var pos = 0;
do
{
var b = (byte)(value & 0x7F);
value >>= 7;
if (value > 0) b |= 0x80;
buf[pos++] = b;
} while (value > 0);
return pos;
}
}
@@ -0,0 +1,192 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go mqttParse() in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Mqtt;
/// <summary>
/// MQTT binary packet parser and dispatch.
/// Reads packets from a byte buffer using <see cref="MqttReader"/> and dispatches
/// to the appropriate handler based on packet type.
/// Mirrors Go <c>mqttParse()</c> in server/mqtt.go.
/// </summary>
internal static class MqttParser
{
/// <summary>PINGRESP packet bytes: 0xD0 0x00.</summary>
private static readonly byte[] PingRespPacket = [MqttPacket.PingResp, 0x00];
/// <summary>
/// Parses MQTT packets from <paramref name="buf"/> and dispatches to handlers.
/// Returns null on success, or an exception describing the parse/dispatch error.
/// Handles partial packets by saving state in the client's <see cref="MqttReader"/>.
/// Mirrors Go <c>mqttParse(r *mqttReader, c *client, ...)</c>.
/// </summary>
public static Exception? Parse(ClientConnection c, byte[] buf, int len)
{
var mqtt = c.Mqtt!;
var r = mqtt.Reader;
// Slice buffer to actual length if needed.
if (len < buf.Length)
{
var tmp = new byte[len];
Buffer.BlockCopy(buf, 0, tmp, 0, len);
buf = tmp;
}
r.Reset(buf);
var connected = (c.Flags & ClientFlags.ConnectReceived) != 0;
Exception? err = null;
while (err == null && r.HasMore())
{
r.PacketStart = r.Position;
// Read packet type + flags byte.
byte b;
try { b = r.ReadByte("packet type"); }
catch (Exception ex) { err = ex; break; }
var pt = (byte)(b & MqttPacket.Mask);
// CONNECT must be the first packet.
if (!connected && pt != MqttPacket.Connect)
{
err = new InvalidOperationException(
$"the first packet should be a CONNECT (0x{MqttPacket.Connect:X2}), got 0x{pt:X2}");
break;
}
// Read remaining length (variable-length encoding).
int pl;
bool complete;
try
{
(pl, complete) = r.ReadPacketLen();
}
catch (Exception ex) { err = ex; break; }
if (!complete)
break; // Partial packet — state saved in reader.
// Dispatch based on packet type.
switch (pt)
{
case MqttPacket.Pub:
var pubFlags = (byte)(b & MqttPacket.FlagMask);
var (pp, pubErr) = MqttPacketHandlers.ParsePub(r, pl, pubFlags, mqtt.RejectQoS2Pub);
if (pubErr != null) { err = pubErr; break; }
err = MqttPacketHandlers.ProcessPub(c, pp!);
break;
case MqttPacket.PubAck:
{
var (ackPi, ackErr) = MqttPacketHandlers.ParsePiPacket(r);
if (ackErr != null) { err = ackErr; break; }
err = MqttPacketHandlers.ProcessPubAck(c, ackPi);
break;
}
case MqttPacket.PubRec:
{
var (recPi, recErr) = MqttPacketHandlers.ParsePiPacket(r);
if (recErr != null) { err = recErr; break; }
err = MqttPacketHandlers.ProcessPubRec(c, recPi);
break;
}
case MqttPacket.PubRel:
{
var (relPi, relErr) = MqttPacketHandlers.ParsePiPacket(r);
if (relErr != null) { err = relErr; break; }
err = MqttPacketHandlers.ProcessPubRel(c, relPi);
break;
}
case MqttPacket.PubComp:
{
var (compPi, compErr) = MqttPacketHandlers.ParsePiPacket(r);
if (compErr != null) { err = compErr; break; }
err = MqttPacketHandlers.ProcessPubComp(c, compPi);
break;
}
case MqttPacket.Sub:
var (subPi, subFilters, subErr) = MqttPacketHandlers.ParseSubsOrUnsubs(r, b, pl, isSub: true);
if (subErr != null) { err = subErr; break; }
err = MqttPacketHandlers.ProcessSubs(c, subPi, subFilters!);
break;
case MqttPacket.Unsub:
var (unsubPi, unsubFilters, unsubErr) = MqttPacketHandlers.ParseSubsOrUnsubs(r, b, pl, isSub: false);
if (unsubErr != null) { err = unsubErr; break; }
err = MqttPacketHandlers.ProcessUnsubs(c, unsubPi, unsubFilters!);
break;
case MqttPacket.Ping:
HandlePingReq(c);
break;
case MqttPacket.Connect:
if (connected)
{
err = new InvalidOperationException("second CONNECT packet not allowed");
break;
}
var (rc, cp, parseErr) = MqttPacketHandlers.ParseConnect(r);
if (parseErr != null)
{
// Send CONNACK with error code if we have one, then close.
if (rc != MqttConnAckRc.Accepted)
MqttPacketHandlers.EnqueueConnAck(c, rc, false);
err = parseErr;
break;
}
if (rc != MqttConnAckRc.Accepted)
{
MqttPacketHandlers.EnqueueConnAck(c, rc, false);
err = new InvalidOperationException($"CONNECT rejected with code 0x{rc:X2}");
break;
}
err = MqttPacketHandlers.ProcessConnect(c, cp!);
if (err == null)
connected = true;
break;
case MqttPacket.Disconnect:
MqttPacketHandlers.HandleDisconnect(c);
return null; // Connection closed, exit parse loop.
default:
err = new InvalidOperationException($"unknown MQTT packet type: 0x{pt:X2}");
break;
}
}
return err;
}
/// <summary>
/// Handles PINGREQ by enqueueing a PINGRESP packet.
/// Mirrors Go <c>mqttEnqueuePingResp()</c>.
/// </summary>
private static void HandlePingReq(ClientConnection c)
{
lock (c)
{
c.EnqueueProto(PingRespPacket);
}
}
}
@@ -128,4 +128,21 @@ internal sealed class MqttReader
Position += 2;
return value;
}
/// <summary>
/// Reads exactly <paramref name="count"/> raw bytes without a length prefix.
/// Used for PUBLISH payloads where the length is known from the remaining length field.
/// </summary>
public byte[] ReadSlice(int count, string field)
{
if (count == 0)
return [];
if (Position + count > _buffer.Length)
throw new InvalidOperationException($"error reading {field}: {nameof(EndOfStreamException)}");
var start = Position;
Position += count;
return _buffer[start..Position];
}
}
@@ -362,4 +362,22 @@ public sealed partial class NatsServer
/// Wraps the synchronous <see cref="Shutdown"/> method.
/// </summary>
internal Task ShutdownAsync() => Task.Run(Shutdown);
// =========================================================================
// CheckAuthForWarnings (feature 3049 — Start parity)
// =========================================================================
/// <summary>
/// Checks for insecure auth configurations and logs warnings.
/// Mirrors Go <c>Server.checkAuthforWarnings()</c> in server/server.go.
/// Stub — full implementation deferred.
/// </summary>
internal void CheckAuthForWarnings()
{
// No-op stub. Go logs warnings about:
// - Password auth without TLS
// - Token auth without TLS
// - NKey auth without TLS
// These are informational warnings only.
}
}
@@ -19,6 +19,7 @@ using System.Text.RegularExpressions;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server;
@@ -968,10 +969,10 @@ public sealed partial class NatsServer
// =========================================================================
/// <summary>
/// Starts the server (non-blocking). Writes startup log lines and begins accept loops.
/// Full implementation requires sessions 10-23 (gateway, websocket, leafnode, routes, etc.).
/// This stub handles the bootstrap sequence up to the subsystems not yet ported.
/// Mirrors Go <c>Server.Start</c>.
/// Starts the server (non-blocking). Writes startup log lines, starts all
/// subsystems (monitoring, JetStream, gateways, websocket, leafnodes, routes,
/// MQTT), then begins the client accept loop.
/// Mirrors Go <c>Server.Start</c> in server.go:22632575.
/// </summary>
public void Start()
{
@@ -991,8 +992,13 @@ public sealed partial class NatsServer
if (!string.IsNullOrEmpty(clusterName))
Noticef(" Cluster: {0}", clusterName);
Noticef(" Name: {0}", _info.Name);
if (opts.JetStream)
Noticef(" Node: {0}", GetHash(_info.Name));
Noticef(" ID: {0}", _info.Id);
// Check for insecure configurations.
CheckAuthForWarnings();
// Avoid RACE between Start() and Shutdown().
Interlocked.Exchange(ref _running, 1);
@@ -1002,6 +1008,34 @@ public sealed partial class NatsServer
lock (_grMu) { _grRunning = true; }
StartRateLimitLogExpiration();
// Pprof http endpoint for the profiler.
if (opts.ProfPort != 0)
{
StartProfiler();
}
if (!string.IsNullOrEmpty(opts.ConfigFile))
{
Noticef("Using configuration file: {0}", opts.ConfigFile);
}
var hasOperators = opts.TrustedOperators.Count > 0;
if (hasOperators)
{
Noticef("Trusted Operators");
}
if (hasOperators && string.IsNullOrEmpty(opts.SystemAccount))
{
Warnf("Trusted Operators should utilize a System Account");
}
if (opts.MaxPayload > ServerConstants.MaxPayloadMaxSize)
{
Warnf("Maximum payloads over {0} are generally discouraged and could lead to poor performance",
ServerConstants.MaxPayloadMaxSize);
}
// Log PID.
if (!string.IsNullOrEmpty(opts.PidFile))
{
@@ -1013,7 +1047,7 @@ public sealed partial class NatsServer
}
}
// System account setup.
// Setup system account which will start the eventing stack.
if (!string.IsNullOrEmpty(opts.SystemAccount))
{
var saErr = SetSystemAccount(opts.SystemAccount);
@@ -1028,11 +1062,143 @@ public sealed partial class NatsServer
SetDefaultSystemAccount();
}
// Start monitoring before enabling other subsystems.
var monErr = StartMonitoring();
if (monErr != null)
{
Fatalf("Can't start monitoring: {0}", monErr);
return;
}
// Start up resolver machinery.
var ar = AccountResolver();
if (ar != null)
{
try
{
ar.Start(this);
}
catch (Exception arEx)
{
Fatalf("Could not start resolver: {0}", arEx);
return;
}
}
// Start expiration of mapped GW replies.
StartGWReplyMapExpiration();
// Check if JetStream has been enabled.
if (opts.JetStream)
{
// Make sure someone is not trying to enable on the system account.
var sa = SystemAccount();
if (sa != null && (sa.JetStreamLimits?.Count ?? 0) > 0)
{
Fatalf("Not allowed to enable JetStream on the system account");
return;
}
var cfg = new JetStreamConfig
{
StoreDir = opts.StoreDir,
SyncInterval = opts.SyncInterval,
SyncAlways = opts.SyncAlways,
Strict = !opts.NoJetStreamStrict,
MaxMemory = opts.JetStreamMaxMemory,
MaxStore = opts.JetStreamMaxStore,
Domain = opts.JetStreamDomain,
CompressOK = true,
UniqueTag = opts.JetStreamUniqueTag,
};
var jsErr = EnableJetStream(cfg);
if (jsErr != null)
{
Fatalf("Can't start JetStream: {0}", jsErr);
return;
}
}
// Delayed API response handling — start regardless of JetStream config.
StartDelayedApiResponder();
// Start OCSP Stapling monitoring.
StartOCSPMonitoring();
// Configure OCSP Response Cache.
StartOCSPResponseCache();
// Signal startup complete.
// Start up gateway if needed.
if (opts.Gateway.Port != 0)
{
var gwErr = StartGateways();
if (gwErr != null)
{
Fatalf("Can't start gateways: {0}", gwErr);
return;
}
}
// Start websocket server if needed.
if (opts.Websocket.Port != 0)
{
StartWebsocketServer();
}
// Start up listen if we want to accept leaf node connections.
if (opts.LeafNode.Port != 0)
{
var lnErr = StartLeafNodeAcceptLoop();
if (lnErr != null)
{
Fatalf("Can't start leaf node listener: {0}", lnErr);
return;
}
}
// Solicit remote servers for leaf node connections.
if (opts.LeafNode.Remotes.Count > 0)
{
SolicitLeafNodeRemotes(opts.LeafNode.Remotes);
}
// The Routing routine needs to wait for the client listen
// port to be opened and potential ephemeral port selected.
var clientListenReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// MQTT
if (opts.Mqtt.Port != 0)
{
this.StartMqtt();
}
// Start up routing as well if needed.
if (opts.Cluster.Port != 0)
{
StartGoRoutine(() =>
{
StartRouting();
});
}
if (!string.IsNullOrEmpty(opts.PortsFileDir))
{
LogPorts();
}
// We've finished starting up.
_startupComplete.TrySetResult();
// Wait for clients.
if (!opts.DontListen)
{
AcceptLoop(clientListenReady);
}
// Bring OCSP Response cache online after accept loop started.
StartOCSPResponseCache();
Noticef("Server is ready");
}
@@ -571,4 +571,23 @@ public sealed partial class NatsServer
_jetStream == null ? null : new JetStreamEngine(_jetStream);
internal JetStream? GetJetStreamState() => _jetStream;
// =========================================================================
// Delayed API responder (feature 3049 — Start parity)
// =========================================================================
/// <summary>
/// Starts the delayed JetStream API response handler goroutine.
/// Started regardless of JetStream being enabled (can be enabled via config reload).
/// Mirrors Go <c>Server.delayedAPIResponder()</c> in server/jetstream_api.go.
/// Stub — full implementation deferred.
/// </summary>
internal void StartDelayedApiResponder()
{
StartGoRoutine(() =>
{
// No-op: exits when quit is signaled.
_quitCts.Token.WaitHandle.WaitOne();
});
}
}
@@ -79,9 +79,11 @@ public sealed partial class NatsServer
_listener = null;
}
doneExpected += CloseWebsocketServer();
if (_gateway.Enabled)
if (_mqttListener != null)
{
// mqtt listener managed by session 22
doneExpected++;
_mqttListener.Stop();
_mqttListener = null;
}
if (_leafNodeListener != null)
{
@@ -127,8 +129,9 @@ public sealed partial class NatsServer
}
// Wait for accept loops to exit.
// Must use .AsTask() because ValueTask cannot be synchronously blocked on.
for (int i = 0; i < doneExpected; i++)
_done.Reader.ReadAsync().GetAwaiter().GetResult();
_done.Reader.ReadAsync().AsTask().GetAwaiter().GetResult();
// Wait for all goroutines.
_grWg.Wait();
@@ -504,7 +507,7 @@ public sealed partial class NatsServer
leafOk = opts.LeafNode.Port == 0 || _leafNodeListener != null;
leafErr = _leafNodeListenerErr;
wsOk = opts.Websocket.Port == 0 || _websocket.Listener != null;
mqttOk = opts.Mqtt.Port == 0;
mqttOk = opts.Mqtt.Port == 0 || _mqttListener != null;
_mu.ExitReadLock();
checks["server"] = (serverOk, serverErr);
@@ -655,8 +658,9 @@ public sealed partial class NatsServer
ShutdownRaftNodes();
// Wait for accept loops.
// Must use .AsTask() because ValueTask cannot be synchronously blocked on.
for (int i = 0; i < expected; i++)
_ldmCh.Reader.ReadAsync().GetAwaiter().GetResult();
_ldmCh.Reader.ReadAsync().AsTask().GetAwaiter().GetResult();
_mu.EnterWriteLock();
var clients = new List<ClientConnection>(_clients.Values);
@@ -0,0 +1,183 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go in the NATS server Go source.
using System.Net;
using System.Net.Sockets;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server;
public sealed partial class NatsServer
{
// =========================================================================
// MQTT Listener and Client Creation
// Mirrors Go startMQTT() and createMQTTClient() in server/mqtt.go.
// =========================================================================
/// <summary>
/// Starts the MQTT TCP listener and accept loop.
/// Called from <see cref="MqttServerExtensions.StartMqtt"/> extension method.
/// Mirrors Go <c>(*Server).startMQTT()</c>.
/// </summary>
internal void StartMqttListener()
{
if (IsShuttingDown()) return;
var opts = GetOpts();
var port = opts.Mqtt.Port;
var host = opts.Mqtt.Host;
if (string.IsNullOrEmpty(host))
host = "0.0.0.0";
// RandomPort (-1) means ephemeral — pass 0 to TcpListener.
var listenPort = port < 0 ? 0 : port;
TcpListener listener;
try
{
var addr = host == "0.0.0.0" || host == "::"
? (host == "::" ? IPAddress.IPv6Any : IPAddress.Any)
: IPAddress.Parse(host);
listener = new TcpListener(addr, listenPort);
listener.Start();
}
catch (Exception ex)
{
Fatalf("Can't listen for MQTT client connections: {0}", ex.Message);
return;
}
var ep = (IPEndPoint)listener.LocalEndpoint!;
var scheme = opts.Mqtt.TlsConfig != null ? "tls" : "mqtt";
Noticef("Listening for MQTT client connections on {0}://{1}:{2}", scheme, host, ep.Port);
// Write back resolved port if ephemeral (0 or -1).
if (port <= 0)
opts.Mqtt.Port = ep.Port;
_mu.EnterWriteLock();
_mqttListener = listener;
_mu.ExitWriteLock();
// Start accept loop in a goroutine.
_ = Task.Run(() =>
{
AcceptConnections(listener, "Mqtt", tc => CreateMqttClient(tc));
});
}
/// <summary>
/// Creates and registers a new MQTT client connection from the accepted TCP client.
/// Unlike NATS clients, MQTT clients do not receive an INFO line — the MQTT CONNECT
/// handshake is initiated by the client.
/// Mirrors Go <c>createMQTTClient()</c> in server/mqtt.go.
/// </summary>
private ClientConnection? CreateMqttClient(TcpClient tc)
{
var opts = GetOpts();
var now = DateTime.UtcNow;
var nc = tc.GetStream();
var c = new ClientConnection(ClientKind.Client, this, nc)
{
Start = now,
Last = now,
Opts = ClientOptions.Default,
Headers = true, // MQTT always uses NATS headers for QoS metadata
};
c.InitMqtt(new MqttHandler
{
RejectQoS2Pub = opts.Mqtt.RejectQoS2Pub,
DowngradeQoS2Sub = opts.Mqtt.DowngradeQoS2Sub,
});
// Register with the global account.
var globalAcc = GlobalAccount();
if (globalAcc != null)
{
try { c.RegisterWithAccount(globalAcc); }
catch (Exception ex)
{
c.ReportErrRegisterAccount(globalAcc, ex);
return null;
}
}
// Register with the server — no INFO sent for MQTT clients.
_mu.EnterWriteLock();
if (!IsRunning() || _ldm)
{
if (IsShuttingDown())
nc.Close();
_mu.ExitWriteLock();
return c;
}
_totalClients++;
if (opts.MaxConn > 0 && _clients.Count >= opts.MaxConn)
{
_mu.ExitWriteLock();
c.MaxConnExceeded();
return null;
}
_clients[c.Cid] = c;
_mu.ExitWriteLock();
lock (c)
{
if (c.IsClosed())
{
c.CloseConnection(ClosedState.WriteError);
return null;
}
c.InitClient();
c.Debugf("MQTT client connection created");
// Set auth timer if authentication is required.
if (_info.AuthRequired)
{
c.Flags |= ClientFlags.ExpectConnect;
c.SetAuthTimer(TimeSpan.FromSeconds(
opts.Mqtt.AuthTimeout > 0 ? opts.Mqtt.AuthTimeout : opts.AuthTimeout));
}
c.SetPingTimer();
// Start read/write loops.
StartGoRoutine(() => c.ReadLoop(null));
StartGoRoutine(() => c.WriteLoop());
}
return c;
}
/// <summary>
/// Returns the MQTT listener address, or null if not listening.
/// Mirrors Go <c>(*Server).MQTTAddr()</c>.
/// </summary>
public IPEndPoint? MqttAddr()
{
_mu.EnterReadLock();
try { return _mqttListener?.LocalEndpoint as IPEndPoint; }
finally { _mu.ExitReadLock(); }
}
}
@@ -121,6 +121,9 @@ public sealed partial class NatsServer : INatsServer
private System.Net.Sockets.TcpListener? _leafNodeListener;
private Exception? _leafNodeListenerErr;
// MQTT listener
private System.Net.Sockets.TcpListener? _mqttListener;
// Profiling listener
private System.Net.Sockets.TcpListener? _profiler;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
// Copyright 2013-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Net;
using System.Text;
using Shouldly;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
/// <summary>
/// End-to-end server boot tests that validate the full
/// Start() → AcceptLoop → client connection lifecycle.
/// </summary>
[Trait("Category", "Integration")]
public sealed class ServerBootTests : IDisposable
{
private readonly string _storeDir;
public ServerBootTests()
{
_storeDir = Path.Combine(Path.GetTempPath(), $"natsnet-test-{Guid.NewGuid():N}");
}
public void Dispose()
{
try { Directory.Delete(_storeDir, true); } catch { }
}
/// <summary>
/// Validates that a server can boot, accept a TCP connection, and send
/// a NATS protocol INFO line. This proves the full Start() → AcceptLoop
/// → CreateClient pipeline works end-to-end.
/// </summary>
/// <summary>
/// Validates that a server can boot, bind a port, and accept a TCP connection.
/// Note: GenerateClientInfoJSON is currently a stub (returns empty), so we only
/// verify the TCP handshake succeeds — not the INFO protocol line.
/// </summary>
[Fact]
public async Task ServerBoot_AcceptsTcpConnection_ShouldSucceed()
{
// Arrange — create server with ephemeral port
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0, // ephemeral
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull("NewServer should succeed");
server.ShouldNotBeNull();
try
{
// Act — start the server
server!.Start();
// Get the actual bound port
var addr = server.Addr() as IPEndPoint;
addr.ShouldNotBeNull("Server should have a listener address after Start()");
addr!.Port.ShouldBeGreaterThan(0);
// Connect a raw TCP client — proves AcceptLoop is working
using var tcp = new System.Net.Sockets.TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(addr.Address, addr.Port, cts.Token);
tcp.Connected.ShouldBeTrue();
// Verify the server registered the client
await Task.Delay(100); // Give CreateClient a moment to run
server.NumClients().ShouldBeGreaterThan(0);
}
finally
{
server!.Shutdown();
}
}
/// <summary>
/// Validates that a server can boot with an MQTT listener on an ephemeral port,
/// accept a TCP connection on the MQTT port, and register it as a client.
/// </summary>
[Fact]
public async Task MqttBoot_AcceptsConnection_ShouldSucceed()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
Mqtt = { Port = -1, Host = "127.0.0.1" },
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull("NewServer should succeed");
server.ShouldNotBeNull();
try
{
server!.Start();
// Verify MQTT listener is up
var mqttAddr = server.MqttAddr();
mqttAddr.ShouldNotBeNull("MqttAddr should return the MQTT listener address");
mqttAddr!.Port.ShouldBeGreaterThan(0);
// ReadyForConnections should include MQTT
server.ReadyForConnections(TimeSpan.FromSeconds(5)).ShouldBeTrue();
// Connect a raw TCP client to the MQTT port
using var tcp = new System.Net.Sockets.TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(mqttAddr.Address, mqttAddr.Port, cts.Token);
tcp.Connected.ShouldBeTrue();
// Give CreateMqttClient a moment to register
await Task.Delay(100);
server.NumClients().ShouldBeGreaterThan(0);
}
finally
{
server!.Shutdown();
}
}
/// <summary>
/// Validates that an MQTT listener starts and shuts down cleanly.
/// </summary>
[Fact]
public void MqttBoot_StartAndShutdown_ShouldSucceed()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
DontListen = true,
Mqtt = { Port = -1, Host = "127.0.0.1" },
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
server!.Start();
server.Running().ShouldBeTrue();
server.MqttAddr().ShouldNotBeNull();
server.Shutdown();
server.Running().ShouldBeFalse();
}
/// <summary>
/// End-to-end: TCP connect → send MQTT CONNECT → receive CONNACK.
/// Validates the full MQTT handshake over the wire.
/// </summary>
[Fact]
public async Task MqttBoot_ConnectHandshake_ShouldReceiveConnAck()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
Mqtt = { Port = -1, Host = "127.0.0.1" },
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
try
{
server!.Start();
var mqttAddr = server.MqttAddr();
mqttAddr.ShouldNotBeNull();
using var tcp = new System.Net.Sockets.TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(mqttAddr!.Address, mqttAddr.Port, cts.Token);
var stream = tcp.GetStream();
// Build and send MQTT CONNECT packet.
var connectPacket = BuildMqttConnectPacket("integration-test");
await stream.WriteAsync(connectPacket, cts.Token);
await stream.FlushAsync(cts.Token);
// Read CONNACK response (4 bytes).
var response = new byte[4];
var totalRead = 0;
while (totalRead < 4)
{
var n = await stream.ReadAsync(response.AsMemory(totalRead, 4 - totalRead), cts.Token);
if (n == 0) break;
totalRead += n;
}
totalRead.ShouldBe(4, "Should receive 4-byte CONNACK");
response[0].ShouldBe((byte)0x20, "Packet type should be CONNACK");
response[1].ShouldBe((byte)0x02, "Remaining length should be 2");
response[3].ShouldBe((byte)0x00, "Return code should be Accepted (0)");
}
finally
{
server!.Shutdown();
}
}
/// <summary>Builds a minimal MQTT CONNECT packet.</summary>
private static byte[] BuildMqttConnectPacket(string clientId)
{
var payload = new List<byte>();
// Protocol name "MQTT"
payload.AddRange(new byte[] { 0x00, 0x04 });
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("MQTT"));
// Protocol level 4
payload.Add(0x04);
// Flags: clean session
payload.Add(0x02);
// Keep alive: 60s
payload.AddRange(new byte[] { 0x00, 0x3C });
// Client ID
var cidBytes = System.Text.Encoding.UTF8.GetBytes(clientId);
payload.Add((byte)(cidBytes.Length >> 8));
payload.Add((byte)(cidBytes.Length & 0xFF));
payload.AddRange(cidBytes);
// Fixed header
var result = new List<byte>();
result.Add(0x10); // CONNECT type
var remLen = payload.Count;
do
{
var b = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) b |= 0x80;
result.Add(b);
} while (remLen > 0);
result.AddRange(payload);
return result.ToArray();
}
/// <summary>
/// End-to-end: CONNECT → SUBSCRIBE → verify SUBACK over the wire.
/// </summary>
[Fact]
public async Task MqttBoot_SubscribeHandshake_ShouldReceiveSubAck()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
Mqtt = { Port = -1, Host = "127.0.0.1" },
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
try
{
server!.Start();
var mqttAddr = server.MqttAddr();
mqttAddr.ShouldNotBeNull();
using var tcp = new System.Net.Sockets.TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(mqttAddr!.Address, mqttAddr.Port, cts.Token);
var stream = tcp.GetStream();
// 1. CONNECT → CONNACK
var connectPacket = BuildMqttConnectPacket("sub-test");
await stream.WriteAsync(connectPacket, cts.Token);
await stream.FlushAsync(cts.Token);
var connack = new byte[4];
await ReadExactAsync(stream, connack, cts.Token);
connack[0].ShouldBe((byte)0x20); // CONNACK
connack[3].ShouldBe((byte)0x00); // Accepted
// 2. SUBSCRIBE to "test/sub" QoS 0 → SUBACK
var subscribePacket = BuildMqttSubscribePacket(packetId: 1, topic: "test/sub", qos: 0);
await stream.WriteAsync(subscribePacket, cts.Token);
await stream.FlushAsync(cts.Token);
// SUBACK: [0x90] [0x03] [PI high] [PI low] [granted QoS]
var suback = new byte[5];
await ReadExactAsync(stream, suback, cts.Token);
suback[0].ShouldBe((byte)0x90); // SUBACK
suback[1].ShouldBe((byte)0x03); // remaining length = 3
suback[2].ShouldBe((byte)0x00); // PI high
suback[3].ShouldBe((byte)0x01); // PI low = 1
suback[4].ShouldBe((byte)0x00); // granted QoS 0
}
finally
{
server!.Shutdown();
}
}
/// <summary>Builds a minimal MQTT SUBSCRIBE packet.</summary>
private static byte[] BuildMqttSubscribePacket(ushort packetId, string topic, byte qos)
{
var topicBytes = System.Text.Encoding.UTF8.GetBytes(topic);
var payload = new List<byte>();
payload.Add((byte)(packetId >> 8));
payload.Add((byte)(packetId & 0xFF));
payload.Add((byte)(topicBytes.Length >> 8));
payload.Add((byte)(topicBytes.Length & 0xFF));
payload.AddRange(topicBytes);
payload.Add(qos);
var result = new List<byte>();
result.Add(0x82); // SUBSCRIBE + flags 0x02
var remLen = payload.Count;
do
{
var b = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) b |= 0x80;
result.Add(b);
} while (remLen > 0);
result.AddRange(payload);
return result.ToArray();
}
/// <summary>Reads exactly <paramref name="buffer"/>.Length bytes from the stream.</summary>
private static async Task ReadExactAsync(System.Net.Sockets.NetworkStream stream, byte[] buffer, CancellationToken ct)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var n = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), ct);
if (n == 0) break;
totalRead += n;
}
totalRead.ShouldBe(buffer.Length);
}
/// <summary>
/// Validates that Shutdown() after Start() completes cleanly.
/// Uses DontListen to skip TCP binding — tests lifecycle only.
/// </summary>
[Fact]
public void ServerBoot_StartAndShutdown_ShouldSucceed()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
DontListen = true,
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
server!.Start();
server.Running().ShouldBeTrue();
server.Shutdown();
server.Running().ShouldBeFalse();
}
}
@@ -8,20 +8,7 @@ namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog;
public sealed partial class MqttHandlerTests
{
[Fact] // T:2225
public void MQTTLeafnodeWithoutJSToClusterWithJSNoSharedSysAcc_ShouldSucceed()
{
var cluster = new JetStreamCluster();
var streamAssignment = new StreamAssignment { Config = new StreamConfig { Name = "MQTT" } };
cluster.TrackInflightStreamProposal("SYS", streamAssignment, deleted: false);
cluster.TrackInflightStreamProposal("SYS", streamAssignment, deleted: true);
cluster.InflightStreams["SYS"]["MQTT"].Deleted.ShouldBeTrue();
cluster.RemoveInflightStreamProposal("SYS", "MQTT");
cluster.RemoveInflightStreamProposal("SYS", "MQTT");
cluster.InflightStreams.ContainsKey("SYS").ShouldBeFalse();
}
// T:2225 moved to IntegrationTests/Mqtt/MqttClusterTests.cs
[Fact] // T:2178
public void MQTTTLS_ShouldSucceed()
@@ -0,0 +1,450 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
using System.Text;
using Shouldly;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server.Tests.Mqtt;
/// <summary>
/// Unit tests for MQTT CONNECT/CONNACK/DISCONNECT packet handling.
/// </summary>
public sealed class MqttConnectTests
{
/// <summary>
/// Builds a minimal valid MQTT CONNECT packet.
/// </summary>
private static byte[] BuildConnectPacket(
string clientId = "test-client",
bool cleanSession = true,
ushort keepAlive = 60,
string? willTopic = null,
byte[]? willMessage = null,
byte willQos = 0,
bool willRetain = false,
string? username = null,
string? password = null)
{
var payload = new List<byte>();
// Variable header.
// Protocol name "MQTT".
var protoName = Encoding.UTF8.GetBytes("MQTT");
payload.Add((byte)(protoName.Length >> 8));
payload.Add((byte)(protoName.Length & 0xFF));
payload.AddRange(protoName);
// Protocol level.
payload.Add(0x04);
// Connect flags.
byte flags = 0;
if (cleanSession) flags |= MqttConnectFlag.CleanSession;
if (willTopic != null)
{
flags |= MqttConnectFlag.WillFlag;
flags |= (byte)((willQos & 0x03) << 3);
if (willRetain) flags |= MqttConnectFlag.WillRetain;
}
if (username != null) flags |= MqttConnectFlag.UsernameFlag;
if (password != null) flags |= MqttConnectFlag.PasswordFlag;
payload.Add(flags);
// Keep alive.
payload.Add((byte)(keepAlive >> 8));
payload.Add((byte)(keepAlive & 0xFF));
// Client ID.
var cidBytes = Encoding.UTF8.GetBytes(clientId);
payload.Add((byte)(cidBytes.Length >> 8));
payload.Add((byte)(cidBytes.Length & 0xFF));
payload.AddRange(cidBytes);
// Will topic + message.
if (willTopic != null)
{
var topicBytes = Encoding.UTF8.GetBytes(willTopic);
payload.Add((byte)(topicBytes.Length >> 8));
payload.Add((byte)(topicBytes.Length & 0xFF));
payload.AddRange(topicBytes);
var msg = willMessage ?? [];
payload.Add((byte)(msg.Length >> 8));
payload.Add((byte)(msg.Length & 0xFF));
payload.AddRange(msg);
}
// Username.
if (username != null)
{
var userBytes = Encoding.UTF8.GetBytes(username);
payload.Add((byte)(userBytes.Length >> 8));
payload.Add((byte)(userBytes.Length & 0xFF));
payload.AddRange(userBytes);
}
// Password.
if (password != null)
{
var passBytes = Encoding.UTF8.GetBytes(password);
payload.Add((byte)(passBytes.Length >> 8));
payload.Add((byte)(passBytes.Length & 0xFF));
payload.AddRange(passBytes);
}
// Build full packet: type byte + remaining length + payload.
var result = new List<byte>();
result.Add(MqttPacket.Connect);
// Encode remaining length.
var remLen = payload.Count;
do
{
var encoded = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) encoded |= 0x80;
result.Add(encoded);
} while (remLen > 0);
result.AddRange(payload);
return result.ToArray();
}
private static ClientConnection CreateMqttClient()
{
var ms = new MemoryStream();
var c = new ClientConnection(ClientKind.Client, nc: ms);
c.InitMqtt(new MqttHandler());
return c;
}
// =========================================================================
// ParseConnect tests
// =========================================================================
[Fact]
public void ParseConnect_ValidMinimal_ShouldSucceed()
{
var buf = BuildConnectPacket();
var r = new MqttReader();
// Skip the fixed header (type + remaining length) — parser handles that.
// For direct ParseConnect testing, we feed only the variable header + payload.
r.Reset(buf[2..]); // Skip type byte and 1-byte remaining length.
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
err.ShouldBeNull();
rc.ShouldBe(MqttConnAckRc.Accepted);
cp.ShouldNotBeNull();
cp!.ClientId.ShouldBe("test-client");
cp.CleanSession.ShouldBeTrue();
cp.KeepAlive.ShouldBe((ushort)60);
cp.Will.ShouldBeNull();
cp.Username.ShouldBeEmpty();
cp.Password.ShouldBeNull();
}
[Fact]
public void ParseConnect_WithWill_ShouldParseCorrectly()
{
var buf = BuildConnectPacket(
willTopic: "test/will",
willMessage: Encoding.UTF8.GetBytes("goodbye"),
willQos: 1,
willRetain: true);
// Find remaining length to skip header correctly.
int headerLen = 1; // type byte
int remLen = 0;
int mult = 1;
for (int i = 1; i < buf.Length; i++)
{
remLen += (buf[i] & 0x7F) * mult;
headerLen++;
if ((buf[i] & 0x80) == 0) break;
mult *= 128;
}
var r = new MqttReader();
r.Reset(buf[headerLen..]);
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
err.ShouldBeNull();
rc.ShouldBe(MqttConnAckRc.Accepted);
cp!.Will.ShouldNotBeNull();
cp.Will!.Topic.ShouldBe("test/will");
cp.Will.Subject.ShouldNotBeEmpty(); // NATS-converted subject
cp.Will.Msg.ShouldNotBeNull();
Encoding.UTF8.GetString(cp.Will.Msg!).ShouldBe("goodbye");
cp.Will.Qos.ShouldBe((byte)1);
cp.Will.Retain.ShouldBeTrue();
}
[Fact]
public void ParseConnect_WithAuth_ShouldParseCorrectly()
{
var buf = BuildConnectPacket(username: "user1", password: "pass1");
var r = new MqttReader();
r.Reset(buf[2..]);
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
err.ShouldBeNull();
rc.ShouldBe(MqttConnAckRc.Accepted);
cp!.Username.ShouldBe("user1");
cp.Password.ShouldNotBeNull();
Encoding.UTF8.GetString(cp.Password!).ShouldBe("pass1");
}
[Fact]
public void ParseConnect_WrongProtocolName_ShouldRejectWithCode()
{
// Build a packet with wrong protocol name.
var buf = new List<byte>();
var wrong = Encoding.UTF8.GetBytes("MQIsdp");
buf.Add((byte)(wrong.Length >> 8));
buf.Add((byte)(wrong.Length & 0xFF));
buf.AddRange(wrong);
buf.Add(0x03); // level
buf.Add(0x02); // clean session
buf.AddRange(new byte[] { 0x00, 0x3C }); // keepalive=60
buf.AddRange(new byte[] { 0x00, 0x02, 0x41, 0x42 }); // clientId="AB"
var r = new MqttReader();
r.Reset(buf.ToArray());
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
rc.ShouldBe(MqttConnAckRc.UnacceptableProtocol);
err.ShouldNotBeNull();
}
[Fact]
public void ParseConnect_EmptyClientIdWithoutCleanSession_ShouldReject()
{
var buf = BuildConnectPacket(clientId: "", cleanSession: false);
var r = new MqttReader();
r.Reset(buf[2..]);
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
rc.ShouldBe(MqttConnAckRc.IdentifierRejected);
err.ShouldNotBeNull();
}
[Fact]
public void ParseConnect_EmptyClientIdWithCleanSession_ShouldAutoGenerate()
{
var buf = BuildConnectPacket(clientId: "", cleanSession: true);
var r = new MqttReader();
r.Reset(buf[2..]);
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
err.ShouldBeNull();
rc.ShouldBe(MqttConnAckRc.Accepted);
cp!.ClientId.ShouldNotBeEmpty(); // Auto-generated
cp.ClientId.Length.ShouldBe(32); // GUID "N" format
}
[Fact]
public void ParseConnect_ReservedBitSet_ShouldError()
{
// Build manually with reserved bit set.
var payload = new List<byte>();
var proto = Encoding.UTF8.GetBytes("MQTT");
payload.Add(0); payload.Add(4);
payload.AddRange(proto);
payload.Add(0x04); // level
payload.Add(0x03); // clean session + reserved bit!
payload.AddRange(new byte[] { 0x00, 0x00 }); // keepalive
payload.AddRange(new byte[] { 0x00, 0x02, 0x41, 0x42 }); // clientId
var r = new MqttReader();
r.Reset(payload.ToArray());
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
err.ShouldNotBeNull();
err.Message.ShouldContain("reserved bit");
}
// =========================================================================
// ProcessConnect + CONNACK tests
// =========================================================================
[Fact]
public void ProcessConnect_ShouldSetFlagsAndSendConnAck()
{
var c = CreateMqttClient();
var cp = new MqttConnectProto
{
ClientId = "test-123",
CleanSession = true,
KeepAlive = 30,
};
var err = MqttPacketHandlers.ProcessConnect(c, cp);
err.ShouldBeNull();
// Verify state.
c.Mqtt!.ClientId.ShouldBe("test-123");
c.Mqtt.CleanSession.ShouldBeTrue();
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
// Verify CONNACK was written.
var ms = (MemoryStream)typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(c)!;
var data = ms.ToArray();
data.Length.ShouldBe(4);
data[0].ShouldBe(MqttPacket.ConnectAck);
data[1].ShouldBe((byte)0x02);
data[2].ShouldBe((byte)0x00); // No session present
data[3].ShouldBe(MqttConnAckRc.Accepted);
}
// =========================================================================
// Full CONNECT via parser integration
// =========================================================================
[Fact]
public void Parser_ConnectPacket_ShouldParseAndSendConnAck()
{
var c = CreateMqttClient();
var buf = BuildConnectPacket(clientId: "mqtt-parser-test");
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Verify connected.
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
c.Mqtt!.ClientId.ShouldBe("mqtt-parser-test");
// Verify CONNACK written.
var ms = (MemoryStream)typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(c)!;
var data = ms.ToArray();
data.Length.ShouldBe(4);
data[0].ShouldBe(MqttPacket.ConnectAck);
data[3].ShouldBe(MqttConnAckRc.Accepted);
}
[Fact]
public void Parser_ConnectThenPing_ShouldSucceed()
{
var c = CreateMqttClient();
var connectBuf = BuildConnectPacket();
var pingBuf = new byte[] { MqttPacket.Ping, 0x00 };
// Concatenate CONNECT + PING into one buffer.
var buf = new byte[connectBuf.Length + pingBuf.Length];
Buffer.BlockCopy(connectBuf, 0, buf, 0, connectBuf.Length);
Buffer.BlockCopy(pingBuf, 0, buf, connectBuf.Length, pingBuf.Length);
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Verify CONNACK + PINGRESP written.
var ms = (MemoryStream)typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(c)!;
var data = ms.ToArray();
data.Length.ShouldBe(6); // 4 (CONNACK) + 2 (PINGRESP)
data[0].ShouldBe(MqttPacket.ConnectAck);
data[4].ShouldBe(MqttPacket.PingResp);
}
// =========================================================================
// DISCONNECT tests
// =========================================================================
[Fact]
public void Parser_Disconnect_ShouldClearWillAndClose()
{
var c = CreateMqttClient();
// First, process a CONNECT with a will.
var connectBuf = BuildConnectPacket(
willTopic: "test/will",
willMessage: Encoding.UTF8.GetBytes("bye"));
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
err.ShouldBeNull();
c.Mqtt!.Will.ShouldNotBeNull();
// Now send DISCONNECT.
var disconnectBuf = new byte[] { MqttPacket.Disconnect, 0x00 };
err = MqttParser.Parse(c, disconnectBuf, disconnectBuf.Length);
err.ShouldBeNull();
// Will should be cleared.
c.Mqtt.Will.ShouldBeNull();
}
// =========================================================================
// Will message delivery tests
// =========================================================================
[Fact]
public void DeliverWill_AbnormalClose_ShouldDeliverAndClearWill()
{
var c = CreateMqttClient();
// CONNECT with a will.
var connectBuf = BuildConnectPacket(
willTopic: "test/will",
willMessage: Encoding.UTF8.GetBytes("goodbye"));
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
err.ShouldBeNull();
c.Mqtt!.Will.ShouldNotBeNull();
// Simulate abnormal close (not DISCONNECT) — will should be delivered.
MqttPacketHandlers.DeliverWill(c);
// Will should be cleared after delivery.
c.Mqtt.Will.ShouldBeNull();
}
[Fact]
public void DeliverWill_AfterDisconnect_ShouldNotDeliver()
{
var c = CreateMqttClient();
// CONNECT with a will.
var connectBuf = BuildConnectPacket(
willTopic: "test/will",
willMessage: Encoding.UTF8.GetBytes("goodbye"));
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
err.ShouldBeNull();
// Clean DISCONNECT — clears the will.
var disconnectBuf = new byte[] { MqttPacket.Disconnect, 0x00 };
err = MqttParser.Parse(c, disconnectBuf, disconnectBuf.Length);
err.ShouldBeNull();
// Will was already cleared by DISCONNECT, so DeliverWill should be a no-op.
MqttPacketHandlers.DeliverWill(c);
c.Mqtt!.Will.ShouldBeNull();
}
[Fact]
public void DeliverWill_NoWill_ShouldBeNoOp()
{
var c = CreateMqttClient();
// CONNECT without a will.
var connectBuf = BuildConnectPacket();
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
err.ShouldBeNull();
c.Mqtt!.Will.ShouldBeNull();
// DeliverWill with no will configured — should be a no-op.
MqttPacketHandlers.DeliverWill(c);
c.Mqtt.Will.ShouldBeNull();
}
}
@@ -0,0 +1,303 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
using Shouldly;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server.Tests.Mqtt;
/// <summary>
/// Unit tests for <see cref="MqttParser"/> — validates packet type extraction,
/// remaining length decoding, CONNECT-first enforcement, partial packet handling,
/// and PINGREQ dispatch.
/// </summary>
public sealed class MqttParserTests
{
/// <summary>
/// Creates a minimal ClientConnection with MQTT handler for testing.
/// </summary>
private static ClientConnection CreateMqttClient()
{
var c = new ClientConnection(ClientKind.Client, nc: new MemoryStream());
c.InitMqtt(new MqttHandler());
return c;
}
// =========================================================================
// CONNECT-first enforcement
// =========================================================================
[Fact]
public void Parse_NonConnectFirst_ShouldReturnError()
{
var c = CreateMqttClient();
// PINGREQ before CONNECT → error
var buf = new byte[] { MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("first packet should be a CONNECT");
}
[Fact]
public void Parse_PublishBeforeConnect_ShouldReturnError()
{
var c = CreateMqttClient();
// PUBLISH QoS 0 before CONNECT
var buf = new byte[] { MqttPacket.Pub, 0x05, 0x00, 0x01, (byte)'t', 0x68, 0x69 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("first packet should be a CONNECT");
}
[Fact]
public void Parse_ConnectFirst_ShouldAcceptConnect()
{
var c = CreateMqttClient();
// Use a MemoryStream so CONNACK can be written.
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, new MemoryStream());
// Build a valid CONNECT packet.
var payload = new List<byte>();
payload.AddRange(new byte[] { 0x00, 0x04 }); // protocol name length
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("MQTT"));
payload.Add(0x04); // level
payload.Add(0x02); // flags: clean session
payload.AddRange(new byte[] { 0x00, 0x3C }); // keep alive = 60
payload.AddRange(new byte[] { 0x00, 0x04 }); // client id length
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("test"));
var buf = new List<byte> { MqttPacket.Connect };
// Remaining length
var remLen = payload.Count;
do
{
var b = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) b |= 0x80;
buf.Add(b);
} while (remLen > 0);
buf.AddRange(payload);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull("CONNECT should be accepted, not rejected as non-CONNECT");
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
}
[Fact]
public void Parse_SecondConnect_ShouldReturnError()
{
var c = CreateMqttClient();
// Simulate that CONNECT was already received.
c.Flags |= ClientFlags.ConnectReceived;
var buf = new byte[] { MqttPacket.Connect, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("second CONNECT");
}
// =========================================================================
// PINGREQ dispatch
// =========================================================================
[Fact]
public void Parse_PingReq_ShouldEnqueuePingResp()
{
var c = CreateMqttClient();
// Mark as connected so ping is accepted.
c.Flags |= ClientFlags.ConnectReceived;
// Use a memory stream to capture EnqueueProto output.
var ms = new MemoryStream();
// Set up the connection's network stream.
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
var buf = new byte[] { MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Verify PINGRESP was written.
var written = ms.ToArray();
written.Length.ShouldBe(2);
written[0].ShouldBe(MqttPacket.PingResp);
written[1].ShouldBe((byte)0x00);
}
[Fact]
public void Parse_MultiplePings_ShouldSucceed()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
// Two PINGREQ packets in the same buffer.
var buf = new byte[] { MqttPacket.Ping, 0x00, MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Two PINGRESP packets should have been written.
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe(MqttPacket.PingResp);
written[1].ShouldBe((byte)0x00);
written[2].ShouldBe(MqttPacket.PingResp);
written[3].ShouldBe((byte)0x00);
}
// =========================================================================
// Remaining length decoding
// =========================================================================
[Fact]
public void Parse_SingleByteRemainingLength_ShouldWork()
{
// SUBSCRIBE with remaining length = 6 (single byte < 128).
// Proves single-byte remaining length decoding works.
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// SUBSCRIBE: type=0x82, remlen=6, PI=1, filter="t" (len=1), QoS=0
var buf = new byte[] { 0x82, 0x06, 0x00, 0x01, 0x00, 0x01, (byte)'t', 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
}
[Fact]
public void Parse_TwoByteRemainingLength_ShouldWork()
{
// PUBLISH QoS 0 with remaining length = 200 → encoded as [0xC8, 0x01].
// Proves two-byte remaining length decoding works.
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// type(1) + remlen(2) + payload(200) = 203 bytes total.
var buf = new byte[203];
buf[0] = MqttPacket.Pub; // 0x30, QoS 0
buf[1] = 0xC8;
buf[2] = 0x01;
// Topic "t": length prefix (2 bytes) + 1 byte.
buf[3] = 0x00;
buf[4] = 0x01;
buf[5] = (byte)'t';
// Bytes 6..202 are zero (197-byte payload).
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
}
// =========================================================================
// Partial packet handling
// =========================================================================
[Fact]
public void Parse_PartialPacket_ShouldSaveState()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// Send only the first byte of a PING packet (missing the 0x00 remaining length).
var buf = new byte[] { MqttPacket.Ping };
var err = MqttParser.Parse(c, buf, buf.Length);
// Should succeed (partial packet saved) — no error because it just stops.
// Actually, the ReadByte for packet type succeeds, then ReadPacketLen has no data,
// so it returns (0, false) — incomplete, state saved.
err.ShouldBeNull();
// Now send the remaining length byte.
var buf2 = new byte[] { 0x00 };
// The reader's pending buffer should have the first byte saved.
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
err = MqttParser.Parse(c, buf2, buf2.Length);
err.ShouldBeNull();
// PINGRESP should have been written.
var written = ms.ToArray();
written.Length.ShouldBe(2);
written[0].ShouldBe(MqttPacket.PingResp);
}
[Fact]
public void Parse_PartialPayload_ShouldSaveState()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// SUBSCRIBE packet: type=0x82, remaining length=10, but only send 5 payload bytes.
var buf = new byte[] { 0x82, 0x0A, 0x01, 0x02, 0x03, 0x04, 0x05 };
// remaining length = 10, but only 5 bytes of payload present → partial.
var err = MqttParser.Parse(c, buf, buf.Length);
// Should save state and return null (needs more data).
err.ShouldBeNull();
}
// =========================================================================
// Unknown packet type
// =========================================================================
[Fact]
public void Parse_UnknownPacketType_ShouldReturnError()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// Packet type 0x00 is reserved/invalid.
var buf = new byte[] { 0x00, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("unknown MQTT packet type");
}
// =========================================================================
// Empty buffer
// =========================================================================
[Fact]
public void Parse_EmptyBuffer_ShouldSucceed()
{
var c = CreateMqttClient();
var buf = Array.Empty<byte>();
var err = MqttParser.Parse(c, buf, 0);
err.ShouldBeNull(); // Nothing to parse.
}
// =========================================================================
// Buffer length parameter
// =========================================================================
[Fact]
public void Parse_LenSmallerThanBuffer_ShouldOnlyParseLenBytes()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
// Buffer has two PING packets, but len says only the first one.
var buf = new byte[] { MqttPacket.Ping, 0x00, MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, 2); // Only first 2 bytes.
err.ShouldBeNull();
var written = ms.ToArray();
written.Length.ShouldBe(2); // Only one PINGRESP.
}
}
@@ -0,0 +1,668 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
using System.Text;
using Shouldly;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server.Tests.Mqtt;
/// <summary>
/// Unit tests for MQTT PUBLISH, SUBSCRIBE, and UNSUBSCRIBE packet handling.
/// </summary>
public sealed class MqttPubSubTests
{
private static ClientConnection CreateConnectedMqttClient()
{
var ms = new MemoryStream();
var c = new ClientConnection(ClientKind.Client, nc: ms);
c.InitMqtt(new MqttHandler());
c.Flags |= ClientFlags.ConnectReceived;
return c;
}
private static MemoryStream GetStream(ClientConnection c)
{
return (MemoryStream)typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(c)!;
}
// =========================================================================
// PUBLISH parsing
// =========================================================================
[Fact]
public void ParsePub_QoS0_ShouldParseCorrectly()
{
var r = new MqttReader();
// Topic "test/topic" + payload "hello"
var topic = Encoding.UTF8.GetBytes("test/topic");
var payload = Encoding.UTF8.GetBytes("hello");
var data = new List<byte>();
data.Add((byte)(topic.Length >> 8));
data.Add((byte)(topic.Length & 0xFF));
data.AddRange(topic);
data.AddRange(payload);
r.Reset(data.ToArray());
byte flags = 0x00; // QoS 0, no retain, no dup
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Count, flags, rejectQoS2: false);
err.ShouldBeNull();
pp.ShouldNotBeNull();
pp!.Topic.ShouldBe("test/topic");
pp.Subject.ShouldNotBeEmpty();
pp.Qos.ShouldBe((byte)0);
pp.Pi.ShouldBe((ushort)0);
pp.Retain.ShouldBeFalse();
pp.Dup.ShouldBeFalse();
Encoding.UTF8.GetString(pp.Msg!).ShouldBe("hello");
}
[Fact]
public void ParsePub_QoS1_ShouldParsePacketId()
{
var r = new MqttReader();
var topic = Encoding.UTF8.GetBytes("a/b");
var data = new List<byte>();
data.Add((byte)(topic.Length >> 8));
data.Add((byte)(topic.Length & 0xFF));
data.AddRange(topic);
data.Add(0x00); data.Add(0x07); // PI = 7
data.AddRange(Encoding.UTF8.GetBytes("msg"));
r.Reset(data.ToArray());
byte flags = MqttPubFlag.QoS1; // QoS 1 = 0x02
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Count, flags, rejectQoS2: false);
err.ShouldBeNull();
pp!.Qos.ShouldBe((byte)1);
pp.Pi.ShouldBe((ushort)7);
}
[Fact]
public void ParsePub_QoS2Rejected_ShouldReturnError()
{
var r = new MqttReader();
var topic = Encoding.UTF8.GetBytes("t");
var data = new List<byte>();
data.Add(0x00); data.Add(0x01);
data.AddRange(topic);
data.Add(0x00); data.Add(0x01); // PI = 1
r.Reset(data.ToArray());
byte flags = MqttPubFlag.QoS2; // QoS 2 = 0x04
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Count, flags, rejectQoS2: true);
err.ShouldNotBeNull();
err.Message.ShouldContain("QoS-2 PUBLISH rejected");
}
[Fact]
public void ParsePub_EmptyTopic_ShouldReturnError()
{
var r = new MqttReader();
var data = new byte[] { 0x00, 0x00 }; // zero-length topic
r.Reset(data);
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Length, 0x00, rejectQoS2: false);
err.ShouldNotBeNull();
err.Message.ShouldContain("empty topic");
}
[Fact]
public void ParsePub_RetainAndDup_ShouldSetFlags()
{
var r = new MqttReader();
var topic = Encoding.UTF8.GetBytes("t");
var data = new List<byte>();
data.Add(0x00); data.Add(0x01);
data.AddRange(topic);
r.Reset(data.ToArray());
byte flags = MqttPubFlag.Retain | MqttPubFlag.Dup; // 0x09
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Count, flags, rejectQoS2: false);
err.ShouldBeNull();
pp!.Retain.ShouldBeTrue();
pp.Dup.ShouldBeTrue();
}
[Fact]
public void ParsePub_EmptyPayload_ShouldSucceed()
{
var r = new MqttReader();
var topic = Encoding.UTF8.GetBytes("t");
var data = new List<byte>();
data.Add(0x00); data.Add(0x01);
data.AddRange(topic);
// No payload bytes.
r.Reset(data.ToArray());
var (pp, err) = MqttPacketHandlers.ParsePub(r, data.Count, 0x00, rejectQoS2: false);
err.ShouldBeNull();
pp!.Msg.ShouldBeNull();
}
// =========================================================================
// PUBLISH processing via parser
// =========================================================================
[Fact]
public void Parser_PublishQoS0_ShouldSucceed()
{
var c = CreateConnectedMqttClient();
// Build PUBLISH: type=0x30 (QoS 0), topic="test", payload="hi"
var topic = Encoding.UTF8.GetBytes("test");
var payload = Encoding.UTF8.GetBytes("hi");
var data = new List<byte>();
data.Add(0x00); data.Add((byte)topic.Length);
data.AddRange(topic);
data.AddRange(payload);
var buf = new List<byte>();
buf.Add(MqttPacket.Pub); // 0x30
buf.Add((byte)data.Count);
buf.AddRange(data);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull();
}
[Fact]
public void Parser_PublishQoS1_ShouldSendPubAck()
{
var c = CreateConnectedMqttClient();
// PUBLISH QoS 1: type=0x32, topic="t", PI=5, payload="x"
var data = new List<byte>();
data.Add(0x00); data.Add(0x01); data.Add((byte)'t'); // topic
data.Add(0x00); data.Add(0x05); // PI = 5
data.Add((byte)'x'); // payload
var buf = new List<byte>();
buf.Add((byte)(MqttPacket.Pub | MqttPubFlag.QoS1)); // 0x32
buf.Add((byte)data.Count);
buf.AddRange(data);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull();
// Verify PUBACK: [0x40] [0x02] [PI high] [PI low]
var ms = GetStream(c);
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe(MqttPacket.PubAck); // 0x40
written[1].ShouldBe((byte)0x02);
written[2].ShouldBe((byte)0x00); // PI high
written[3].ShouldBe((byte)0x05); // PI low
}
[Fact]
public void Parser_PublishQoS2_ShouldSendPubRec()
{
var c = CreateConnectedMqttClient();
// PUBLISH QoS 2: type=0x34, topic="t", PI=10, payload="y"
var data = new List<byte>();
data.Add(0x00); data.Add(0x01); data.Add((byte)'t'); // topic
data.Add(0x00); data.Add(0x0A); // PI = 10
data.Add((byte)'y'); // payload
var buf = new List<byte>();
buf.Add((byte)(MqttPacket.Pub | MqttPubFlag.QoS2)); // 0x34
buf.Add((byte)data.Count);
buf.AddRange(data);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull();
// Verify PUBREC: [0x50] [0x02] [PI high] [PI low]
var ms = GetStream(c);
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe(MqttPacket.PubRec); // 0x50
written[1].ShouldBe((byte)0x02);
written[2].ShouldBe((byte)0x00); // PI high
written[3].ShouldBe((byte)0x0A); // PI low
// Message should be stored in QoS2Pending.
c.Mqtt!.QoS2Pending.ShouldContainKey((ushort)10);
}
[Fact]
public void Parser_QoS2_FullHandshake_PubRecPubRelPubComp()
{
var c = CreateConnectedMqttClient();
// Step 1: PUBLISH QoS 2 → PUBREC
var pubData = new List<byte>();
pubData.Add(0x00); pubData.Add(0x01); pubData.Add((byte)'t');
pubData.Add(0x00); pubData.Add(0x07); // PI = 7
pubData.AddRange(Encoding.UTF8.GetBytes("qos2msg"));
var pubBuf = new List<byte>();
pubBuf.Add((byte)(MqttPacket.Pub | MqttPubFlag.QoS2));
pubBuf.Add((byte)pubData.Count);
pubBuf.AddRange(pubData);
var err = MqttParser.Parse(c, pubBuf.ToArray(), pubBuf.Count);
err.ShouldBeNull();
c.Mqtt!.QoS2Pending.ShouldContainKey((ushort)7);
// Reset stream to capture only PUBCOMP.
var ms = GetStream(c);
ms.SetLength(0);
// Step 2: PUBREL from client → PUBCOMP
// PUBREL: [0x62] [0x02] [PI high] [PI low]
var pubrelBuf = new byte[] { 0x62, 0x02, 0x00, 0x07 };
err = MqttParser.Parse(c, pubrelBuf, pubrelBuf.Length);
err.ShouldBeNull();
// Verify PUBCOMP was sent.
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe(MqttPacket.PubComp); // 0x70
written[1].ShouldBe((byte)0x02);
written[2].ShouldBe((byte)0x00);
written[3].ShouldBe((byte)0x07);
// Message should be removed from QoS2Pending.
c.Mqtt.QoS2Pending.ShouldNotContainKey((ushort)7);
}
[Fact]
public void Parser_PubAck_ShouldRemoveFromPending()
{
var c = CreateConnectedMqttClient();
// Pre-populate pending.
c.Mqtt!.Pending[(ushort)3] = null;
c.Mqtt.Pending.ShouldContainKey((ushort)3);
// PUBACK: [0x40] [0x02] [0x00] [0x03]
var buf = new byte[] { 0x40, 0x02, 0x00, 0x03 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
c.Mqtt.Pending.ShouldNotContainKey((ushort)3);
}
[Fact]
public void Parser_PubRec_ShouldSendPubRel()
{
var c = CreateConnectedMqttClient();
// Pre-populate pending.
c.Mqtt!.Pending[(ushort)9] = null;
// PUBREC: [0x50] [0x02] [0x00] [0x09]
var buf = new byte[] { 0x50, 0x02, 0x00, 0x09 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Should have sent PUBREL: [0x62] [0x02] [0x00] [0x09]
var ms = GetStream(c);
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe((byte)0x62); // PUBREL with bit 1 set
written[3].ShouldBe((byte)0x09);
// Pending should be cleared.
c.Mqtt.Pending.ShouldNotContainKey((ushort)9);
}
[Fact]
public void Parser_PubComp_ShouldRemoveFromPending()
{
var c = CreateConnectedMqttClient();
c.Mqtt!.Pending[(ushort)15] = null;
// PUBCOMP: [0x70] [0x02] [0x00] [0x0F]
var buf = new byte[] { 0x70, 0x02, 0x00, 0x0F };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
c.Mqtt.Pending.ShouldNotContainKey((ushort)15);
}
// =========================================================================
// SUBSCRIBE parsing
// =========================================================================
[Fact]
public void ParseSubs_SingleFilter_ShouldParseCorrectly()
{
var r = new MqttReader();
var filter = Encoding.UTF8.GetBytes("test/topic");
var data = new List<byte>();
data.Add(0x00); data.Add(0x0A); // PI = 10
data.Add((byte)(filter.Length >> 8));
data.Add((byte)(filter.Length & 0xFF));
data.AddRange(filter);
data.Add(0x01); // QoS 1
r.Reset(data.ToArray());
var (pi, filters, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, (byte)(MqttPacket.Sub | MqttConst.SubscribeFlags), data.Count, isSub: true);
err.ShouldBeNull();
pi.ShouldBe((ushort)10);
filters.ShouldNotBeNull();
filters!.Count.ShouldBe(1);
filters[0].Filter.ShouldNotBeEmpty();
filters[0].Qos.ShouldBe((byte)1);
}
[Fact]
public void ParseSubs_MultipleFilters_ShouldParseAll()
{
var r = new MqttReader();
var f1 = Encoding.UTF8.GetBytes("a/b");
var f2 = Encoding.UTF8.GetBytes("c/d");
var data = new List<byte>();
data.Add(0x00); data.Add(0x01); // PI = 1
data.Add((byte)(f1.Length >> 8)); data.Add((byte)(f1.Length & 0xFF));
data.AddRange(f1);
data.Add(0x00); // QoS 0
data.Add((byte)(f2.Length >> 8)); data.Add((byte)(f2.Length & 0xFF));
data.AddRange(f2);
data.Add(0x02); // QoS 2
r.Reset(data.ToArray());
var (pi, filters, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, (byte)(MqttPacket.Sub | MqttConst.SubscribeFlags), data.Count, isSub: true);
err.ShouldBeNull();
filters!.Count.ShouldBe(2);
filters[0].Qos.ShouldBe((byte)0);
filters[1].Qos.ShouldBe((byte)2);
}
[Fact]
public void ParseSubs_WrongFlags_ShouldReturnError()
{
var r = new MqttReader();
var data = new byte[] { 0x00, 0x01, 0x00, 0x01, (byte)'t', 0x00 };
r.Reset(data);
// Wrong flags: 0x00 instead of 0x02
var (_, _, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, MqttPacket.Sub, data.Length, isSub: true); // flags = 0x00
err.ShouldNotBeNull();
err.Message.ShouldContain("reserved flags");
}
[Fact]
public void ParseSubs_ZeroPacketId_ShouldReturnError()
{
var r = new MqttReader();
var data = new byte[] { 0x00, 0x00, 0x00, 0x01, (byte)'t', 0x00 };
r.Reset(data);
var (_, _, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, (byte)(MqttPacket.Sub | MqttConst.SubscribeFlags), data.Length, isSub: true);
err.ShouldNotBeNull();
err.Message.ShouldContain("packet identifier must not be 0");
}
[Fact]
public void ParseSubs_InvalidQoS_ShouldReturnError()
{
var r = new MqttReader();
var data = new byte[] { 0x00, 0x01, 0x00, 0x01, (byte)'t', 0x03 }; // QoS=3, invalid
r.Reset(data);
var (_, _, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, (byte)(MqttPacket.Sub | MqttConst.SubscribeFlags), data.Length, isSub: true);
err.ShouldNotBeNull();
err.Message.ShouldContain("invalid QoS");
}
// =========================================================================
// SUBSCRIBE processing via parser
// =========================================================================
[Fact]
public void Parser_Subscribe_ShouldSendSubAck()
{
var c = CreateConnectedMqttClient();
// Build SUBSCRIBE: PI=5, filter="test/topic", QoS=1
var filter = Encoding.UTF8.GetBytes("test/topic");
var payload = new List<byte>();
payload.Add(0x00); payload.Add(0x05); // PI = 5
payload.Add((byte)(filter.Length >> 8));
payload.Add((byte)(filter.Length & 0xFF));
payload.AddRange(filter);
payload.Add(0x01); // QoS 1
var buf = new List<byte>();
buf.Add((byte)(MqttPacket.Sub | MqttConst.SubscribeFlags)); // 0x82
buf.Add((byte)payload.Count);
buf.AddRange(payload);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull();
// Verify SUBACK was written.
var ms = GetStream(c);
var data = ms.ToArray();
data.Length.ShouldBeGreaterThan(0);
data[0].ShouldBe(MqttPacket.SubAck); // 0x90
// SUBACK payload: PI (2 bytes) + QoS per filter (1 byte) = 3
// Format: [0x90] [0x03] [0x00] [0x05] [0x01]
data[1].ShouldBe((byte)0x03); // remaining length
data[2].ShouldBe((byte)0x00); // PI high
data[3].ShouldBe((byte)0x05); // PI low
data[4].ShouldBe((byte)0x01); // granted QoS 1
}
[Fact]
public void Parser_Subscribe_QoS2Downgrade_ShouldGrantQoS1()
{
var c = CreateConnectedMqttClient();
c.Mqtt!.DowngradeQoS2Sub = true;
var filter = Encoding.UTF8.GetBytes("a");
var payload = new List<byte>();
payload.Add(0x00); payload.Add(0x01); // PI = 1
payload.Add(0x00); payload.Add((byte)filter.Length);
payload.AddRange(filter);
payload.Add(0x02); // QoS 2 requested
var buf = new List<byte>();
buf.Add((byte)(MqttPacket.Sub | MqttConst.SubscribeFlags));
buf.Add((byte)payload.Count);
buf.AddRange(payload);
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
err.ShouldBeNull();
var ms = GetStream(c);
var data = ms.ToArray();
// Last byte of SUBACK is the granted QoS, should be 1 (downgraded from 2).
data[^1].ShouldBe((byte)0x01);
}
// =========================================================================
// UNSUBSCRIBE parsing
// =========================================================================
[Fact]
public void ParseUnsubs_SingleFilter_ShouldParseCorrectly()
{
var r = new MqttReader();
var filter = Encoding.UTF8.GetBytes("test/topic");
var data = new List<byte>();
data.Add(0x00); data.Add(0x03); // PI = 3
data.Add((byte)(filter.Length >> 8));
data.Add((byte)(filter.Length & 0xFF));
data.AddRange(filter);
// No QoS byte for UNSUBSCRIBE
r.Reset(data.ToArray());
var (pi, filters, err) = MqttPacketHandlers.ParseSubsOrUnsubs(
r, (byte)(MqttPacket.Unsub | MqttConst.UnsubscribeFlags), data.Count, isSub: false);
err.ShouldBeNull();
pi.ShouldBe((ushort)3);
filters!.Count.ShouldBe(1);
filters[0].Qos.ShouldBe((byte)0); // Always 0 for unsub
}
// =========================================================================
// UNSUBSCRIBE processing via parser
// =========================================================================
[Fact]
public void Parser_Unsubscribe_ShouldSendUnsubAck()
{
var c = CreateConnectedMqttClient();
// First subscribe to create the subscription.
var filter = Encoding.UTF8.GetBytes("test/unsub");
var subPayload = new List<byte>();
subPayload.Add(0x00); subPayload.Add(0x01); // PI = 1
subPayload.Add((byte)(filter.Length >> 8));
subPayload.Add((byte)(filter.Length & 0xFF));
subPayload.AddRange(filter);
subPayload.Add(0x00); // QoS 0
var subBuf = new List<byte>();
subBuf.Add((byte)(MqttPacket.Sub | MqttConst.SubscribeFlags));
subBuf.Add((byte)subPayload.Count);
subBuf.AddRange(subPayload);
MqttParser.Parse(c, subBuf.ToArray(), subBuf.Count);
// Reset stream to capture UNSUBACK only.
var ms = GetStream(c);
ms.SetLength(0);
// Now unsubscribe.
var unsubPayload = new List<byte>();
unsubPayload.Add(0x00); unsubPayload.Add(0x02); // PI = 2
unsubPayload.Add((byte)(filter.Length >> 8));
unsubPayload.Add((byte)(filter.Length & 0xFF));
unsubPayload.AddRange(filter);
var unsubBuf = new List<byte>();
unsubBuf.Add((byte)(MqttPacket.Unsub | MqttConst.UnsubscribeFlags)); // 0xA2
unsubBuf.Add((byte)unsubPayload.Count);
unsubBuf.AddRange(unsubPayload);
var err = MqttParser.Parse(c, unsubBuf.ToArray(), unsubBuf.Count);
err.ShouldBeNull();
// Verify UNSUBACK: [0xB0] [0x02] [PI high] [PI low]
var data = ms.ToArray();
data.Length.ShouldBe(4);
data[0].ShouldBe(MqttPacket.UnsubAck); // 0xB0
data[1].ShouldBe((byte)0x02);
data[2].ShouldBe((byte)0x00); // PI high
data[3].ShouldBe((byte)0x02); // PI low
}
// =========================================================================
// Full CONNECT + SUBSCRIBE + PUBLISH + UNSUBSCRIBE flow
// =========================================================================
[Fact]
public void Parser_FullFlow_ConnectSubPubUnsub()
{
var ms = new MemoryStream();
var c = new ClientConnection(ClientKind.Client, nc: ms);
c.InitMqtt(new MqttHandler());
// 1. CONNECT
var connectBuf = BuildConnectPacket("flow-test");
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
err.ShouldBeNull();
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
// 2. SUBSCRIBE to "test/flow" QoS 0
var filter = Encoding.UTF8.GetBytes("test/flow");
var subPayload = new List<byte>();
subPayload.Add(0x00); subPayload.Add(0x01); // PI = 1
subPayload.Add((byte)(filter.Length >> 8));
subPayload.Add((byte)(filter.Length & 0xFF));
subPayload.AddRange(filter);
subPayload.Add(0x00); // QoS 0
var subBuf = new List<byte>();
subBuf.Add((byte)(MqttPacket.Sub | MqttConst.SubscribeFlags));
subBuf.Add((byte)subPayload.Count);
subBuf.AddRange(subPayload);
err = MqttParser.Parse(c, subBuf.ToArray(), subBuf.Count);
err.ShouldBeNull();
// 3. PUBLISH to "test/flow" QoS 0
var topic = Encoding.UTF8.GetBytes("test/flow");
var pubData = new List<byte>();
pubData.Add((byte)(topic.Length >> 8));
pubData.Add((byte)(topic.Length & 0xFF));
pubData.AddRange(topic);
pubData.AddRange(Encoding.UTF8.GetBytes("hello"));
var pubBuf = new List<byte>();
pubBuf.Add(MqttPacket.Pub);
pubBuf.Add((byte)pubData.Count);
pubBuf.AddRange(pubData);
err = MqttParser.Parse(c, pubBuf.ToArray(), pubBuf.Count);
err.ShouldBeNull();
// 4. UNSUBSCRIBE from "test/flow"
var unsubPayload = new List<byte>();
unsubPayload.Add(0x00); unsubPayload.Add(0x02); // PI = 2
unsubPayload.Add((byte)(filter.Length >> 8));
unsubPayload.Add((byte)(filter.Length & 0xFF));
unsubPayload.AddRange(filter);
var unsubBuf = new List<byte>();
unsubBuf.Add((byte)(MqttPacket.Unsub | MqttConst.UnsubscribeFlags));
unsubBuf.Add((byte)unsubPayload.Count);
unsubBuf.AddRange(unsubPayload);
err = MqttParser.Parse(c, unsubBuf.ToArray(), unsubBuf.Count);
err.ShouldBeNull();
// Verify: CONNACK(4) + SUBACK(5) + UNSUBACK(4) = 13 bytes written
var data = ms.ToArray();
data.Length.ShouldBe(13);
data[0].ShouldBe(MqttPacket.ConnectAck); // CONNACK
data[4].ShouldBe(MqttPacket.SubAck); // SUBACK
data[9].ShouldBe(MqttPacket.UnsubAck); // UNSUBACK
}
/// <summary>Builds a minimal MQTT CONNECT packet.</summary>
private static byte[] BuildConnectPacket(string clientId)
{
var payload = new List<byte>();
payload.AddRange(new byte[] { 0x00, 0x04 });
payload.AddRange(Encoding.UTF8.GetBytes("MQTT"));
payload.Add(0x04);
payload.Add(0x02); // clean session
payload.AddRange(new byte[] { 0x00, 0x3C });
var cidBytes = Encoding.UTF8.GetBytes(clientId);
payload.Add((byte)(cidBytes.Length >> 8));
payload.Add((byte)(cidBytes.Length & 0xFF));
payload.AddRange(cidBytes);
var result = new List<byte> { MqttPacket.Connect };
var remLen = payload.Count;
do
{
var b = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) b |= 0x80;
result.Add(b);
} while (remLen > 0);
result.AddRange(payload);
return result.ToArray();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
Generated: 2026-03-01 18:41:43 UTC
Generated: 2026-03-02 00:11:44 UTC
## Modules (12 total)