Compare commits
15 Commits
dd282e69dc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03371eb194 | |||
| 28451ad263 | |||
| 8127f6e1cb | |||
| 710f443eda | |||
| b465d095e3 | |||
| 715367b9ea | |||
| 95cf20b00b | |||
| 2e2ffee41a | |||
| 6fb7f43335 | |||
| 60bb56a90c | |||
| be1eb3392e | |||
| bd40b36c23 | |||
| 8973db0027 | |||
| 99399ac917 | |||
| ca8297d0ad |
@@ -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:976–1037`) 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:2263–2575`:
|
||||
|
||||
```
|
||||
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:136–137`
|
||||
|
||||
**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:976–1037` with the full Go-parity startup sequence.
|
||||
|
||||
**Files:**
|
||||
- Modify: `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs:970–1037`
|
||||
|
||||
**Step 1: Replace the Start() method**
|
||||
|
||||
Replace lines 970–1037 (the `/// <summary>` through the closing `}` of `Start()`) with the complete implementation below. The new code mirrors Go `server.go:2263–2575` 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:2263–2575.
|
||||
/// </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:2263–2575.
|
||||
/// </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();
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+3
-2
@@ -1,6 +1,6 @@
|
||||
# NATS .NET Porting Status Report
|
||||
|
||||
Generated: 2026-03-01 18:41:43 UTC
|
||||
Generated: 2026-03-02 00:42:20 UTC
|
||||
|
||||
## Modules (12 total)
|
||||
|
||||
@@ -21,8 +21,9 @@ Generated: 2026-03-01 18:41:43 UTC
|
||||
|
||||
| Status | Count |
|
||||
|--------|-------|
|
||||
| complete | 9 |
|
||||
| n_a | 307 |
|
||||
| verified | 2950 |
|
||||
| verified | 2941 |
|
||||
|
||||
## Library Mappings (36 total)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user