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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user