# MQTT Connection Type Parity + Config Parsing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Port Go-compatible MQTT connection-type handling for JWT `allowed_connection_types`, add `/connz` `mqtt_client` filtering, parse all Go `MQTTOpts` config fields, and expand `MqttOptsVarz` monitoring output — with tests and docs updates.
**Architecture:** Thread a connection-type value into auth context and enforce Go-style allowed-connection-type semantics in `JwtAuthenticator`. Add connz query-option filtering for `mqtt_client` across open and closed connections. Parse the full `mqtt {}` config block into a new `MqttOptions` model following the existing `ParseTls()` pattern in `ConfigProcessor`. Expand `MqttOptsVarz` and wire into `/varz`. Keep behavior backward-compatible and transport-agnostic so MQTT runtime plumbing can be added later without changing auth/monitoring/config semantics.
**Tech Stack:** .NET 10, xUnit 3, Shouldly, ASP.NET minimal APIs, System.Text.Json.
---
### Task 1: Add failing JWT connection-type behavior tests
**Files:**
- Modify: `tests/NATS.Server.Tests/JwtAuthenticatorTests.cs`
**Step 1: Write the failing tests**
Add these 5 test methods to the existing `JwtAuthenticatorTests` class. Each test must build a valid operator/account/user JWT chain (reuse the existing helper pattern from other tests in the file). The user JWT's `nats.allowed_connection_types` array controls which connection types are permitted.
```csharp
[Fact]
public async Task Allowed_connection_types_allows_standard_context()
{
// Build valid operator/account/user JWT chain.
// User JWT includes: "allowed_connection_types":["STANDARD"]
// Context sets ConnectionType = "STANDARD".
// Assert Authenticate() is not null.
}
[Fact]
public async Task Allowed_connection_types_rejects_mqtt_only_for_standard_context()
{
// User JWT includes: "allowed_connection_types":["MQTT"]
// Context sets ConnectionType = "STANDARD".
// Assert Authenticate() is null.
}
[Fact]
public async Task Allowed_connection_types_allows_known_even_with_unknown_values()
{
// User JWT includes: ["STANDARD", "SOME_NEW_TYPE"]
// Context sets ConnectionType = "STANDARD".
// Assert Authenticate() is not null.
}
[Fact]
public async Task Allowed_connection_types_rejects_when_only_unknown_values_present()
{
// User JWT includes: ["SOME_NEW_TYPE"]
// Context sets ConnectionType = "STANDARD".
// Assert Authenticate() is null.
}
[Fact]
public async Task Allowed_connection_types_is_case_insensitive_for_input_values()
{
// User JWT includes: ["standard"]
// Context sets ConnectionType = "STANDARD".
// Assert Authenticate() is not null.
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~JwtAuthenticatorTests.Allowed_connection_types" -v minimal`
Expected: FAIL (current implementation ignores `allowed_connection_types`).
**Step 3: Commit test-only checkpoint**
```bash
git add tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
git commit -m "test: add failing jwt allowed connection type coverage"
```
---
### Task 2: Implement auth connection-type model and Go-style allowed-type conversion
**Files:**
- Modify: `src/NATS.Server/Auth/IAuthenticator.cs` (line 11-16: add `ConnectionType` property to `ClientAuthContext`)
- Create: `src/NATS.Server/Auth/Jwt/JwtConnectionTypes.cs`
- Modify: `src/NATS.Server/Auth/JwtAuthenticator.cs` (insert check after step 7 revocation check, before step 8 permissions, around line 97)
- Modify: `src/NATS.Server/NatsClient.cs` (line 382-387: add `ConnectionType` to auth context construction)
**Step 1: Add connection type to auth context**
In `src/NATS.Server/Auth/IAuthenticator.cs`, add the `ConnectionType` property to `ClientAuthContext`. Note: this requires adding a `using NATS.Server.Auth.Jwt;` at the top of the file.
```csharp
public sealed class ClientAuthContext
{
public required ClientOptions Opts { get; init; }
public required byte[] Nonce { get; init; }
public string ConnectionType { get; init; } = JwtConnectionTypes.Standard;
public X509Certificate2? ClientCertificate { get; init; }
}
```
**Step 2: Create JWT connection-type constants + converter helper**
Create new file `src/NATS.Server/Auth/Jwt/JwtConnectionTypes.cs`:
```csharp
namespace NATS.Server.Auth.Jwt;
///
/// Known connection type constants matching Go server/client.go.
/// Used for JWT allowed_connection_types claim validation.
/// Reference: golang/nats-server/server/client.go connectionType constants.
///
internal static class JwtConnectionTypes
{
public const string Standard = "STANDARD";
public const string Websocket = "WEBSOCKET";
public const string Leafnode = "LEAFNODE";
public const string LeafnodeWs = "LEAFNODE_WS";
public const string Mqtt = "MQTT";
public const string MqttWs = "MQTT_WS";
public const string InProcess = "INPROCESS";
private static readonly HashSet Known =
[
Standard, Websocket, Leafnode, LeafnodeWs, Mqtt, MqttWs, InProcess,
];
///
/// Converts a list of connection type strings (from JWT claims) into a set of
/// known valid types plus a flag indicating unknown values were present.
/// Reference: Go server/client.go convertAllowedConnectionTypes.
///
public static (HashSet Valid, bool HasUnknown) Convert(IEnumerable? values)
{
var valid = new HashSet(StringComparer.Ordinal);
var hasUnknown = false;
if (values is null) return (valid, false);
foreach (var raw in values)
{
var up = (raw ?? string.Empty).Trim().ToUpperInvariant();
if (up.Length == 0) continue;
if (Known.Contains(up)) valid.Add(up);
else hasUnknown = true;
}
return (valid, hasUnknown);
}
}
```
**Step 3: Enforce allowed connection types in JWT auth**
In `src/NATS.Server/Auth/JwtAuthenticator.cs`, insert the following block after the revocation check (step 7, around line 96) and before the permissions build (step 8):
```csharp
// 7b. Check allowed connection types
var (allowedTypes, hasUnknown) = JwtConnectionTypes.Convert(userClaims.Nats?.AllowedConnectionTypes);
if (allowedTypes.Count == 0)
{
if (hasUnknown)
return null; // unknown-only list should reject
}
else
{
var connType = string.IsNullOrWhiteSpace(context.ConnectionType)
? JwtConnectionTypes.Standard
: context.ConnectionType.ToUpperInvariant();
if (!allowedTypes.Contains(connType))
return null;
}
```
**Step 4: Set auth context connection type in client connect path**
In `src/NATS.Server/NatsClient.cs` around line 382, add `ConnectionType` to the existing `ClientAuthContext` construction:
```csharp
var context = new ClientAuthContext
{
Opts = ClientOpts,
Nonce = _nonce ?? [],
ConnectionType = JwtConnectionTypes.Standard,
ClientCertificate = TlsState?.PeerCert,
};
```
Add `using NATS.Server.Auth.Jwt;` at the top of the file.
**Step 5: Run tests to verify pass**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~JwtAuthenticatorTests.Allowed_connection_types" -v minimal`
Expected: PASS.
**Step 6: Commit implementation checkpoint**
```bash
git add src/NATS.Server/Auth/IAuthenticator.cs src/NATS.Server/Auth/Jwt/JwtConnectionTypes.cs src/NATS.Server/Auth/JwtAuthenticator.cs src/NATS.Server/NatsClient.cs
git commit -m "feat: enforce jwt allowed connection types with go-compatible semantics"
```
---
### Task 3: Add failing connz mqtt_client filter tests
**Files:**
- Modify: `tests/NATS.Server.Tests/MonitorTests.cs`
**Step 1: Write the failing tests**
Add these 2 test methods to the existing `MonitorTests` class. These test the `/connz?mqtt_client=` query parameter filtering.
```csharp
[Fact]
public async Task Connz_filters_by_mqtt_client_for_open_connections()
{
// Start server with monitoring port.
// Connect a regular NATS client (no MQTT ID).
// Query /connz?mqtt_client=some-id.
// Assert num_connections == 0 (no client has that MQTT ID).
}
[Fact]
public async Task Connz_filters_by_mqtt_client_for_closed_connections()
{
// Start server with monitoring port.
// Query /connz?state=closed&mqtt_client=missing-id.
// Assert num_connections == 0.
}
```
**Step 2: Run tests to verify expected failure mode**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~MonitorTests.Connz_filters_by_mqtt_client" -v minimal`
Expected: FAIL (query option not implemented yet — `mqtt_client` param ignored, so all connections returned).
**Step 3: Commit test-only checkpoint**
```bash
git add tests/NATS.Server.Tests/MonitorTests.cs
git commit -m "test: add failing connz mqtt_client filter coverage"
```
---
### Task 4: Implement connz mqtt_client filtering and closed snapshot support
**Files:**
- Modify: `src/NATS.Server/Monitoring/Connz.cs` (line 191-210: add `MqttClient` to `ConnzOptions`)
- Modify: `src/NATS.Server/Monitoring/ConnzHandler.cs` (line 148-201: parse query param; line 18-29: apply filter after collection but before sort)
- Modify: `src/NATS.Server/Monitoring/ClosedClient.cs` (line 6-25: add `MqttClient` property)
- Modify: `src/NATS.Server/NatsServer.cs` (line 695-714: add `MqttClient` to closed snapshot)
**Step 1: Add `MqttClient` to `ConnzOptions`**
In `src/NATS.Server/Monitoring/Connz.cs`, add after `FilterSubject` property (line 205):
```csharp
public string MqttClient { get; set; } = "";
```
**Step 2: Parse `mqtt_client` query param in handler**
In `src/NATS.Server/Monitoring/ConnzHandler.cs` `ParseQueryParams` method, add after the existing `limit` parse block (around line 198):
```csharp
if (q.TryGetValue("mqtt_client", out var mqttClient))
opts.MqttClient = mqttClient.ToString();
```
**Step 3: Apply `mqtt_client` filter in `HandleConnz`**
In `src/NATS.Server/Monitoring/ConnzHandler.cs` `HandleConnz` method, add after the closed connections collection block (after line 29) and before the sort validation (line 32):
```csharp
// Filter by MQTT client ID
if (!string.IsNullOrEmpty(opts.MqttClient))
connInfos = connInfos.Where(c => c.MqttClient == opts.MqttClient).ToList();
```
**Step 4: Add `MqttClient` to `ClosedClient` model**
In `src/NATS.Server/Monitoring/ClosedClient.cs`, add after line 24 (`TlsCipherSuite`):
```csharp
public string MqttClient { get; init; } = "";
```
**Step 5: Add `MqttClient` to closed snapshot creation in `NatsServer.RemoveClient`**
In `src/NATS.Server/NatsServer.cs` around line 713 (inside the `new ClosedClient { ... }` block), add:
```csharp
MqttClient = "", // populated when MQTT transport is implemented
```
**Step 6: Add `MqttClient` to `BuildClosedConnInfo`**
In `src/NATS.Server/Monitoring/ConnzHandler.cs` `BuildClosedConnInfo` method (line 119-146), add to the `new ConnInfo { ... }` initializer:
```csharp
MqttClient = closed.MqttClient,
```
**Step 7: Run connz mqtt filter tests**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~MonitorTests.Connz_filters_by_mqtt_client" -v minimal`
Expected: PASS.
**Step 8: Commit implementation checkpoint**
```bash
git add src/NATS.Server/Monitoring/Connz.cs src/NATS.Server/Monitoring/ConnzHandler.cs src/NATS.Server/Monitoring/ClosedClient.cs src/NATS.Server/NatsServer.cs
git commit -m "feat: add connz mqtt_client filtering"
```
---
### Task 5: Verification checkpoint for JWT + connz tasks
**Step 1: Run all JWT connection-type tests**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~JwtAuthenticatorTests.Allowed_connection_types" -v minimal`
Expected: PASS.
**Step 2: Run all connz tests**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~MonitorTests.Connz" -v minimal`
Expected: PASS.
**Step 3: Run full test suite**
Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -v minimal`
Expected: PASS (no regressions).
---
### Task 6: Add MqttOptions model and config parsing
**Files:**
- Create: `src/NATS.Server/MqttOptions.cs`
- Modify: `src/NATS.Server/NatsOptions.cs` (line 116-117: add `Mqtt` property)
- Modify: `src/NATS.Server/Configuration/ConfigProcessor.cs` (line 248: add `mqtt` case; add `ParseMqtt` + `ParseMqttAuth` + `ParseMqttTls` + `ToDouble` methods)
**Step 1: Create `MqttOptions` model**
Create new file `src/NATS.Server/MqttOptions.cs`. This matches Go `MQTTOpts` struct (golang/nats-server/server/opts.go:613-707):
```csharp
namespace NATS.Server;
///
/// MQTT protocol configuration options.
/// Corresponds to Go server/opts.go MQTTOpts struct.
/// Config is parsed and stored but no MQTT listener is started yet.
///
public sealed class MqttOptions
{
// Network
public string Host { get; set; } = "";
public int Port { get; set; }
// Auth override (MQTT-specific, separate from global auth)
public string? NoAuthUser { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Token { get; set; }
public double AuthTimeout { get; set; }
// TLS
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }
public string? TlsCaCert { get; set; }
public bool TlsVerify { get; set; }
public double TlsTimeout { get; set; } = 2.0;
public bool TlsMap { get; set; }
public HashSet? TlsPinnedCerts { get; set; }
// JetStream integration
public string? JsDomain { get; set; }
public int StreamReplicas { get; set; }
public int ConsumerReplicas { get; set; }
public bool ConsumerMemoryStorage { get; set; }
public TimeSpan ConsumerInactiveThreshold { get; set; }
// QoS
public TimeSpan AckWait { get; set; } = TimeSpan.FromSeconds(30);
public ushort MaxAckPending { get; set; }
public TimeSpan JsApiTimeout { get; set; } = TimeSpan.FromSeconds(5);
public bool HasTls => TlsCert != null && TlsKey != null;
}
```
**Step 2: Add `Mqtt` property to `NatsOptions`**
In `src/NATS.Server/NatsOptions.cs`, add before the `HasTls` property (around line 117):
```csharp
// MQTT configuration (parsed from config, no listener yet)
public MqttOptions? Mqtt { get; set; }
```
**Step 3: Add `ToDouble` helper to `ConfigProcessor`**
In `src/NATS.Server/Configuration/ConfigProcessor.cs`, add after the `ToString` helper (around line 654):
```csharp
private static double ToDouble(object? value) => value switch
{
double d => d,
long l => l,
int i => i,
string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d,
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"),
};
```
**Step 4: Add `mqtt` case to `ProcessKey` switch**
In `src/NATS.Server/Configuration/ConfigProcessor.cs`, replace the default case comment at line 248:
```csharp
// MQTT
case "mqtt":
if (value is Dictionary mqttDict)
ParseMqtt(mqttDict, opts, errors);
break;
// Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.)
default:
break;
```
**Step 5: Add `ParseMqtt` method**
Add this method after `ParseTags` (around line 621). It follows the exact key/alias structure from Go `parseMQTT` (opts.go:5443-5541):
```csharp
// ─── MQTT parsing ─────────────────────────────────────────────
private static void ParseMqtt(Dictionary dict, NatsOptions opts, List errors)
{
var mqtt = opts.Mqtt ?? new MqttOptions();
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "listen":
var (host, port) = ParseHostPort(value);
if (host is not null) mqtt.Host = host;
if (port is not null) mqtt.Port = port.Value;
break;
case "port":
mqtt.Port = ToInt(value);
break;
case "host" or "net":
mqtt.Host = ToString(value);
break;
case "no_auth_user":
mqtt.NoAuthUser = ToString(value);
break;
case "tls":
if (value is Dictionary tlsDict)
ParseMqttTls(tlsDict, mqtt, errors);
break;
case "authorization" or "authentication":
if (value is Dictionary authDict)
ParseMqttAuth(authDict, mqtt, errors);
break;
case "ack_wait" or "ackwait":
mqtt.AckWait = ParseDuration(value);
break;
case "js_api_timeout" or "api_timeout":
mqtt.JsApiTimeout = ParseDuration(value);
break;
case "max_ack_pending" or "max_pending" or "max_inflight":
var pending = ToInt(value);
if (pending < 0 || pending > 0xFFFF)
errors.Add($"mqtt max_ack_pending invalid value {pending}, should be in [0..{0xFFFF}] range");
else
mqtt.MaxAckPending = (ushort)pending;
break;
case "js_domain":
mqtt.JsDomain = ToString(value);
break;
case "stream_replicas":
mqtt.StreamReplicas = ToInt(value);
break;
case "consumer_replicas":
mqtt.ConsumerReplicas = ToInt(value);
break;
case "consumer_memory_storage":
mqtt.ConsumerMemoryStorage = ToBool(value);
break;
case "consumer_inactive_threshold" or "consumer_auto_cleanup":
mqtt.ConsumerInactiveThreshold = ParseDuration(value);
break;
default:
// Unknown MQTT keys silently ignored
break;
}
}
opts.Mqtt = mqtt;
}
private static void ParseMqttAuth(Dictionary dict, MqttOptions mqtt, List errors)
{
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "user" or "username":
mqtt.Username = ToString(value);
break;
case "pass" or "password":
mqtt.Password = ToString(value);
break;
case "token":
mqtt.Token = ToString(value);
break;
case "timeout":
mqtt.AuthTimeout = ToDouble(value);
break;
default:
break;
}
}
}
private static void ParseMqttTls(Dictionary dict, MqttOptions mqtt, List errors)
{
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "cert_file":
mqtt.TlsCert = ToString(value);
break;
case "key_file":
mqtt.TlsKey = ToString(value);
break;
case "ca_file":
mqtt.TlsCaCert = ToString(value);
break;
case "verify":
mqtt.TlsVerify = ToBool(value);
break;
case "verify_and_map":
var map = ToBool(value);
mqtt.TlsMap = map;
if (map) mqtt.TlsVerify = true;
break;
case "timeout":
mqtt.TlsTimeout = ToDouble(value);
break;
case "pinned_certs":
if (value is List