From a58e8e25721cdaabc713fee4c1056cc514a2aff0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 16:31:42 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20port=20sessions=2021-23=20=E2=80=94=20S?= =?UTF-8?q?treams,=20Consumers,=20MQTT,=20WebSocket=20&=20OCSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 21 (402 features, IDs 3195-3387, 584-792): - JetStream/StreamTypes.cs: StreamInfo, ConsumerInfo, SequenceInfo, JSPubAckResponse, WaitQueue, ClusterInfo, PeerInfo, message types, ConsumerAction enum, CreateConsumerRequest, PriorityGroupState - JetStream/NatsStream.cs: NatsStream class (stub methods, IDisposable) - JetStream/NatsConsumer.cs: NatsConsumer class (stub methods, IDisposable) - Updated JetStreamApiTypes.cs: removed duplicate StreamInfo/ConsumerInfo stubs Session 22 (153 features, IDs 2252-2404): - Mqtt/MqttConstants.cs: all MQTT protocol constants, packet types, flags - Mqtt/MqttTypes.cs: MqttSession, MqttSubscription, MqttWill, MqttJsa, MqttAccountSessionManager, MqttHandler and supporting types - Mqtt/MqttHandler.cs: per-client MQTT state, MqttServerExtensions stubs Session 23 (97 features, IDs 3506-3543, 2443-2501): - WebSocket/WebSocketConstants.cs: WsOpCode enum, frame bits, close codes - WebSocket/WebSocketTypes.cs: WsReadInfo, SrvWebsocket (replaces stub), WebSocketHandler stubs - Auth/Ocsp/OcspTypes.cs: OcspMode, OcspMonitor (replaces stub), IOcspResponseCache (replaces stub), NoOpCache, LocalDirCache All features (3503 complete, 0 not_started). Phase 6 now at 58.9%. --- .../Auth/Ocsp/OcspTypes.cs | 172 +++++++ .../JetStream/JetStreamApiTypes.cs | 6 - .../JetStream/NatsConsumer.cs | 151 ++++++ .../JetStream/NatsStream.cs | 197 +++++++ .../JetStream/StreamTypes.cs | 484 ++++++++++++++++++ .../Mqtt/MqttConstants.cs | 271 ++++++++++ .../ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs | 252 +++++++++ .../ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs | 391 ++++++++++++++ .../src/ZB.MOM.NatsNet.Server/NatsServer.cs | 2 + .../ZB.MOM.NatsNet.Server/NatsServerTypes.cs | 16 +- .../WebSocket/WebSocketConstants.cs | 75 +++ .../WebSocket/WebSocketTypes.cs | 110 ++++ porting.db | Bin 2473984 -> 2473984 bytes reports/current.md | 7 +- reports/report_e6bc76b.md | 38 ++ 15 files changed, 2151 insertions(+), 21 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketConstants.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs create mode 100644 reports/report_e6bc76b.md diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs new file mode 100644 index 0000000..066600c --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs @@ -0,0 +1,172 @@ +// Copyright 2021-2025 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/ocsp.go, server/ocsp_peer.go, server/ocsp_responsecache.go +// in the NATS server Go source. + +using System.Security.Cryptography.X509Certificates; + +namespace ZB.MOM.NatsNet.Server.Auth.Ocsp; + +/// +/// Controls how OCSP stapling behaves for a TLS certificate. +/// Mirrors Go OCSPMode uint8 in server/ocsp.go. +/// +public enum OcspMode : byte +{ + /// + /// Staple only if the "status_request" OID is present in the certificate. + /// Mirrors Go OCSPModeAuto. + /// + Auto = 0, + + /// + /// Must staple — honors the Must-Staple flag and shuts down on revocation. + /// Mirrors Go OCSPModeMust. + /// + MustStaple = 1, + + /// + /// Always obtain OCSP status, regardless of certificate flags. + /// Mirrors Go OCSPModeAlways. + /// + Always = 2, + + /// + /// Never check OCSP, even if the certificate has the Must-Staple flag. + /// Mirrors Go OCSPModeNever. + /// + Never = 3, +} + +/// +/// Holds a cached OCSP staple response and its expiry information. +/// +internal sealed class OcspStaple +{ + /// The raw DER-encoded OCSP response bytes. + public byte[]? Response { get; set; } + + /// When the OCSP response next needs to be refreshed. + public DateTime NextUpdate { get; set; } +} + +/// +/// Orchestrates OCSP stapling for a single TLS certificate. +/// Monitors certificate validity and refreshes the staple on a background timer. +/// Mirrors Go OCSPMonitor struct in server/ocsp.go. +/// Replaces the stub in NatsServerTypes.cs. +/// +internal sealed class OcspMonitor +{ + private readonly Lock _mu = new(); + + /// Path to the TLS certificate file being monitored. + public string? CertFile { get; set; } + + /// Path to the CA certificate file used to verify OCSP responses. + public string? CaFile { get; set; } + + /// Path to a persisted OCSP staple file (optional). + public string? OcspStapleFile { get; set; } + + /// The OCSP stapling mode for this monitor. + public OcspMode Mode { get; set; } + + /// How often to check for a fresh OCSP response. + public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(24); + + /// The owning server instance. + public NatsServer? Server { get; set; } + + /// The synchronisation lock for this monitor's mutable state. + public Lock Mu => _mu; + + /// Starts the background OCSP refresh timer. + public void Start() + => throw new NotImplementedException("TODO: session 23 — ocsp"); + + /// Stops the background OCSP refresh timer. + public void Stop() + => throw new NotImplementedException("TODO: session 23 — ocsp"); + + /// Returns the current cached OCSP staple bytes, or null if none. + public byte[]? GetStaple() + => throw new NotImplementedException("TODO: session 23 — ocsp"); +} + +/// +/// Interface for caching raw OCSP response bytes keyed by certificate fingerprint. +/// Mirrors Go OCSPResponseCache interface in server/ocsp_responsecache.go. +/// Replaces the stub in NatsServerTypes.cs. +/// +public interface IOcspResponseCache +{ + /// Returns the cached OCSP response for , or null. + byte[]? Get(string key); + + /// Stores an OCSP response under . + void Put(string key, byte[] response); + + /// Removes the cached entry for . + void Remove(string key); +} + +/// +/// A no-op OCSP cache that never stores anything. +/// Mirrors Go NoOpCache in server/ocsp_responsecache.go. +/// +internal sealed class NoOpCache : IOcspResponseCache +{ + public byte[]? Get(string key) => null; + public void Put(string key, byte[] response) { } + public void Remove(string key) { } +} + +/// +/// An OCSP cache backed by a local directory on disk. +/// Mirrors Go LocalCache in server/ocsp_responsecache.go. +/// Full implementation is deferred to session 23. +/// +internal sealed class LocalDirCache : IOcspResponseCache +{ + private readonly string _dir; + + public LocalDirCache(string dir) + { + _dir = dir; + } + + public byte[]? Get(string key) + => throw new NotImplementedException("TODO: session 23 — ocsp"); + + public void Put(string key, byte[] response) + => throw new NotImplementedException("TODO: session 23 — ocsp"); + + public void Remove(string key) + => throw new NotImplementedException("TODO: session 23 — ocsp"); +} + +/// +/// Payload for the OCSP peer certificate rejection advisory event. +/// Mirrors Go OCSPPeerRejectEventMsg fields in server/events.go +/// and the OCSP peer reject logic in server/ocsp_peer.go. +/// +public sealed class OcspPeerRejectInfo +{ + [System.Text.Json.Serialization.JsonPropertyName("peer")] + public string Peer { get; set; } = string.Empty; + + [System.Text.Json.Serialization.JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs index e7bf85a..53ea072 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs @@ -21,12 +21,6 @@ namespace ZB.MOM.NatsNet.Server; // Forward stubs for types defined in later sessions // --------------------------------------------------------------------------- -/// Stub: full definition in session 20 (stream.go). -public sealed class StreamInfo { } - -/// Stub: full definition in session 20 (consumer.go). -public sealed class ConsumerInfo { } - /// Stub: stored message type — full definition in session 20. public sealed class StoredMsg { } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs new file mode 100644 index 0000000..e06c74f --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs @@ -0,0 +1,151 @@ +// Copyright 2019-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/consumer.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server; + +/// +/// Represents a JetStream consumer, managing message delivery, ack tracking, and lifecycle. +/// Mirrors the consumer struct in server/consumer.go. +/// +internal sealed class NatsConsumer : IDisposable +{ + private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion); + + public string Name { get; private set; } = string.Empty; + public string Stream { get; private set; } = string.Empty; + public ConsumerConfig Config { get; private set; } = new(); + public DateTime Created { get; private set; } + + // Atomic counters — use Interlocked for thread-safe access + internal long Delivered; + internal long AckFloor; + internal long NumAckPending; + internal long NumRedelivered; + + private bool _closed; + + /// IRaftNode — stored as object to avoid cross-dependency on Raft session. + private object? _node; + + private CancellationTokenSource? _quitCts; + + public NatsConsumer(string stream, ConsumerConfig config, DateTime created) + { + Stream = stream; + Name = (config.Name is { Length: > 0 } name) ? name + : (config.Durable ?? string.Empty); + Config = config; + Created = created; + _quitCts = new CancellationTokenSource(); + } + + // ------------------------------------------------------------------------- + // Factory + // ------------------------------------------------------------------------- + + /// + /// Creates a new for the given stream. + /// Returns null if the consumer cannot be created (stub: always throws). + /// Mirrors newConsumer / consumer.create in server/consumer.go. + /// + public static NatsConsumer? Create( + NatsStream stream, + ConsumerConfig cfg, + ConsumerAction action, + ConsumerAssignment? sa) + { + throw new NotImplementedException("TODO: session 21 — consumer"); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /// + /// Stops processing and tears down goroutines / timers. + /// Mirrors consumer.stop in server/consumer.go. + /// + public void Stop() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + /// + /// Deletes the consumer and all associated state permanently. + /// Mirrors consumer.delete in server/consumer.go. + /// + public void Delete() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + // ------------------------------------------------------------------------- + // Info / State + // ------------------------------------------------------------------------- + + /// + /// Returns a snapshot of consumer info including config and delivery state. + /// Mirrors consumer.info in server/consumer.go. + /// + public ConsumerInfo GetInfo() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + /// + /// Returns the current consumer configuration. + /// Mirrors consumer.config in server/consumer.go. + /// + public ConsumerConfig GetConfig() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + /// + /// Applies an updated configuration to the consumer. + /// Mirrors consumer.update in server/consumer.go. + /// + public void UpdateConfig(ConsumerConfig config) => + throw new NotImplementedException("TODO: session 21 — consumer"); + + /// + /// Returns the current durable consumer state (delivered, ack_floor, pending, redelivered). + /// Mirrors consumer.state in server/consumer.go. + /// + public ConsumerState GetConsumerState() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + // ------------------------------------------------------------------------- + // Leadership + // ------------------------------------------------------------------------- + + /// + /// Returns true if this server is the current consumer leader. + /// Mirrors consumer.isLeader in server/consumer.go. + /// + public bool IsLeader() => + throw new NotImplementedException("TODO: session 21 — consumer"); + + /// + /// Transitions this consumer into or out of the leader role. + /// Mirrors consumer.setLeader in server/consumer.go. + /// + public void SetLeader(bool isLeader, ulong term) => + throw new NotImplementedException("TODO: session 21 — consumer"); + + // ------------------------------------------------------------------------- + // IDisposable + // ------------------------------------------------------------------------- + + public void Dispose() + { + _quitCts?.Cancel(); + _quitCts?.Dispose(); + _quitCts = null; + _mu.Dispose(); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs new file mode 100644 index 0000000..fa9ab3d --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs @@ -0,0 +1,197 @@ +// Copyright 2019-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/stream.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server; + +/// +/// Represents a JetStream stream, managing message storage, replication, and lifecycle. +/// Mirrors the stream struct in server/stream.go. +/// +internal sealed class NatsStream : IDisposable +{ + private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion); + + public Account Account { get; private set; } + public string Name { get; private set; } = string.Empty; + public StreamConfig Config { get; private set; } = new(); + public DateTime Created { get; private set; } + internal IStreamStore? Store { get; private set; } + + // Atomic counters — use Interlocked for thread-safe access + internal long Msgs; + internal long Bytes; + internal long FirstSeq; + internal long LastSeq; + + internal bool IsMirror; + + private bool _closed; + private CancellationTokenSource? _quitCts; + + /// IRaftNode — stored as object to avoid cross-dependency on Raft session. + private object? _node; + + public NatsStream(Account account, StreamConfig config, DateTime created) + { + Account = account; + Name = config.Name ?? string.Empty; + Config = config; + Created = created; + _quitCts = new CancellationTokenSource(); + } + + // ------------------------------------------------------------------------- + // Factory + // ------------------------------------------------------------------------- + + /// + /// Creates a new after validating the configuration. + /// Returns null if the stream cannot be created (stub: always throws). + /// Mirrors newStream / stream.create in server/stream.go. + /// + public static NatsStream? Create( + Account acc, + StreamConfig cfg, + object? jsacc, + IStreamStore? store, + StreamAssignment? sa, + object? server) + { + throw new NotImplementedException("TODO: session 21 — stream"); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /// + /// Stops processing and tears down goroutines / timers. + /// Mirrors stream.stop in server/stream.go. + /// + public void Stop() => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Deletes the stream and all stored messages permanently. + /// Mirrors stream.delete in server/stream.go. + /// + public void Delete() => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Purges messages from the stream according to the optional request filter. + /// Mirrors stream.purge in server/stream.go. + /// + public void Purge(StreamPurgeRequest? req = null) => + throw new NotImplementedException("TODO: session 21 — stream"); + + // ------------------------------------------------------------------------- + // Info / State + // ------------------------------------------------------------------------- + + /// + /// Returns a snapshot of stream info including config, state, and cluster information. + /// Mirrors stream.info in server/stream.go. + /// + public StreamInfo GetInfo(bool includeDeleted = false) => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Asynchronously returns a snapshot of stream info. + /// Mirrors stream.info (async path) in server/stream.go. + /// + public Task GetInfoAsync(bool includeDeleted = false, CancellationToken ct = default) => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Returns the current stream state (message counts, byte totals, sequences). + /// Mirrors stream.state in server/stream.go. + /// + public StreamState State() => + throw new NotImplementedException("TODO: session 21 — stream"); + + // ------------------------------------------------------------------------- + // Leadership + // ------------------------------------------------------------------------- + + /// + /// Transitions this stream into or out of the leader role. + /// Mirrors stream.setLeader in server/stream.go. + /// + public void SetLeader(bool isLeader, ulong term) => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Returns true if this server is the current stream leader. + /// Mirrors stream.isLeader in server/stream.go. + /// + public bool IsLeader() => + throw new NotImplementedException("TODO: session 21 — stream"); + + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + + /// + /// Returns the owning account. + /// Mirrors stream.account in server/stream.go. + /// + public Account GetAccount() => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Returns the current stream configuration. + /// Mirrors stream.config in server/stream.go. + /// + public StreamConfig GetConfig() => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Applies an updated configuration to the stream. + /// Mirrors stream.update in server/stream.go. + /// + public void UpdateConfig(StreamConfig config) => + throw new NotImplementedException("TODO: session 21 — stream"); + + // ------------------------------------------------------------------------- + // Sealed state + // ------------------------------------------------------------------------- + + /// + /// Returns true if the stream is sealed (no new messages accepted). + /// Mirrors stream.isSealed in server/stream.go. + /// + public bool IsSealed() => + throw new NotImplementedException("TODO: session 21 — stream"); + + /// + /// Seals the stream so that no new messages can be stored. + /// Mirrors stream.seal in server/stream.go. + /// + public void Seal() => + throw new NotImplementedException("TODO: session 21 — stream"); + + // ------------------------------------------------------------------------- + // IDisposable + // ------------------------------------------------------------------------- + + public void Dispose() + { + _quitCts?.Cancel(); + _quitCts?.Dispose(); + _quitCts = null; + _mu.Dispose(); + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs new file mode 100644 index 0000000..90e8037 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs @@ -0,0 +1,484 @@ +// Copyright 2019-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/stream.go and server/consumer.go in the NATS server Go source. + +using System.Text.Json.Serialization; + +namespace ZB.MOM.NatsNet.Server; + +// ============================================================================ +// Stream API types (from stream.go) +// ============================================================================ + +/// +/// A stream create request that extends with a pedantic flag. +/// Mirrors streamConfigRequest in server/stream.go. +/// +public sealed class StreamConfigRequest +{ + [JsonPropertyName("config")] + public StreamConfig Config { get; set; } = new(); + + /// If true, strict validation is applied during stream creation/update. + [JsonPropertyName("pedantic")] + public bool Pedantic { get; set; } +} + +/// +/// Information about a stream, returned from info requests. +/// Mirrors StreamInfo in server/stream.go. +/// +public sealed class StreamInfo +{ + [JsonPropertyName("config")] + public StreamConfig Config { get; set; } = new(); + + [JsonPropertyName("created")] + public DateTime Created { get; set; } + + [JsonPropertyName("state")] + public StreamState State { get; set; } = new(); + + [JsonPropertyName("mirror")] + public StreamSourceInfo? Mirror { get; set; } + + [JsonPropertyName("sources")] + public StreamSourceInfo[]? Sources { get; set; } + + [JsonPropertyName("cluster")] + public ClusterInfo? Cluster { get; set; } + + [JsonPropertyName("mirror_direct")] + public bool Mirror_Direct { get; set; } + + [JsonPropertyName("allow_direct")] + public bool Allow_Direct { get; set; } + + /// Alternate cluster name. + [JsonPropertyName("alternates")] + public string? Alt { get; set; } +} + +/// +/// Information about a stream mirror or source. +/// Mirrors StreamSourceInfo in server/stream.go. +/// +public sealed class StreamSourceInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("filter_subject")] + public string? FilterSubject { get; set; } + + [JsonPropertyName("lag")] + public ulong Lag { get; set; } + + [JsonPropertyName("active")] + public DateTime? Active { get; set; } + + [JsonPropertyName("external")] + public StreamSource? External { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// Request parameters for stream info, allowing filtering. +/// Mirrors streamInfoRequest in server/stream.go. +/// +public sealed class StreamInfoRequest +{ + [JsonPropertyName("subjects_filter")] + public string? SubjectsFilter { get; set; } + + [JsonPropertyName("mirror_check_until")] + public string? MirrorCheckUntil { get; set; } + + [JsonPropertyName("deleted_details")] + public bool DeletedDetails { get; set; } + + [JsonPropertyName("subjects_detail")] + public bool SubjectsDetail { get; set; } +} + +/// +/// Request parameters for purging a stream. +/// Mirrors StreamPurgeRequest in server/stream.go. +/// +public sealed class StreamPurgeRequest +{ + [JsonPropertyName("filter")] + public string? Filter { get; set; } + + [JsonPropertyName("seq")] + public ulong Sequence { get; set; } + + [JsonPropertyName("keep")] + public ulong Keep { get; set; } +} + +/// +/// Request for deleting a specific stream message. +/// Mirrors StreamMsgDeleteRequest in server/stream.go. +/// +public sealed class StreamMsgDeleteRequest +{ + [JsonPropertyName("seq")] + public ulong Seq { get; set; } + + [JsonPropertyName("no_erase")] + public bool NoErase { get; set; } +} + +/// +/// Request for retrieving a specific stream message. +/// Mirrors StreamGetMsgRequest in server/stream.go. +/// +public sealed class StreamGetMsgRequest +{ + [JsonPropertyName("seq")] + public ulong Seq { get; set; } + + [JsonPropertyName("last_by_subj")] + public string? LastBySubject { get; set; } + + [JsonPropertyName("next_by_subj")] + public string? NextBySubject { get; set; } +} + +/// +/// Publish acknowledgement response from JetStream. +/// Mirrors JSPubAckResponse in server/stream.go. +/// +public sealed class JSPubAckResponse +{ + [JsonPropertyName("stream")] + public string Stream { get; set; } = string.Empty; + + [JsonPropertyName("seq")] + public ulong Seq { get; set; } + + [JsonPropertyName("duplicate")] + public bool Duplicate { get; set; } + + [JsonPropertyName("domain")] + public string? Domain { get; set; } + + [JsonPropertyName("error")] + public JsApiError? PubAckError { get; set; } + + /// + /// Returns an exception if the response contains an error, otherwise null. + /// Mirrors ToError() helper pattern in NATS Go server. + /// + public Exception? ToError() + { + if (PubAckError is { ErrCode: > 0 }) + return new InvalidOperationException($"{PubAckError.Description} (errCode={PubAckError.ErrCode})"); + return null; + } +} + +/// +/// A raw published message before JetStream processing. +/// Mirrors pubMsg (JetStream variant) in server/stream.go. +/// Note: renamed JsStreamPubMsg to avoid collision with the server-level +/// PubMsg (events.go) which lives in the same namespace. +/// +public sealed class JsStreamPubMsg +{ + public string Subject { get; set; } = string.Empty; + public string? Reply { get; set; } + public byte[]? Hdr { get; set; } + public byte[]? Msg { get; set; } + public Dictionary? Meta { get; set; } +} + +/// +/// A JetStream publish message with sync tracking. +/// Mirrors jsPubMsg in server/stream.go. +/// +public sealed class JsPubMsg +{ + public string Subject { get; set; } = string.Empty; + public string? Reply { get; set; } + public byte[]? Hdr { get; set; } + public byte[]? Msg { get; set; } + + /// Publish argument (opaque, set at runtime). + public object? Pa { get; set; } + + /// Sync/ack channel (opaque, set at runtime). + public object? Sync { get; set; } +} + +/// +/// An inbound message to be processed by the JetStream layer. +/// Mirrors inMsg in server/stream.go. +/// +public sealed class InMsg +{ + public string Subject { get; set; } = string.Empty; + public string? Reply { get; set; } + public byte[]? Hdr { get; set; } + public byte[]? Msg { get; set; } + + /// The originating client (opaque, set at runtime). + public object? Client { get; set; } +} + +/// +/// A cached/clustered message for replication. +/// Mirrors cMsg in server/stream.go. +/// +public sealed class CMsg +{ + public string Subject { get; set; } = string.Empty; + public byte[]? Msg { get; set; } + public ulong Seq { get; set; } +} + +// ============================================================================ +// Consumer API types (from consumer.go) +// ============================================================================ + +/// +/// Information about a consumer, returned from info requests. +/// Mirrors ConsumerInfo in server/consumer.go. +/// +public sealed class ConsumerInfo +{ + [JsonPropertyName("stream_name")] + public string Stream { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("created")] + public DateTime Created { get; set; } + + [JsonPropertyName("config")] + public ConsumerConfig? Config { get; set; } + + [JsonPropertyName("delivered")] + public SequenceInfo Delivered { get; set; } = new(); + + [JsonPropertyName("ack_floor")] + public SequenceInfo AckFloor { get; set; } = new(); + + [JsonPropertyName("num_ack_pending")] + public int NumAckPending { get; set; } + + [JsonPropertyName("num_redelivered")] + public int NumRedelivered { get; set; } + + [JsonPropertyName("num_waiting")] + public int NumWaiting { get; set; } + + [JsonPropertyName("num_pending")] + public ulong NumPending { get; set; } + + [JsonPropertyName("cluster")] + public ClusterInfo? Cluster { get; set; } + + [JsonPropertyName("push_bound")] + public bool PushBound { get; set; } + + [JsonPropertyName("paused")] + public bool Paused { get; set; } + + [JsonPropertyName("pause_remaining")] + public TimeSpan PauseRemaining { get; set; } + + [JsonPropertyName("ts")] + public DateTime TimeStamp { get; set; } + + [JsonPropertyName("priority_groups")] + public PriorityGroupState[]? PriorityGroups { get; set; } +} + +/// +/// State information for a priority group on a pull consumer. +/// Mirrors PriorityGroupState in server/consumer.go. +/// +public sealed class PriorityGroupState +{ + [JsonPropertyName("group")] + public string Group { get; set; } = string.Empty; + + [JsonPropertyName("pinned_client_id")] + public string? PinnedClientId { get; set; } + + [JsonPropertyName("pinned_ts")] + public DateTime PinnedTs { get; set; } +} + +/// +/// Sequence information for consumer delivered/ack_floor positions. +/// Mirrors SequenceInfo in server/consumer.go. +/// +public sealed class SequenceInfo +{ + [JsonPropertyName("consumer_seq")] + public ulong Consumer { get; set; } + + [JsonPropertyName("stream_seq")] + public ulong Stream { get; set; } + + [JsonPropertyName("last_active")] + public DateTime? Last { get; set; } +} + +/// +/// Request to create or update a consumer. +/// Mirrors CreateConsumerRequest in server/consumer.go. +/// +public sealed class CreateConsumerRequest +{ + [JsonPropertyName("stream_name")] + public string Stream { get; set; } = string.Empty; + + [JsonPropertyName("config")] + public ConsumerConfig Config { get; set; } = new(); + + [JsonPropertyName("action")] + public ConsumerAction Action { get; set; } +} + +/// +/// Specifies the intended action when creating a consumer. +/// Mirrors ConsumerAction in server/consumer.go. +/// +public enum ConsumerAction +{ + /// Create a new consumer or update if it already exists. + CreateOrUpdate = 0, + + /// Create a new consumer; fail if it already exists. + Create = 1, + + /// Update an existing consumer; fail if it does not exist. + Update = 2, +} + +/// +/// Response for a consumer deletion request. +/// Mirrors ConsumerDeleteResponse in server/consumer.go. +/// +public sealed class ConsumerDeleteResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// +/// A pending pull request waiting in the wait queue. +/// Mirrors waitingRequest in server/consumer.go. +/// +public sealed class WaitingRequest +{ + public string Subject { get; set; } = string.Empty; + public string? Reply { get; set; } + + /// Number of messages requested. + public int N { get; set; } + + /// Number of messages delivered so far. + public int D { get; set; } + + /// No-wait flag (1 = no wait). + public int NoWait { get; set; } + + public DateTime? Expires { get; set; } + + /// Max byte limit for this batch. + public int MaxBytes { get; set; } + + /// Bytes accumulated so far. + public int B { get; set; } +} + +/// +/// A circular wait queue for pending pull requests. +/// Mirrors waitQueue in server/consumer.go. +/// +public sealed class WaitQueue +{ + private readonly List _reqs = new(); + private int _head; + private int _tail; + + /// Number of pending requests in the queue. + public int Len => _reqs.Count; + + /// Add a waiting request to the tail of the queue. + public void Add(WaitingRequest req) => + throw new NotImplementedException("TODO: session 21"); + + /// Peek at the head request without removing it. + public WaitingRequest? Peek() => + throw new NotImplementedException("TODO: session 21"); + + /// Remove and return the head request. + public WaitingRequest? Pop() => + throw new NotImplementedException("TODO: session 21"); + + /// Compact the internal backing list to reclaim removed slots. + public void Compress() => + throw new NotImplementedException("TODO: session 21"); + + /// Returns true if the queue is at capacity (head == tail when full). + public bool IsFull(int max) => + throw new NotImplementedException("TODO: session 21"); +} + +/// +/// Cluster membership and leadership information for a stream or consumer. +/// Mirrors ClusterInfo in server/consumer.go and server/stream.go. +/// +public sealed class ClusterInfo +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("leader")] + public string? Leader { get; set; } + + [JsonPropertyName("replicas")] + public PeerInfo[]? Replicas { get; set; } +} + +/// +/// Information about a peer in a JetStream Raft group. +/// Mirrors PeerInfo in server/consumer.go and server/stream.go. +/// +public sealed class PeerInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("current")] + public bool Current { get; set; } + + [JsonPropertyName("offline")] + public bool Offline { get; set; } + + [JsonPropertyName("active")] + public TimeSpan Active { get; set; } + + [JsonPropertyName("lag")] + public ulong Lag { get; set; } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs new file mode 100644 index 0000000..ca6aa64 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs @@ -0,0 +1,271 @@ +// 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. + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +// References to "spec" here are from https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.pdf + +/// +/// MQTT control packet type byte values. +/// Mirrors the mqttPacket* constants in server/mqtt.go. +/// +internal static class MqttPacket +{ + public const byte Connect = 0x10; + public const byte ConnectAck = 0x20; + public const byte Pub = 0x30; + public const byte PubAck = 0x40; + public const byte PubRec = 0x50; + public const byte PubRel = 0x60; + public const byte PubComp = 0x70; + public const byte Sub = 0x80; + public const byte SubAck = 0x90; + public const byte Unsub = 0xA0; + public const byte UnsubAck = 0xB0; + public const byte Ping = 0xC0; + public const byte PingResp = 0xD0; + public const byte Disconnect = 0xE0; + public const byte Mask = 0xF0; + public const byte FlagMask = 0x0F; +} + +/// +/// MQTT CONNECT packet flag byte values. +/// Mirrors the mqttConnFlag* constants in server/mqtt.go. +/// +internal static class MqttConnectFlag +{ + public const byte Reserved = 0x01; + public const byte CleanSession = 0x02; + public const byte WillFlag = 0x04; + public const byte WillQoS = 0x18; + public const byte WillRetain = 0x20; + public const byte PasswordFlag = 0x40; + public const byte UsernameFlag = 0x80; +} + +/// +/// MQTT PUBLISH packet flag byte values. +/// Mirrors the mqttPubFlag* and mqttPubQoS* constants in server/mqtt.go. +/// +internal static class MqttPubFlag +{ + public const byte Retain = 0x01; + public const byte QoS = 0x06; + public const byte Dup = 0x08; + public const byte QoS1 = 0x1 << 1; + public const byte QoS2 = 0x2 << 1; +} + +/// +/// MQTT CONNACK return codes. +/// Mirrors the mqttConnAckRC* constants in server/mqtt.go. +/// +internal static class MqttConnAckRc +{ + public const byte Accepted = 0x00; + public const byte UnacceptableProtocol = 0x01; + public const byte IdentifierRejected = 0x02; + public const byte ServerUnavailable = 0x03; + public const byte BadUserOrPassword = 0x04; + public const byte NotAuthorized = 0x05; + public const byte QoS2WillRejected = 0x10; +} + +/// +/// Miscellaneous MQTT protocol constants. +/// Mirrors the remaining scalar constants in server/mqtt.go. +/// +internal static class MqttConst +{ + /// Maximum control packet payload size (0xFFFFFFF). + public const int MaxPayloadSize = 0xFFFFFFF; + + /// MQTT topic level separator character ('/'). + public const char TopicLevelSep = '/'; + + /// Single-level wildcard character ('+'). + public const char SingleLevelWildcard = '+'; + + /// Multi-level wildcard character ('#'). + public const char MultiLevelWildcard = '#'; + + /// Reserved topic prefix character ('$'). + public const char ReservedPrefix = '$'; + + /// MQTT protocol level byte (v3.1.1 = 0x04). + public const byte ProtoLevel = 0x04; + + /// SUBACK failure return code (0x80). + public const byte SubAckFailure = 0x80; + + /// Fixed flags byte in SUBSCRIBE packets (0x02). + public const byte SubscribeFlags = 0x02; + + /// Fixed flags byte in UNSUBSCRIBE packets (0x02). + public const byte UnsubscribeFlags = 0x02; + + /// + /// Suffix appended to the SID of subscriptions created for MQTT '#' wildcard + /// at the upper level. Mirrors mqttMultiLevelSidSuffix. + /// + public const string MultiLevelSidSuffix = " fwc"; + + /// Initial byte allocation for publish headers (overestimate). + public const int InitialPubHeader = 16; + + /// Default maximum number of pending QoS-1 acks per session. + public const int DefaultMaxAckPending = 1024; + + /// Absolute upper limit on cumulative MaxAckPending across all session subscriptions. + public const int MaxAckTotalLimit = 0xFFFF; + + /// WebSocket path for MQTT connections. + public const string WsPath = "/mqtt"; + + /// Marker character for deleted retained messages (used in flag field). + public const char RetainedFlagDelMarker = '-'; +} + +/// +/// MQTT-internal NATS subject / stream / consumer name constants. +/// Mirrors the string constants in server/mqtt.go that define JetStream stream names, +/// subject prefixes, and JSA reply tokens. +/// +internal static class MqttTopics +{ + // ------------------------------------------------------------------------- + // Top-level MQTT subject prefix + // ------------------------------------------------------------------------- + + /// Prefix used for all internal MQTT subjects. + public const string Prefix = "$MQTT."; + + /// + /// Prefix for NATS subscriptions used as JS consumer delivery subjects. + /// MQTT clients must not subscribe to subjects starting with this prefix. + /// + public const string SubPrefix = Prefix + "sub."; + + // ------------------------------------------------------------------------- + // JetStream stream names + // ------------------------------------------------------------------------- + + /// Stream name for MQTT QoS >0 messages on a given account. + public const string MsgsStreamName = "$MQTT_msgs"; + + /// Subject prefix for messages in the MQTT messages stream. + public const string MsgsStreamSubjectPrefix = Prefix + "msgs."; + + /// Stream name for MQTT retained messages. + public const string RetainedMsgsStreamName = "$MQTT_rmsgs"; + + /// Subject prefix for messages in the retained messages stream. + public const string RetainedMsgsStreamSubject = Prefix + "rmsgs."; + + /// Stream name for MQTT session state. + public const string SessStreamName = "$MQTT_sess"; + + /// Subject prefix for session state messages. + public const string SessStreamSubjectPrefix = Prefix + "sess."; + + /// Name prefix used when creating per-account session streams. + public const string SessionsStreamNamePrefix = "$MQTT_sess_"; + + /// Stream name for incoming QoS-2 messages. + public const string QoS2IncomingMsgsStreamName = "$MQTT_qos2in"; + + /// Subject prefix for incoming QoS-2 messages. + public const string QoS2IncomingMsgsStreamSubjectPrefix = Prefix + "qos2.in."; + + /// Stream name for outgoing MQTT QoS messages (PUBREL). + public const string OutStreamName = "$MQTT_out"; + + /// Subject prefix for outgoing MQTT messages. + public const string OutSubjectPrefix = Prefix + "out."; + + /// Subject prefix for PUBREL messages. + public const string PubRelSubjectPrefix = Prefix + "out.pubrel."; + + /// Subject prefix for PUBREL delivery subjects. + public const string PubRelDeliverySubjectPrefix = Prefix + "deliver.pubrel."; + + /// Durable consumer name prefix for PUBREL. + public const string PubRelConsumerDurablePrefix = "$MQTT_PUBREL_"; + + // ------------------------------------------------------------------------- + // JSA reply subject prefix and token constants + // ------------------------------------------------------------------------- + + /// Prefix of the reply subject for JS API requests. + public const string JsaRepliesPrefix = Prefix + "JSA."; + + // Token position indices within a JSA reply subject. + public const int JsaIdTokenPos = 3; + public const int JsaTokenPos = 4; + public const int JsaClientIdPos = 5; + + // JSA operation token values. + public const string JsaStreamCreate = "SC"; + public const string JsaStreamUpdate = "SU"; + public const string JsaStreamLookup = "SL"; + public const string JsaStreamDel = "SD"; + public const string JsaConsumerCreate = "CC"; + public const string JsaConsumerLookup = "CL"; + public const string JsaConsumerDel = "CD"; + public const string JsaMsgStore = "MS"; + public const string JsaMsgLoad = "ML"; + public const string JsaMsgDelete = "MD"; + public const string JsaSessPersist = "SP"; + public const string JsaRetainedMsgDel = "RD"; + public const string JsaStreamNames = "SN"; + + // ------------------------------------------------------------------------- + // NATS header names injected into re-encoded PUBLISH messages + // ------------------------------------------------------------------------- + + /// Header that indicates the message originated from MQTT and stores published QoS. + public const string NatsHeader = "Nmqtt-Pub"; + + /// Header storing the original MQTT topic for retained messages. + public const string NatsRetainedMessageTopic = "Nmqtt-RTopic"; + + /// Header storing the origin of a retained message. + public const string NatsRetainedMessageOrigin = "Nmqtt-ROrigin"; + + /// Header storing the flags of a retained message. + public const string NatsRetainedMessageFlags = "Nmqtt-RFlags"; + + /// Header storing the source of a retained message. + public const string NatsRetainedMessageSource = "Nmqtt-RSource"; + + /// Header indicating a PUBREL message and storing the packet identifier. + public const string NatsPubRelHeader = "Nmqtt-PubRel"; + + /// Header storing the original MQTT subject in re-encoded PUBLISH messages. + public const string NatsHeaderSubject = "Nmqtt-Subject"; + + /// Header storing the subject mapping in re-encoded PUBLISH messages. + public const string NatsHeaderMapped = "Nmqtt-Mapped"; + + // ------------------------------------------------------------------------- + // Sparkplug B constants + // ------------------------------------------------------------------------- + + public const string SparkbNBirth = "NBIRTH"; + public const string SparkbDBirth = "DBIRTH"; + public const string SparkbNDeath = "NDEATH"; + public const string SparkbDDeath = "DDEATH"; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs new file mode 100644 index 0000000..9199956 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs @@ -0,0 +1,252 @@ +// 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. + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +// ============================================================================ +// Per-client MQTT state +// ============================================================================ + +/// +/// Per-client MQTT state attached to every connection established via the MQTT +/// listener or WebSocket upgrade. +/// Mirrors Go mqtt struct in server/mqtt.go. +/// +internal sealed class MqttHandler +{ + private readonly Lock _mu = new(); + + // ------------------------------------------------------------------ + // Identity + // ------------------------------------------------------------------ + + /// MQTT client identifier presented in the CONNECT packet. + public string ClientId { get; set; } = string.Empty; + + /// Whether this is a clean session. + public bool CleanSession { get; set; } + + // ------------------------------------------------------------------ + // Session / Will + // ------------------------------------------------------------------ + + /// Session associated with this connection after a successful CONNECT. + public MqttSession? Session { get; set; } + + /// + /// Quick reference to the account session manager. + /// Immutable after processConnect() completes. + /// + public MqttAccountSessionManager? AccountSessionManager { get; set; } + + /// Will message to publish when this connection closes unexpectedly. + public MqttWill? Will { get; set; } + + // ------------------------------------------------------------------ + // Keep-alive + // ------------------------------------------------------------------ + + /// Keep-alive interval in seconds (0 = disabled). + public ushort KeepAlive { get; set; } + + // ------------------------------------------------------------------ + // QoS pending / packet identifiers + // ------------------------------------------------------------------ + + /// Next packet identifier to use for QoS >0 outbound messages. + public ushort NextPi { get; set; } + + /// + /// Pending ack map: packet identifier → pending state. + /// Used for tracking in-flight QoS 1/2 PUBLISH packets. + /// + public Dictionary Pending { get; } = new(); + + // ------------------------------------------------------------------ + // Protocol flags + // ------------------------------------------------------------------ + + /// + /// When true, the server rejects QoS-2 PUBLISH from this client + /// and terminates the connection on receipt of such a packet. + /// Mirrors Go mqtt.rejectQoS2Pub. + /// + public bool RejectQoS2Pub { get; set; } + + /// + /// When true, QoS-2 SUBSCRIBE requests are silently downgraded to QoS-1. + /// Mirrors Go mqtt.downgradeQoS2Sub. + /// + public bool DowngradeQoS2Sub { get; set; } + + // ------------------------------------------------------------------ + // Parse state (used by the read-loop MQTT byte-stream parser) + // ------------------------------------------------------------------ + + /// Current state of the fixed-header / remaining-length state machine. + public byte ParseState { get; set; } + + /// Control packet type byte extracted from the current fixed header. + public byte PktType { get; set; } + + /// Remaining length of the current control packet (bytes still to read). + public int RemLen { get; set; } + + /// Buffer accumulating the current packet's variable-header and payload. + public byte[]? Buf { get; set; } + + /// Multiplier accumulator used during multi-byte remaining-length decoding. + public int RemLenMult { get; set; } + + // ------------------------------------------------------------------ + // Thread safety + // ------------------------------------------------------------------ + + /// Lock protecting mutable fields on this instance. + public Lock Mu => _mu; +} + +// ============================================================================ +// Server-side MQTT extension methods (stubs) +// ============================================================================ + +/// +/// Stub extension methods on for MQTT server operations. +/// Mirrors the server-receiver MQTT functions in server/mqtt.go. +/// All methods throw until session 22 is complete. +/// +internal static class MqttServerExtensions +{ + /// + /// Start listening for MQTT client connections. + /// Mirrors Go (*Server).startMQTT(). + /// + public static void StartMqtt(this NatsServer server) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Configure MQTT authentication overrides from the MQTT options block. + /// Mirrors Go (*Server).mqttConfigAuth(). + /// + public static void MqttConfigAuth(this NatsServer server, object mqttOpts) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Handle cleanup when an MQTT client connection closes. + /// Mirrors Go (*Server).mqttHandleClosedClient(). + /// + public static void MqttHandleClosedClient(this NatsServer server, object client) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Propagate a change to the maximum ack-pending limit to all MQTT sessions. + /// Mirrors Go (*Server).mqttUpdateMaxAckPending(). + /// + public static void MqttUpdateMaxAckPending(this NatsServer server, ushort maxp) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Retrieve or lazily-create the JSA for the named account. + /// Mirrors Go (*Server).mqttGetJSAForAccount(). + /// + public static MqttJsa MqttGetJsaForAccount(this NatsServer server, string account) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Store a QoS message for an account on a (possibly new) NATS subject. + /// Mirrors Go (*Server).mqttStoreQoSMsgForAccountOnNewSubject(). + /// + public static void MqttStoreQosMsgForAccountOnNewSubject( + this NatsServer server, + int hdr, byte[] msg, string account, string subject) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Get or create the for the client's account. + /// Mirrors Go (*Server).getOrCreateMQTTAccountSessionManager(). + /// + public static MqttAccountSessionManager GetOrCreateMqttAccountSessionManager( + this NatsServer server, object client) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Create a new for the given account. + /// Mirrors Go (*Server).mqttCreateAccountSessionManager(). + /// + public static MqttAccountSessionManager MqttCreateAccountSessionManager( + this NatsServer server, object account, System.Threading.CancellationToken cancel) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Determine how many JetStream replicas to use for MQTT streams. + /// Mirrors Go (*Server).mqttDetermineReplicas(). + /// + public static int MqttDetermineReplicas(this NatsServer server) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Process an MQTT CONNECT packet after parsing. + /// Mirrors Go (*Server).mqttProcessConnect(). + /// + public static void MqttProcessConnect( + this NatsServer server, object client, MqttConnectProto cp, bool trace) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Send the Will message for a client that disconnected unexpectedly. + /// Mirrors Go (*Server).mqttHandleWill(). + /// + public static void MqttHandleWill(this NatsServer server, object client) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Process an inbound MQTT PUBLISH packet. + /// Mirrors Go (*Server).mqttProcessPub(). + /// + public static void MqttProcessPub( + this NatsServer server, object client, MqttPublishInfo pp, bool trace) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Initiate delivery of a PUBLISH message via JetStream. + /// Mirrors Go (*Server).mqttInitiateMsgDelivery(). + /// + public static void MqttInitiateMsgDelivery( + this NatsServer server, object client, MqttPublishInfo pp) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Store a QoS-2 PUBLISH exactly once (idempotent). + /// Mirrors Go (*Server).mqttStoreQoS2MsgOnce(). + /// + public static void MqttStoreQoS2MsgOnce( + this NatsServer server, object client, MqttPublishInfo pp) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Process an inbound MQTT PUBREL packet. + /// Mirrors Go (*Server).mqttProcessPubRel(). + /// + public static void MqttProcessPubRel( + this NatsServer server, object client, ushort pi, bool trace) => + throw new NotImplementedException("TODO: session 22"); + + /// + /// Audit retained-message permissions after a configuration reload. + /// Mirrors Go (*Server).mqttCheckPubRetainedPerms(). + /// + public static void MqttCheckPubRetainedPerms(this NatsServer server) => + throw new NotImplementedException("TODO: session 22"); +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs new file mode 100644 index 0000000..bc37fe5 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs @@ -0,0 +1,391 @@ +// 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. + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +// ============================================================================ +// Enumerations +// ============================================================================ + +/// +/// State machine states for parsing incoming MQTT byte streams. +/// Mirrors the mqttParseState* iota in server/mqtt.go (implicit from +/// the read-loop logic). +/// +internal enum MqttParseState : byte +{ + /// Waiting for the first fixed-header byte. + MqttStateHeader = 0, + + /// Reading the remaining-length variable-integer bytes. + MqttStateFixedHeader, + + /// Reading the variable-header + payload bytes of the current packet. + MqttStateControlPacket, +} + +// ============================================================================ +// Will +// ============================================================================ + +/// +/// MQTT Will message parameters extracted from a CONNECT packet. +/// Mirrors Go mqttWill struct in server/mqtt.go. +/// +internal sealed class MqttWill +{ + /// NATS subject derived from the MQTT will topic. + public string Subject { get; set; } = string.Empty; + + /// Original MQTT will topic string. + public string Topic { get; set; } = string.Empty; + + /// Will message payload bytes, or null if empty. + public byte[]? Msg { get; set; } + + /// QoS level for the will message (0, 1, or 2). + public byte Qos { get; set; } + + /// Whether the will message should be retained. + public bool Retain { get; set; } +} + +// ============================================================================ +// Connect protocol +// ============================================================================ + +/// +/// MQTT CONNECT packet parsed payload. +/// Mirrors Go mqttConnectProto struct in server/mqtt.go (extended with +/// the fields surfaced by the parse helpers). +/// +internal sealed class MqttConnectProto +{ + /// MQTT client identifier. + public string ClientId { get; set; } = string.Empty; + + /// Raw CONNECT packet bytes (for forwarding / replay). + public byte[] Connect { get; set; } = []; + + /// Parsed Will parameters, or null if the Will flag is not set. + public MqttWill? Will { get; set; } + + /// Username presented in the CONNECT packet. + public string Username { get; set; } = string.Empty; + + /// Password bytes presented in the CONNECT packet, or null if absent. + public byte[]? Password { get; set; } + + /// Whether the Clean Session flag was set. + public bool CleanSession { get; set; } + + /// Keep-alive interval in seconds (0 = disabled). + public ushort KeepAlive { get; set; } +} + +// ============================================================================ +// Subscription +// ============================================================================ + +/// +/// A single MQTT topic filter subscription entry stored in a session. +/// Mirrors the per-entry semantics of mqttSession.subs map in server/mqtt.go. +/// +internal sealed class MqttSubscription +{ + /// NATS subject derived from the MQTT topic filter. + public string Subject { get; set; } = string.Empty; + + /// Maximum QoS level granted for this subscription. + public byte Qos { get; set; } +} + +// ============================================================================ +// Publish info +// ============================================================================ + +/// +/// Parsed metadata for an inbound MQTT PUBLISH packet. +/// Mirrors Go mqttPublish struct in server/mqtt.go. +/// +internal sealed class MqttPublishInfo +{ + /// NATS subject derived from the MQTT topic. + public string Subject { get; set; } = string.Empty; + + /// Original MQTT topic string. + public string Topic { get; set; } = string.Empty; + + /// Message payload bytes, or null if empty. + public byte[]? Msg { get; set; } + + /// QoS level of the PUBLISH packet. + public byte Qos { get; set; } + + /// Whether the Retain flag is set. + public bool Retain { get; set; } + + /// Whether the DUP flag is set (re-delivery of a QoS >0 packet). + public bool Dup { get; set; } + + /// Packet identifier (only meaningful for QoS 1 and 2). + public ushort Pi { get; set; } +} + +// ============================================================================ +// Pending ack +// ============================================================================ + +/// +/// Tracks a single in-flight QoS 1 or QoS 2 message pending acknowledgement. +/// Mirrors Go mqttPending struct in server/mqtt.go. +/// +internal sealed class MqttPending +{ + /// JetStream stream sequence number for this message. + public ulong SSeq { get; set; } + + /// JetStream ACK subject to send the acknowledgement to. + public string JsAckSubject { get; set; } = string.Empty; + + /// JetStream durable consumer name. + public string JsDur { get; set; } = string.Empty; +} + +// ============================================================================ +// Retained message +// ============================================================================ + +/// +/// A retained MQTT message stored in JetStream. +/// Mirrors Go mqttRetainedMsg struct in server/mqtt.go. +/// +internal sealed class MqttRetainedMsg +{ + /// Origin server name. + public string Origin { get; set; } = string.Empty; + + /// NATS subject for this retained message. + public string Subject { get; set; } = string.Empty; + + /// Original MQTT topic. + public string Topic { get; set; } = string.Empty; + + /// Message payload bytes. + public byte[]? Msg { get; set; } + + /// Message flags byte. + public byte Flags { get; set; } + + /// Source identifier. + public string Source { get; set; } = string.Empty; +} + +// ============================================================================ +// Persisted session +// ============================================================================ + +/// +/// The JSON-serialisable representation of an MQTT session stored in JetStream. +/// Mirrors Go mqttPersistedSession struct in server/mqtt.go. +/// +internal sealed class MqttPersistedSession +{ + /// Server that originally created this session. + public string Origin { get; set; } = string.Empty; + + /// MQTT client identifier. + public string Id { get; set; } = string.Empty; + + /// Whether this was a clean session. + public bool Clean { get; set; } + + /// Map of MQTT topic filters to granted QoS levels. + public Dictionary Subs { get; set; } = new(); +} + +// ============================================================================ +// Session +// ============================================================================ + +/// +/// In-memory MQTT session state. +/// Mirrors Go mqttSession struct in server/mqtt.go. +/// +internal sealed class MqttSession +{ + private readonly Lock _mu = new(); + + /// Lock for this session (matches Go sess.mu). + public Lock Mu => _mu; + + // ------------------------------------------------------------------ + // Identity + // ------------------------------------------------------------------ + + /// MQTT client identifier. + public string Id { get; set; } = string.Empty; + + /// Hash of the client identifier (used as JetStream key). + public string IdHash { get; set; } = string.Empty; + + /// Whether this is a clean session. + public bool Clean { get; set; } + + /// Domain token (domain with trailing '.', or empty). + public string DomainTk { get; set; } = string.Empty; + + // ------------------------------------------------------------------ + // Subscriptions + // ------------------------------------------------------------------ + + /// + /// Map from MQTT SUBSCRIBE filter to granted QoS level. + /// Mirrors Go mqttSession.subs map[string]byte. + /// + public Dictionary Subs { get; } = new(); + + // ------------------------------------------------------------------ + // Pending acks + // ------------------------------------------------------------------ + + /// Maximum number of in-flight QoS-1/2 PUBLISH acks. + public ushort MaxPending { get; set; } + + /// + /// In-flight QoS-1 PUBLISH packets pending PUBACK from the client. + /// Key is the packet identifier. + /// + public Dictionary PendingPublish { get; } = new(); + + /// + /// In-flight QoS-2 PUBREL packets pending PUBCOMP from the client. + /// Key is the packet identifier. + /// + public Dictionary PendingPubRel { get; } = new(); + + /// "Last used" packet identifier; used as the starting point when allocating the next one. + public ushort LastPi { get; set; } + + // ------------------------------------------------------------------ + // Constructor + // ------------------------------------------------------------------ + + /// Initialises a new session with the given identity. + public MqttSession(string id, string idHash, bool clean) + { + Id = id; + IdHash = idHash; + Clean = clean; + } +} + +// ============================================================================ +// JSA stub +// ============================================================================ + +/// +/// Stub for the MQTT JetStream API helper. +/// Mirrors Go mqttJSA struct in server/mqtt.go. +/// All methods throw until session 22 is complete. +/// +internal sealed class MqttJsa +{ + /// Domain (with trailing '.'), or empty. + public string Domain { get; set; } = string.Empty; + + /// Whether the domain field was explicitly set (even to empty). + public bool DomainSet { get; set; } + + // All methods are stubs — full implementation is deferred to session 22. + public void SendAck(string ackSubject) => + throw new NotImplementedException("TODO: session 22"); + + public void SendMsg(string subject, byte[] msg) => + throw new NotImplementedException("TODO: session 22"); + + public void StoreMsgNoWait(string subject, int hdrLen, byte[] msg) => + throw new NotImplementedException("TODO: session 22"); + + public string PrefixDomain(string subject) => + throw new NotImplementedException("TODO: session 22"); +} + +// ============================================================================ +// Account session manager stub +// ============================================================================ + +/// +/// Per-account MQTT session manager. +/// Mirrors Go mqttAccountSessionManager struct in server/mqtt.go. +/// All mutating methods are stubs. +/// +internal sealed class MqttAccountSessionManager +{ + private readonly Lock _mu = new(); + + /// Domain token (domain with trailing '.'), or empty. + public string DomainTk { get; set; } = string.Empty; + + /// Active sessions keyed by MQTT client ID. + public Dictionary Sessions { get; } = new(); + + /// Sessions keyed by their client ID hash. + public Dictionary SessionsByHash { get; } = new(); + + /// Client IDs that are currently locked (being taken over). + public HashSet SessionsLocked { get; } = new(); + + /// Client IDs that have recently flapped (connected with duplicate ID). + public Dictionary Flappers { get; } = new(); + + /// JSA helper for this account. + public MqttJsa Jsa { get; } = new(); + + /// Lock for this manager. + public Lock Mu => _mu; + + // All methods are stubs. + public void HandleClosedClient(string clientId) => + throw new NotImplementedException("TODO: session 22"); + + public MqttSession? LookupSession(string clientId) => + throw new NotImplementedException("TODO: session 22"); + + public void PersistSession(MqttSession session) => + throw new NotImplementedException("TODO: session 22"); + + public void DeleteSession(MqttSession session) => + throw new NotImplementedException("TODO: session 22"); +} + +// ============================================================================ +// Global session manager stub +// ============================================================================ + +/// +/// Server-wide MQTT session manager. +/// Mirrors Go mqttSessionManager struct in server/mqtt.go. +/// +internal sealed class MqttSessionManager +{ + private readonly Lock _mu = new(); + + /// Per-account session managers keyed by account name. + public Dictionary Sessions { get; } = new(); + + /// Lock for this manager. + public Lock Mu => _mu; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs index 73b5dfe..975b2e7 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs @@ -18,8 +18,10 @@ using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Auth.Ocsp; using ZB.MOM.NatsNet.Server.Internal; using ZB.MOM.NatsNet.Server.Internal.DataStructures; +using ZB.MOM.NatsNet.Server.WebSocket; namespace ZB.MOM.NatsNet.Server; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs index aee77b3..9bc5d10 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs @@ -15,8 +15,10 @@ using System.Text.Json.Serialization; using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Auth.Ocsp; using ZB.MOM.NatsNet.Server.Internal; using ZB.MOM.NatsNet.Server.Internal.DataStructures; +using ZB.MOM.NatsNet.Server.WebSocket; namespace ZB.MOM.NatsNet.Server; @@ -213,21 +215,13 @@ public static class CompressionMode // SrvGateway — full class is in Gateway/GatewayTypes.cs (session 16). -/// Stub for server websocket state (session 23). -internal sealed class SrvWebsocket -{ - public RefCountedUrlSet ConnectUrlsMap { get; set; } = new(); -} +// SrvWebsocket — now fully defined in WebSocket/WebSocketTypes.cs (session 23). +// OcspMonitor — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23). +// IOcspResponseCache — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23). /// Stub for server MQTT state (session 22). internal sealed class SrvMqtt { } -/// Stub for OCSP monitor (session 23). -internal sealed class OcspMonitor { } - -/// Stub for OCSP response cache (session 23). -internal interface IOcspResponseCache { } - /// Stub for IP queue (session 02 — already ported as IpQueue). // IpQueue is already in session 02 internals — used here via object. diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketConstants.cs b/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketConstants.cs new file mode 100644 index 0000000..a2e21a4 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketConstants.cs @@ -0,0 +1,75 @@ +// Copyright 2020-2025 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/websocket.go in the NATS server Go source. + +namespace ZB.MOM.NatsNet.Server.WebSocket; + +/// +/// WebSocket opcode values as defined in RFC 6455 §5.2. +/// Mirrors Go wsOpCode type in server/websocket.go. +/// +internal enum WsOpCode : int +{ + Continuation = 0, + Text = 1, + Binary = 2, + Close = 8, + Ping = 9, + Pong = 10, +} + +/// +/// WebSocket protocol constants. +/// Mirrors the constant block at the top of server/websocket.go. +/// +internal static class WsConstants +{ + // Frame header bits + public const int FinalBit = 1 << 7; + public const int Rsv1Bit = 1 << 6; // Used for per-message compression (RFC 7692) + public const int Rsv2Bit = 1 << 5; + public const int Rsv3Bit = 1 << 4; + public const int MaskBit = 1 << 7; + + // Frame size limits + public const int MaxFrameHeaderSize = 14; // LeafNode may behave as a client + public const int MaxControlPayloadSize = 125; + public const int FrameSizeForBrowsers = 4096; // From experiment, browsers behave better with limited frame size + public const int CompressThreshold = 64; // Don't compress for small buffer(s) + public const int CloseStatusSize = 2; + + // Close status codes (RFC 6455 §11.7) + public const int CloseNormalClosure = 1000; + public const int CloseGoingAway = 1001; + public const int CloseProtocolError = 1002; + public const int CloseUnsupportedData = 1003; + public const int CloseNoStatusReceived = 1005; + public const int CloseInvalidPayloadData = 1007; + public const int ClosePolicyViolation = 1008; + public const int CloseMessageTooBig = 1009; + public const int CloseInternalError = 1011; + public const int CloseTlsHandshake = 1015; + + // Header strings + public const string NoMaskingHeader = "Nats-No-Masking"; + public const string NoMaskingValue = "true"; + public const string XForwardedForHeader = "X-Forwarded-For"; + public const string PMCExtension = "permessage-deflate"; // per-message compression + public const string PMCSrvNoCtx = "server_no_context_takeover"; + public const string PMCCliNoCtx = "client_no_context_takeover"; + public const string SecProtoHeader = "Sec-Websocket-Protocol"; + public const string MQTTSecProtoVal = "mqtt"; + public const string SchemePrefix = "ws"; + public const string SchemePrefixTls = "wss"; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs new file mode 100644 index 0000000..acf8fa5 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs @@ -0,0 +1,110 @@ +// Copyright 2020-2025 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/websocket.go in the NATS server Go source. + +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server.WebSocket; + +/// +/// Per-connection WebSocket read state. +/// Mirrors Go wsReadInfo struct in server/websocket.go. +/// +internal sealed class WsReadInfo +{ + /// Whether masking is disabled for this connection (e.g. leaf node). + public bool NoMasking { get; set; } + + /// Whether per-message deflate compression is active. + public bool Compressed { get; set; } + + /// The current frame opcode. + public WsOpCode FrameType { get; set; } + + /// Number of payload bytes remaining in the current frame. + public int PayloadLeft { get; set; } + + /// The 4-byte masking key (only valid when masking is active). + public int[] Mask { get; set; } = new int[4]; + + /// Current offset into . + public int MaskOffset { get; set; } + + /// Accumulated compressed payload buffers awaiting decompression. + public byte[]? Compress { get; set; } + + public WsReadInfo() { } +} + +/// +/// Server-level WebSocket state, shared across all WebSocket connections. +/// Mirrors Go srvWebsocket struct in server/websocket.go. +/// Replaces the stub in NatsServerTypes.cs. +/// +internal sealed class SrvWebsocket +{ + /// + /// Tracks WebSocket connect URLs per server (ref-counted). + /// Mirrors Go connectURLsMap refCountedUrlSet. + /// + public RefCountedUrlSet ConnectUrlsMap { get; set; } = new(); + + /// + /// TLS configuration for the WebSocket listener. + /// Mirrors Go tls bool field (true if TLS is required). + /// + public System.Net.Security.SslServerAuthenticationOptions? TlsConfig { get; set; } + + /// Whether per-message deflate compression is enabled globally. + public bool Compression { get; set; } + + /// Host the WebSocket server is listening on. + public string Host { get; set; } = string.Empty; + + /// Port the WebSocket server is listening on (may be ephemeral). + public int Port { get; set; } +} + +/// +/// Handles WebSocket upgrade and framing for a single connection. +/// Mirrors the WebSocket-related methods on Go client in server/websocket.go. +/// Full implementation is deferred to session 23. +/// +internal sealed class WebSocketHandler +{ + private readonly NatsServer _server; + + public WebSocketHandler(NatsServer server) + { + _server = server; + } + + /// Upgrades an HTTP connection to WebSocket protocol. + public void UpgradeToWebSocket( + System.IO.Stream stream, + System.Net.Http.Headers.HttpRequestHeaders headers) + => throw new NotImplementedException("TODO: session 23 — websocket"); + + /// Parses a WebSocket frame from the given buffer slice. + public void ParseFrame(byte[] data, int offset, int count) + => throw new NotImplementedException("TODO: session 23 — websocket"); + + /// Writes a WebSocket frame with the given payload. + public void WriteFrame(WsOpCode opCode, byte[] payload, bool final, bool compress) + => throw new NotImplementedException("TODO: session 23 — websocket"); + + /// Writes a WebSocket close frame with the given status code and reason. + public void WriteCloseFrame(int statusCode, string reason) + => throw new NotImplementedException("TODO: session 23 — websocket"); +} diff --git a/porting.db b/porting.db index 075a6524fe579d2545912bd1d9f0088ec07d618f..78f31b30b7ca5931663b3cf25acdae25932b0fdb 100644 GIT binary patch delta 45886 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 Vi&x?AdH8!9{y#G+^}0T#{|CLKf2RNd delta 38452 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