feat: implement full MQTT Go parity across 5 phases — binary protocol, auth/TLS, cross-protocol bridging, monitoring, and JetStream persistence

Phase 1: Binary MQTT 3.1.1 wire protocol with PipeReader-based parsing,
full packet type dispatch, and MQTT 3.1.1 compliance checks.

Phase 2: Auth pipeline routing MQTT CONNECT through AuthService,
TLS transport with SslStream wrapping, pinned cert validation.

Phase 3: IMessageRouter refactor (NatsClient → INatsClient),
MqttNatsClientAdapter for cross-protocol bridging, MqttTopicMapper
with full Go-parity topic/subject translation.

Phase 4: /connz mqtt_client field population, /varz actual MQTT port.

Phase 5: JetStream persistence — MqttStreamInitializer creates 5
internal streams, MqttConsumerManager for QoS 1/2 consumers,
subject-keyed session/retained lookups replacing linear scans.

All 503 MQTT tests and 1589 Core tests pass.
This commit is contained in:
Joseph Doherty
2026-03-13 10:09:40 -04:00
parent 0be321fa53
commit 845441b32c
34 changed files with 3194 additions and 126 deletions

View File

@@ -115,6 +115,13 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public string ServerName => _serverInfo.ServerName;
public int ClientCount => _clients.Count;
public int Port => _options.Port;
/// <summary>
/// Returns the actual bound port of the MQTT listener, or null if MQTT is not enabled.
/// Used by VarzHandler for monitoring.
/// </summary>
public int? MqttListenerPort => _mqttListener?.Port;
public Account SystemAccount => _systemAccount;
public string ServerNKey { get; }
public InternalEventSystem? EventSystem => _eventSystem;
@@ -914,11 +921,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (_options.Mqtt is { Port: > 0 } mqttOptions)
{
var mqttHost = string.IsNullOrWhiteSpace(mqttOptions.Host) ? _options.Host : mqttOptions.Host;
// Create MQTT JetStream components if JetStream is enabled
MqttStreamInitializer? mqttStreamInit = null;
MqttConsumerManager? mqttConsumerMgr = null;
if (_jetStreamStreamManager != null && _jetStreamConsumerManager != null)
{
mqttStreamInit = new Mqtt.MqttStreamInitializer(_jetStreamStreamManager);
mqttConsumerMgr = new Mqtt.MqttConsumerManager(_jetStreamStreamManager, _jetStreamConsumerManager);
}
_mqttListener = new MqttListener(
mqttHost,
mqttOptions.Port,
mqttOptions.Username,
mqttOptions.Password);
_authService,
mqttOptions,
mqttStreamInit,
mqttConsumerMgr);
await _mqttListener.StartAsync(linked.Token);
}
if (_jetStreamService != null)
@@ -1316,8 +1335,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
ReadOnlyMemory<byte> payload, INatsClient sender)
{
// Cast to NatsClient for operations that require it (JetStream pub-ack, stats).
// Non-NatsClient senders (e.g. MqttNatsClientAdapter) skip those code paths.
var natsClient = sender as NatsClient;
if (replyTo != null
&& subject.StartsWith("$JS.API", StringComparison.Ordinal)
&& _jetStreamApiRouter != null)
@@ -1327,10 +1350,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
// Go reference: consumer.go:4276 processNextMsgRequest
if (subject.StartsWith(JetStream.Api.JetStreamApiSubjects.ConsumerNext, StringComparison.Ordinal)
&& _jetStreamConsumerManager != null
&& _jetStreamStreamManager != null)
&& _jetStreamStreamManager != null
&& natsClient != null)
{
Interlocked.Increment(ref _stats.JetStreamApiTotal);
DeliverPullFetchMessages(subject, replyTo, payload, sender);
DeliverPullFetchMessages(subject, replyTo, payload, natsClient);
return;
}
@@ -1353,7 +1377,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (TryCaptureJetStreamPublish(subject, payload, out var pubAck))
{
sender.RecordJetStreamPubAck(pubAck);
natsClient?.RecordJetStreamPubAck(pubAck);
// Replicate data messages to cluster peers so their JetStream stores also capture them.
// Route forwarding below is gated on subscriber interest, which JetStream streams don't
@@ -1426,18 +1450,34 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (queueGroup.Length == 0) continue;
// Simple round-robin -- pick based on total delivered across group
var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
// Undo the OutMsgs increment -- it will be incremented properly in SendMessageNoFlush
Interlocked.Decrement(ref sender.OutMsgs);
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
if (natsClient != null)
{
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
var idx = Math.Abs((int)Interlocked.Increment(ref natsClient.OutMsgs)) % queueGroup.Length;
// Undo the OutMsgs increment -- it will be incremented properly in SendMessageNoFlush
Interlocked.Decrement(ref natsClient.OutMsgs);
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
{
DeliverMessage(sub, subject, replyTo, headers, payload, pcd);
delivered = true;
break;
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
{
DeliverMessage(sub, subject, replyTo, headers, payload, pcd);
delivered = true;
break;
}
}
}
else
{
// Non-NatsClient sender: simple first-match
foreach (var sub in queueGroup)
{
if (sub.Client != null && sub.Client != sender)
{
DeliverMessage(sub, subject, replyTo, headers, payload, pcd);
delivered = true;
break;
}
}
}
}
@@ -1471,9 +1511,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
// No-responders: if nobody received the message and the publisher
// opted in, send back a 503 status HMSG on the reply subject.
if (!delivered && replyTo != null && sender.ClientOpts?.NoResponders == true)
if (!delivered && replyTo != null && sender.ClientOpts?.NoResponders == true && natsClient != null)
{
SendNoResponders(sender, replyTo);
SendNoResponders(natsClient, replyTo);
}
}
@@ -2267,9 +2307,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Publishes a $SYS.ACCOUNT.{account}.CONNECT advisory when a client
/// completes authentication. Maps to Go's sendConnectEvent in events.go.
/// </summary>
public void PublishConnectEvent(NatsClient client)
public void PublishConnectEvent(INatsClient client)
{
if (_eventSystem == null) return;
if (_eventSystem == null || client is not NatsClient natsClient) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.ConnectEvent, accountName);
var evt = new ConnectEventMsg
@@ -2277,7 +2317,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
Client = BuildEventClientInfo(natsClient),
};
SendInternalMsg(subject, null, evt);
}
@@ -2286,9 +2326,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Publishes a $SYS.ACCOUNT.{account}.DISCONNECT advisory when a client
/// disconnects. Maps to Go's sendDisconnectEvent in events.go.
/// </summary>
public void PublishDisconnectEvent(NatsClient client)
public void PublishDisconnectEvent(INatsClient client)
{
if (_eventSystem == null) return;
if (_eventSystem == null || client is not NatsClient natsClient) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.DisconnectEvent, accountName);
var evt = new DisconnectEventMsg
@@ -2296,62 +2336,71 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
Client = BuildEventClientInfo(natsClient),
Sent = new DataStats
{
Msgs = Interlocked.Read(ref client.OutMsgs),
Bytes = Interlocked.Read(ref client.OutBytes),
Msgs = Interlocked.Read(ref natsClient.OutMsgs),
Bytes = Interlocked.Read(ref natsClient.OutBytes),
},
Received = new DataStats
{
Msgs = Interlocked.Read(ref client.InMsgs),
Bytes = Interlocked.Read(ref client.InBytes),
Msgs = Interlocked.Read(ref natsClient.InMsgs),
Bytes = Interlocked.Read(ref natsClient.InBytes),
},
Reason = client.CloseReason.ToReasonString(),
Reason = natsClient.CloseReason.ToReasonString(),
};
SendInternalMsg(subject, null, evt);
}
public void RemoveClient(NatsClient client)
public void RemoveClient(INatsClient client)
{
// Publish disconnect advisory before removing client state
if (client.ConnectReceived)
PublishDisconnectEvent(client);
if (client is not NatsClient natsClient)
{
// Non-NatsClient (e.g. MqttNatsClientAdapter) — basic cleanup
_clients.TryRemove(client.Id, out _);
var subList = client.Account?.SubList ?? _globalAccount.SubList;
client.Account?.RemoveClient(client.Id);
return;
}
_clients.TryRemove(client.Id, out _);
_logger.LogDebug("Removed client {ClientId}", client.Id);
// Publish disconnect advisory before removing client state
if (natsClient.ConnectReceived)
PublishDisconnectEvent(natsClient);
_clients.TryRemove(natsClient.Id, out _);
_logger.LogDebug("Removed client {ClientId}", natsClient.Id);
var (tlsPeerCertSubject, tlsPeerCertSubjectPkSha256, tlsPeerCertSha256) =
TlsPeerCertMapper.ToClosedFields(client.TlsState?.PeerCert);
var (jwt, issuerKey, tags) = ExtractJwtMetadata(client.ClientOpts?.JWT);
var proxyKey = ExtractProxyKey(client.ClientOpts?.Username);
TlsPeerCertMapper.ToClosedFields(natsClient.TlsState?.PeerCert);
var (jwt, issuerKey, tags) = ExtractJwtMetadata(natsClient.ClientOpts?.JWT);
var proxyKey = ExtractProxyKey(natsClient.ClientOpts?.Username);
// Snapshot for closed-connections tracking (ring buffer auto-overwrites oldest when full)
_closedClients.Add(new ClosedClient
{
Cid = client.Id,
Ip = client.RemoteIp ?? "",
Port = client.RemotePort,
Start = client.StartTime,
Cid = natsClient.Id,
Ip = natsClient.RemoteIp ?? "",
Port = natsClient.RemotePort,
Start = natsClient.StartTime,
Stop = DateTime.UtcNow,
Reason = client.CloseReason.ToReasonString(),
Name = client.ClientOpts?.Name ?? "",
Lang = client.ClientOpts?.Lang ?? "",
Version = client.ClientOpts?.Version ?? "",
AuthorizedUser = client.ClientOpts?.Username ?? "",
Account = client.Account?.Name ?? "",
InMsgs = Interlocked.Read(ref client.InMsgs),
OutMsgs = Interlocked.Read(ref client.OutMsgs),
InBytes = Interlocked.Read(ref client.InBytes),
OutBytes = Interlocked.Read(ref client.OutBytes),
NumSubs = (uint)client.Subscriptions.Count,
Rtt = client.Rtt,
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
Reason = natsClient.CloseReason.ToReasonString(),
Name = natsClient.ClientOpts?.Name ?? "",
Lang = natsClient.ClientOpts?.Lang ?? "",
Version = natsClient.ClientOpts?.Version ?? "",
AuthorizedUser = natsClient.ClientOpts?.Username ?? "",
Account = natsClient.Account?.Name ?? "",
InMsgs = Interlocked.Read(ref natsClient.InMsgs),
OutMsgs = Interlocked.Read(ref natsClient.OutMsgs),
InBytes = Interlocked.Read(ref natsClient.InBytes),
OutBytes = Interlocked.Read(ref natsClient.OutBytes),
NumSubs = (uint)natsClient.Subscriptions.Count,
Rtt = natsClient.Rtt,
TlsVersion = natsClient.TlsState?.TlsVersion ?? "",
TlsCipherSuite = natsClient.TlsState?.CipherSuite ?? "",
TlsPeerCertSubject = tlsPeerCertSubject,
TlsPeerCertSubjectPkSha256 = tlsPeerCertSubjectPkSha256,
TlsPeerCertSha256 = tlsPeerCertSha256,
MqttClient = "", // populated when MQTT transport is implemented
MqttClient = natsClient.MqttClientId ?? "",
Stalls = 0,
Jwt = jwt,
IssuerKey = issuerKey,
@@ -2360,9 +2409,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
ProxyKey = proxyKey,
});
var subList = client.Account?.SubList ?? _globalAccount.SubList;
client.RemoveAllSubscriptions(subList);
client.Account?.RemoveClient(client.Id);
var ncSubList = natsClient.Account?.SubList ?? _globalAccount.SubList;
natsClient.RemoveAllSubscriptions(ncSubList);
natsClient.Account?.RemoveClient(natsClient.Id);
}
private void TrackEarlyClosedClient(Socket socket, ulong clientId, ClientClosedReason reason)