From c30e67a69df13882ed1a70e1ae9a78c5cd35a53a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Mar 2026 14:09:23 -0400 Subject: [PATCH] Fix E2E test gaps and add comprehensive E2E + parity test suites - Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status --- Directory.Packages.props | 1 + NatsDotNet.slnx | 1 + ...02-25-gap-port-auth-and-accounts-design.md | 85 +++ ...6-02-25-gap-port-auth-and-accounts-plan.md | 103 ++++ ...026-02-25-gap-port-configuration-design.md | 83 +++ .../2026-02-25-gap-port-configuration-plan.md | 103 ++++ .../2026-02-25-gap-port-core-server-design.md | 98 +++ .../2026-02-25-gap-port-core-server-plan.md | 103 ++++ .../2026-02-25-gap-port-events-design.md | 76 +++ docs/plans/2026-02-25-gap-port-events-plan.md | 103 ++++ .../2026-02-25-gap-port-gateways-design.md | 75 +++ .../2026-02-25-gap-port-gateways-plan.md | 103 ++++ .../2026-02-25-gap-port-internal-ds-design.md | 86 +++ .../2026-02-25-gap-port-internal-ds-plan.md | 103 ++++ .../2026-02-25-gap-port-jetstream-design.md | 121 ++++ .../2026-02-25-gap-port-jetstream-plan.md | 103 ++++ .../2026-02-25-gap-port-leaf-nodes-design.md | 76 +++ .../2026-02-25-gap-port-leaf-nodes-plan.md | 103 ++++ .../2026-02-25-gap-port-logging-design.md | 80 +++ .../plans/2026-02-25-gap-port-logging-plan.md | 103 ++++ ...2-25-gap-port-misc-uncategorized-design.md | 84 +++ ...-02-25-gap-port-misc-uncategorized-plan.md | 103 ++++ .../2026-02-25-gap-port-monitoring-design.md | 88 +++ .../2026-02-25-gap-port-monitoring-plan.md | 103 ++++ docs/plans/2026-02-25-gap-port-mqtt-design.md | 76 +++ docs/plans/2026-02-25-gap-port-mqtt-plan.md | 103 ++++ .../2026-02-25-gap-port-protocol-design.md | 85 +++ .../2026-02-25-gap-port-protocol-plan.md | 103 ++++ docs/plans/2026-02-25-gap-port-raft-design.md | 76 +++ docs/plans/2026-02-25-gap-port-raft-plan.md | 103 ++++ .../2026-02-25-gap-port-routes-design.md | 76 +++ docs/plans/2026-02-25-gap-port-routes-plan.md | 103 ++++ ...026-02-25-gap-port-subscriptions-design.md | 81 +++ .../2026-02-25-gap-port-subscriptions-plan.md | 103 ++++ ...2026-02-25-gap-port-tls-security-design.md | 95 +++ .../2026-02-25-gap-port-tls-security-plan.md | 103 ++++ ...-25-gap-port-utilities-and-other-design.md | 93 +++ ...02-25-gap-port-utilities-and-other-plan.md | 103 ++++ .../2026-02-25-gap-port-websocket-design.md | 74 +++ .../2026-02-25-gap-port-websocket-plan.md | 103 ++++ docs/plans/2026-03-12-e2e-extended-design.md | 149 +++++ docs/plans/2026-03-12-e2e-extended-plan.md | 573 ++++++++++++++++++ docs/plans/2026-03-12-e2e-tests-design.md | 57 ++ docs/plans/2026-03-12-e2e-tests-plan.md | 166 +++++ gaps/auth-and-accounts.md | 46 +- gaps/configuration.md | 46 +- gaps/core-server.md | 98 +-- gaps/events.md | 128 ++-- gaps/execution.md | 90 +++ gaps/gateways.md | 60 +- gaps/internal-ds.md | 29 +- gaps/jetstream.md | 34 +- gaps/leaf-nodes.md | 83 +-- gaps/logging.md | 8 +- gaps/misc-uncategorized.md | 30 +- gaps/monitoring.md | 22 +- gaps/mqtt.md | 58 +- gaps/plans.md | 21 + gaps/protocol.md | 103 ++-- gaps/raft.md | 62 +- gaps/routes.md | 54 +- gaps/subscriptions.md | 64 +- gaps/tls-security.md | 52 +- gaps/utilities-and-other.md | 52 +- gaps/websocket.md | 16 +- src/NATS.Server.Host/Program.cs | 21 + src/NATS.Server/Auth/Account.cs | 106 ++++ src/NATS.Server/Auth/AuthService.cs | 90 ++- .../Auth/ExternalAuthCalloutAuthenticator.cs | 4 + src/NATS.Server/Auth/NKeyUser.cs | 3 + src/NATS.Server/Auth/TlsMapAuthenticator.cs | 79 +++ src/NATS.Server/Auth/User.cs | 2 + src/NATS.Server/ClientConnectionType.cs | 17 + .../Configuration/ConfigProcessor.cs | 253 +++++++- .../Configuration/GatewayOptions.cs | 109 ++++ .../Configuration/JetStreamOptions.cs | 65 ++ .../Configuration/LeafNodeOptions.cs | 103 ++++ .../Configuration/NatsConfLexer.cs | 40 +- .../Configuration/NatsConfParser.cs | 122 +++- .../Configuration/NatsConfToken.cs | 34 ++ src/NATS.Server/Events/EventCompressor.cs | 96 ++- src/NATS.Server/Events/EventSubjects.cs | 14 +- src/NATS.Server/Events/EventTypes.cs | 249 ++++++++ src/NATS.Server/Events/InternalEventSystem.cs | 53 +- src/NATS.Server/Gateways/GatewayConnection.cs | 1 + src/NATS.Server/Gateways/GatewayManager.cs | 84 ++- src/NATS.Server/Gateways/ReplyMapper.cs | 72 ++- src/NATS.Server/Internal/Avl/SequenceSet.cs | 32 +- .../Internal/Gsl/GenericSubjectList.cs | 10 +- .../Internal/SubjectTree/SubjectTree.cs | 134 ++++ .../Internal/SysMem/SystemMemory.cs | 21 + .../Internal/TimeHashWheel/HashWheel.cs | 6 + .../Api/Handlers/ConsumerApiHandlers.cs | 75 ++- .../Api/Handlers/StreamApiHandlers.cs | 42 +- .../JetStream/Api/JetStreamApiLimits.cs | 14 + .../JetStream/Api/JetStreamApiResponse.cs | 132 ++++ .../JetStream/Api/JetStreamApiRouter.cs | 8 +- src/NATS.Server/JetStream/ConsumerManager.cs | 18 + .../JetStream/JetStreamParityModels.cs | 58 ++ .../JetStream/Models/StreamConfig.cs | 11 + .../JetStream/Storage/FileStore.cs | 6 +- src/NATS.Server/JetStream/Storage/MemStore.cs | 4 +- src/NATS.Server/JetStream/StreamManager.cs | 71 ++- .../Validation/JetStreamConfigValidator.cs | 38 ++ src/NATS.Server/LeafNodes/LeafConnectInfo.cs | 40 ++ src/NATS.Server/LeafNodes/LeafConnection.cs | 144 ++++- src/NATS.Server/LeafNodes/LeafNodeManager.cs | 83 ++- src/NATS.Server/LeafNodes/LeafSubKey.cs | 46 ++ src/NATS.Server/Monitoring/ClosedClient.cs | 11 +- src/NATS.Server/Monitoring/Connz.cs | 69 ++- src/NATS.Server/Monitoring/ConnzHandler.cs | 63 +- src/NATS.Server/Monitoring/Healthz.cs | 50 ++ src/NATS.Server/Monitoring/MonitorServer.cs | 4 +- .../Monitoring/TlsPeerCertMapper.cs | 60 ++ src/NATS.Server/Monitoring/VarzHandler.cs | 55 +- src/NATS.Server/Mqtt/MqttBinaryDecoder.cs | 8 +- src/NATS.Server/Mqtt/MqttPacketReader.cs | 2 + src/NATS.Server/Mqtt/MqttPacketWriter.cs | 23 +- src/NATS.Server/Mqtt/MqttParityModels.cs | 90 +++ src/NATS.Server/Mqtt/MqttProtocolConstants.cs | 91 +++ src/NATS.Server/Mqtt/MqttRetainedStore.cs | 7 +- src/NATS.Server/NatsClient.cs | 22 + src/NATS.Server/NatsOptions.cs | 174 +++++- src/NATS.Server/NatsServer.cs | 450 +++++++++++++- src/NATS.Server/Protocol/NatsParser.cs | 34 +- src/NATS.Server/Protocol/NatsProtocol.cs | 37 ++ src/NATS.Server/Protocol/ProtoWire.cs | 89 +++ src/NATS.Server/Raft/CommitQueue.cs | 6 + src/NATS.Server/Raft/RaftConfig.cs | 20 + src/NATS.Server/Raft/RaftEntry.cs | 12 + src/NATS.Server/Raft/RaftNode.cs | 174 +++++- src/NATS.Server/Raft/RaftPeerState.cs | 34 +- src/NATS.Server/Raft/RaftStateExtensions.cs | 18 + src/NATS.Server/Routes/RouteConnection.cs | 133 +++- src/NATS.Server/Routes/RouteManager.cs | 171 +++++- src/NATS.Server/Server/RateCounter.cs | 54 ++ .../Server/ServerErrorConstants.cs | 27 + src/NATS.Server/Server/ServerUtilities.cs | 82 +++ src/NATS.Server/SlopwatchSuppressAttribute.cs | 12 + src/NATS.Server/Subscriptions/SubList.cs | 394 +++++++++++- src/NATS.Server/Subscriptions/SubListStats.cs | 40 +- src/NATS.Server/Subscriptions/SubjectMatch.cs | 55 ++ .../Subscriptions/SubjectTransform.cs | 184 ++++++ src/NATS.Server/Tls/OcspPeerConfig.cs | 192 ++++++ src/NATS.Server/Tls/OcspPeerMessages.cs | 85 +++ src/NATS.Server/Tls/TlsHelper.cs | 240 +++++++- .../WebSocket/WebSocketOptionsValidator.cs | 93 +++ src/NATS.Server/WebSocket/WsAuthConfig.cs | 19 + src/NATS.Server/WebSocket/WsUpgrade.cs | 41 ++ tests/NATS.E2E.Tests/AccountIsolationTests.cs | 98 +++ tests/NATS.E2E.Tests/AuthTests.cs | 262 ++++++++ tests/NATS.E2E.Tests/BasicTests.cs | 67 ++ tests/NATS.E2E.Tests/CoreMessagingTests.cs | 378 ++++++++++++ .../Infrastructure/AccountServerFixture.cs | 49 ++ .../Infrastructure/AuthServerFixture.cs | 82 +++ .../Infrastructure/Collections.cs | 4 + .../Infrastructure/E2ETestHelper.cs | 15 + .../Infrastructure/JetStreamServerFixture.cs | 45 ++ .../Infrastructure/MonitorServerFixture.cs | 36 ++ .../Infrastructure/NatsServerFixture.cs | 31 + .../Infrastructure/NatsServerProcess.cs | 205 +++++++ .../Infrastructure/TlsServerFixture.cs | 111 ++++ tests/NATS.E2E.Tests/JetStreamTests.cs | 295 +++++++++ tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj | 23 + .../SlopwatchSuppressAttribute.cs | 12 + tests/NATS.E2E.Tests/TlsTests.cs | 58 ++ ...untResponseAndInterestParityBatch1Tests.cs | 117 ++++ ...AuthModelAndCalloutConstantsParityTests.cs | 46 ++ .../Auth/AuthServiceParityBatch4Tests.cs | 89 +++ .../Auth/TlsMapAuthParityBatch1Tests.cs | 65 ++ .../ConfigPedanticParityBatch1Tests.cs | 83 +++ .../ConfigWarningsParityBatch1Tests.cs | 31 + .../EventApiAndSubjectsParityBatch2Tests.cs | 152 +++++ .../Events/EventCompressionTests.cs | 27 + ...ntServerInfoCapabilityParityBatch1Tests.cs | 46 ++ .../Events/RemoteServerEventTests.cs | 2 +- ...wayConnectionDirectionParityBatch2Tests.cs | 95 +++ .../GatewayRemoteConfigParityBatch3Tests.cs | 61 ++ .../GatewayReplyAndConfigParityBatch1Tests.cs | 104 ++++ .../GatewayServerAccessorParityBatch4Tests.cs | 37 ++ .../Internal/InternalDsParityBatch2Tests.cs | 91 +++ .../InternalDsPeriodicSamplerParityTests.cs | 37 ++ .../JetStreamApiLimitsParityBatch1Tests.cs | 108 ++++ .../JetStreamConfigModelParityBatch3Tests.cs | 129 ++++ .../JetStreamServerConfigParityBatch2Tests.cs | 78 +++ .../JetStreamMonitoringParityTests.cs | 2 +- ...nectionAndRemoteConfigParityBatch1Tests.cs | 136 +++++ .../LeafConnectionParityBatch3Tests.cs | 181 ++++++ .../LeafConnectionParityBatch4Tests.cs | 132 ++++ .../LeafNodeManagerParityBatch5Tests.cs | 135 +++++ .../LeafNodes/LeafSubKeyParityBatch2Tests.cs | 42 ++ .../Monitoring/ConnzParityFieldTests.cs | 39 +- .../Monitoring/ConnzParityFilterTests.cs | 10 +- ...onitoringHealthAndSortParityBatch1Tests.cs | 39 ++ .../Monitoring/TlsPeerCertParityTests.cs | 65 ++ .../Mqtt/MqttModelParityBatch3Tests.cs | 92 +++ .../MqttProtocolConstantsParityBatch1Tests.cs | 74 +++ .../MqttProtocolConstantsParityBatch2Tests.cs | 91 +++ .../MsgTraceGoParityTests.cs | 116 ++++ tests/NATS.Server.Tests/NatsConfLexerTests.cs | 17 + tests/NATS.Server.Tests/ParserTests.cs | 11 + .../Protocol/ProtoWireParityTests.cs | 87 +++ .../ProtocolDefaultConstantsGapParityTests.cs | 75 +++ .../ProtocolParserSnippetGapParityTests.cs | 45 ++ .../RaftConfigAndStateParityBatch1Tests.cs | 63 ++ .../Raft/RaftNodeParityBatch2Tests.cs | 149 +++++ .../Raft/RaftParityBatch3Tests.cs | 79 +++ .../RouteBatchProtoParityBatch3Tests.cs | 103 ++++ .../RouteInfoBroadcastParityBatch4Tests.cs | 84 +++ .../Routes/RouteParityHelpersBatch1Tests.cs | 170 ++++++ .../RouteRemoteSubCleanupParityBatch2Tests.cs | 139 +++++ ...eServerClientAccessorsParityBatch2Tests.cs | 95 +++ .../Server/CoreServerGapParityTests.cs | 282 +++++++++ .../CoreServerOptionsParityBatch3Tests.cs | 106 ++++ ...tilitiesAndRateCounterParityBatch1Tests.cs | 53 ++ ...tilitiesErrorConstantsParityBatch2Tests.cs | 28 + .../SubListCtorAndNotificationParityTests.cs | 46 ++ .../Subscriptions/SubListParityBatch2Tests.cs | 130 ++++ .../SubjectSubsetMatchParityBatch1Tests.cs | 40 ++ .../SubjectTransformParityBatch3Tests.cs | 83 +++ tests/NATS.Server.Tests/TlsHelperTests.cs | 39 ++ .../TlsOcspParityBatch1Tests.cs | 133 ++++ .../TlsOcspParityBatch2Tests.cs | 165 +++++ .../WebSocket/WebSocketOptionsTests.cs | 24 + ...SocketOptionsValidatorParityBatch2Tests.cs | 172 ++++++ .../WsUpgradeHelperParityBatch1Tests.cs | 66 ++ 226 files changed, 17801 insertions(+), 709 deletions(-) create mode 100644 docs/plans/2026-02-25-gap-port-auth-and-accounts-design.md create mode 100644 docs/plans/2026-02-25-gap-port-auth-and-accounts-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-configuration-design.md create mode 100644 docs/plans/2026-02-25-gap-port-configuration-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-core-server-design.md create mode 100644 docs/plans/2026-02-25-gap-port-core-server-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-events-design.md create mode 100644 docs/plans/2026-02-25-gap-port-events-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-gateways-design.md create mode 100644 docs/plans/2026-02-25-gap-port-gateways-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-internal-ds-design.md create mode 100644 docs/plans/2026-02-25-gap-port-internal-ds-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-jetstream-design.md create mode 100644 docs/plans/2026-02-25-gap-port-jetstream-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-leaf-nodes-design.md create mode 100644 docs/plans/2026-02-25-gap-port-leaf-nodes-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-logging-design.md create mode 100644 docs/plans/2026-02-25-gap-port-logging-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-misc-uncategorized-design.md create mode 100644 docs/plans/2026-02-25-gap-port-misc-uncategorized-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-monitoring-design.md create mode 100644 docs/plans/2026-02-25-gap-port-monitoring-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-mqtt-design.md create mode 100644 docs/plans/2026-02-25-gap-port-mqtt-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-protocol-design.md create mode 100644 docs/plans/2026-02-25-gap-port-protocol-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-raft-design.md create mode 100644 docs/plans/2026-02-25-gap-port-raft-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-routes-design.md create mode 100644 docs/plans/2026-02-25-gap-port-routes-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-subscriptions-design.md create mode 100644 docs/plans/2026-02-25-gap-port-subscriptions-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-tls-security-design.md create mode 100644 docs/plans/2026-02-25-gap-port-tls-security-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-utilities-and-other-design.md create mode 100644 docs/plans/2026-02-25-gap-port-utilities-and-other-plan.md create mode 100644 docs/plans/2026-02-25-gap-port-websocket-design.md create mode 100644 docs/plans/2026-02-25-gap-port-websocket-plan.md create mode 100644 docs/plans/2026-03-12-e2e-extended-design.md create mode 100644 docs/plans/2026-03-12-e2e-extended-plan.md create mode 100644 docs/plans/2026-03-12-e2e-tests-design.md create mode 100644 docs/plans/2026-03-12-e2e-tests-plan.md create mode 100644 gaps/execution.md create mode 100644 gaps/plans.md create mode 100644 src/NATS.Server/ClientConnectionType.cs create mode 100644 src/NATS.Server/Internal/SysMem/SystemMemory.cs create mode 100644 src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs create mode 100644 src/NATS.Server/JetStream/JetStreamParityModels.cs create mode 100644 src/NATS.Server/LeafNodes/LeafConnectInfo.cs create mode 100644 src/NATS.Server/LeafNodes/LeafSubKey.cs create mode 100644 src/NATS.Server/Monitoring/Healthz.cs create mode 100644 src/NATS.Server/Monitoring/TlsPeerCertMapper.cs create mode 100644 src/NATS.Server/Mqtt/MqttParityModels.cs create mode 100644 src/NATS.Server/Mqtt/MqttProtocolConstants.cs create mode 100644 src/NATS.Server/Protocol/ProtoWire.cs create mode 100644 src/NATS.Server/Raft/RaftConfig.cs create mode 100644 src/NATS.Server/Raft/RaftEntry.cs create mode 100644 src/NATS.Server/Raft/RaftStateExtensions.cs create mode 100644 src/NATS.Server/Server/RateCounter.cs create mode 100644 src/NATS.Server/Server/ServerErrorConstants.cs create mode 100644 src/NATS.Server/Server/ServerUtilities.cs create mode 100644 src/NATS.Server/SlopwatchSuppressAttribute.cs create mode 100644 src/NATS.Server/Tls/OcspPeerConfig.cs create mode 100644 src/NATS.Server/Tls/OcspPeerMessages.cs create mode 100644 src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs create mode 100644 src/NATS.Server/WebSocket/WsAuthConfig.cs create mode 100644 tests/NATS.E2E.Tests/AccountIsolationTests.cs create mode 100644 tests/NATS.E2E.Tests/AuthTests.cs create mode 100644 tests/NATS.E2E.Tests/BasicTests.cs create mode 100644 tests/NATS.E2E.Tests/CoreMessagingTests.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/Collections.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/JetStreamServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs create mode 100644 tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs create mode 100644 tests/NATS.E2E.Tests/JetStreamTests.cs create mode 100644 tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj create mode 100644 tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs create mode 100644 tests/NATS.E2E.Tests/TlsTests.cs create mode 100644 tests/NATS.Server.Tests/Auth/AccountResponseAndInterestParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Auth/AuthModelAndCalloutConstantsParityTests.cs create mode 100644 tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs create mode 100644 tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Configuration/ConfigPedanticParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Configuration/ConfigWarningsParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Events/EventApiAndSubjectsParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Events/EventServerInfoCapabilityParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Gateways/GatewayConnectionDirectionParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Gateways/GatewayRemoteConfigParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/Gateways/GatewayReplyAndConfigParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Gateways/GatewayServerAccessorParityBatch4Tests.cs create mode 100644 tests/NATS.Server.Tests/Internal/InternalDsParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Internal/InternalDsPeriodicSamplerParityTests.cs create mode 100644 tests/NATS.Server.Tests/JetStream/Api/JetStreamApiLimitsParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/JetStream/JetStreamConfigModelParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/JetStream/JetStreamServerConfigParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/LeafNodes/LeafConnectionAndRemoteConfigParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch4Tests.cs create mode 100644 tests/NATS.Server.Tests/LeafNodes/LeafNodeManagerParityBatch5Tests.cs create mode 100644 tests/NATS.Server.Tests/LeafNodes/LeafSubKeyParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Monitoring/MonitoringHealthAndSortParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs create mode 100644 tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs create mode 100644 tests/NATS.Server.Tests/Protocol/ProtocolDefaultConstantsGapParityTests.cs create mode 100644 tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs create mode 100644 tests/NATS.Server.Tests/Raft/RaftConfigAndStateParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/Routes/RouteBatchProtoParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/Routes/RouteInfoBroadcastParityBatch4Tests.cs create mode 100644 tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Server/CoreServerClientAccessorsParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs create mode 100644 tests/NATS.Server.Tests/Server/CoreServerOptionsParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/Server/UtilitiesAndRateCounterParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Server/UtilitiesErrorConstantsParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs create mode 100644 tests/NATS.Server.Tests/Subscriptions/SubListParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/Subscriptions/SubjectSubsetMatchParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/Subscriptions/SubjectTransformParityBatch3Tests.cs create mode 100644 tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs create mode 100644 tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/WebSocket/WebSocketOptionsValidatorParityBatch2Tests.cs create mode 100644 tests/NATS.Server.Tests/WebSocket/WsUpgradeHelperParityBatch1Tests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 1dcd8b5..87e438f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,5 +34,6 @@ + diff --git a/NatsDotNet.slnx b/NatsDotNet.slnx index db7e170..52b8dc1 100644 --- a/NatsDotNet.slnx +++ b/NatsDotNet.slnx @@ -5,5 +5,6 @@ + diff --git a/docs/plans/2026-02-25-gap-port-auth-and-accounts-design.md b/docs/plans/2026-02-25-gap-port-auth-and-accounts-design.md new file mode 100644 index 0000000..bd331ad --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-auth-and-accounts-design.md @@ -0,0 +1,85 @@ +# Auth & Accounts Full Parity Design + +**Date:** 2026-02-25 +**Category:** `auth-and-accounts` +**Gap Inventory:** `gaps/auth-and-accounts.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 128 +- `PARTIAL`: 38 +- `PORTED`: 64 +- `NOT_APPLICABLE`: 9 +- `DEFERRED`: 0 +- **Open parity work items:** 166 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/auth-and-accounts.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/auth-and-accounts.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/auth-and-accounts.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/auth-and-accounts.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/auth.go` +- `golang/nats-server/server/auth_callout.go` +- `golang/nats-server/server/nkey.go` +- `golang/nats-server/server/jwt.go` +- `golang/nats-server/server/accounts.go` + +### Go Test Files +- `golang/nats-server/server/auth_test.go` +- `golang/nats-server/server/auth_callout_test.go` +- `golang/nats-server/server/nkey_test.go` +- `golang/nats-server/server/jwt_test.go` +- `golang/nats-server/server/accounts_test.go` +- `golang/nats-server/server/trust_test.go` + +### .NET Source Files +- `src/NATS.Server/Auth/ (all files including Jwt/ subdirectory)` +- `src/NATS.Server/Imports/ (account import/export)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Auth/` +- `tests/NATS.Server.Tests/Accounts/` diff --git a/docs/plans/2026-02-25-gap-port-auth-and-accounts-plan.md b/docs/plans/2026-02-25-gap-port-auth-and-accounts-plan.md new file mode 100644 index 0000000..524ab3e --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-auth-and-accounts-plan.md @@ -0,0 +1,103 @@ +# Auth & Accounts Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `auth-and-accounts` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/auth-and-accounts.md` +- Primary .NET Source Anchor: `src/NATS.Server/Auth/ (all files including Jwt/ subdirectory)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Auth/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/auth-and-accounts.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Auth/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Auth/ (all files including Jwt/ subdirectory)` +- Modify: additional category files listed in `gaps/auth-and-accounts.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/auth-and-accounts.md` +- Modify: `gaps/auth-and-accounts.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/auth-and-accounts.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `auth-and-accounts`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `auth-and-accounts` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/auth-and-accounts.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-configuration-design.md b/docs/plans/2026-02-25-gap-port-configuration-design.md new file mode 100644 index 0000000..4846d4a --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-configuration-design.md @@ -0,0 +1,83 @@ +# Configuration Full Parity Design + +**Date:** 2026-02-25 +**Category:** `configuration` +**Gap Inventory:** `gaps/configuration.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 12 +- `PARTIAL`: 8 +- `PORTED`: 108 +- `NOT_APPLICABLE`: 3 +- `DEFERRED`: 0 +- **Open parity work items:** 20 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/configuration.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/configuration.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/configuration.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/configuration.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/conf/lex.go` +- `golang/nats-server/conf/parse.go` +- `golang/nats-server/conf/token.go` + +### Go Test Files +- `golang/nats-server/conf/lex_test.go` +- `golang/nats-server/conf/parse_test.go` +- `golang/nats-server/server/config_check_test.go` + +### .NET Source Files +- `src/NATS.Server/Configuration/NatsConfLexer.cs` +- `src/NATS.Server/Configuration/NatsConfParser.cs` +- `src/NATS.Server/Configuration/NatsConfToken.cs` +- `src/NATS.Server/Configuration/ConfigProcessor.cs` +- `src/NATS.Server/Configuration/ConfigReloader.cs` +- `All other files in src/NATS.Server/Configuration/` + +### .NET Test Files +- `tests/NATS.Server.Tests/Configuration/` diff --git a/docs/plans/2026-02-25-gap-port-configuration-plan.md b/docs/plans/2026-02-25-gap-port-configuration-plan.md new file mode 100644 index 0000000..2f21877 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-configuration-plan.md @@ -0,0 +1,103 @@ +# Configuration Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `configuration` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/configuration.md` +- Primary .NET Source Anchor: `src/NATS.Server/Configuration/NatsConfLexer.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Configuration/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/configuration.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Configuration/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Configuration/NatsConfLexer.cs` +- Modify: additional category files listed in `gaps/configuration.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/configuration.md` +- Modify: `gaps/configuration.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/configuration.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `configuration`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `configuration` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/configuration.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-core-server-design.md b/docs/plans/2026-02-25-gap-port-core-server-design.md new file mode 100644 index 0000000..f9a1100 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-core-server-design.md @@ -0,0 +1,98 @@ +# Core Server Full Parity Design + +**Date:** 2026-02-25 +**Category:** `core-server` +**Gap Inventory:** `gaps/core-server.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 55 +- `PARTIAL`: 30 +- `PORTED`: 123 +- `NOT_APPLICABLE`: 14 +- `DEFERRED`: 0 +- **Open parity work items:** 85 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/core-server.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/core-server.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/core-server.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/core-server.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/server.go` +- `golang/nats-server/server/client.go` +- `golang/nats-server/server/opts.go` +- `golang/nats-server/server/reload.go` +- `golang/nats-server/server/service.go` +- `golang/nats-server/server/signal.go` +- `golang/nats-server/main.go` + +### Go Test Files +- `golang/nats-server/server/server_test.go` +- `golang/nats-server/server/client_test.go` +- `golang/nats-server/server/opts_test.go` +- `golang/nats-server/server/reload_test.go` +- `golang/nats-server/server/signal_test.go` +- `golang/nats-server/server/test_test.go (test helpers)` + +### .NET Source Files +- `src/NATS.Server/NatsServer.cs` +- `src/NATS.Server/NatsClient.cs` +- `src/NATS.Server/NatsOptions.cs` +- `src/NATS.Server/ClientFlags.cs` +- `src/NATS.Server/ClientKind.cs` +- `src/NATS.Server/ClientClosedReason.cs` +- `src/NATS.Server/ClientTraceInfo.cs` +- `src/NATS.Server/ClosedState.cs` +- `src/NATS.Server/INatsClient.cs` +- `src/NATS.Server/InternalClient.cs` +- `src/NATS.Server/ServerStats.cs` +- `src/NATS.Server/SlowConsumerTracker.cs` +- `src/NATS.Server/MqttOptions.cs` +- `src/NATS.Server.Host/Program.cs` + +### .NET Test Files +- `tests/NATS.Server.Tests/ (root-level test files)` diff --git a/docs/plans/2026-02-25-gap-port-core-server-plan.md b/docs/plans/2026-02-25-gap-port-core-server-plan.md new file mode 100644 index 0000000..e141eb6 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-core-server-plan.md @@ -0,0 +1,103 @@ +# Core Server Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `core-server` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/core-server.md` +- Primary .NET Source Anchor: `src/NATS.Server/NatsServer.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/ (root-level test files)` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/core-server.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/ (root-level test files)` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/NatsServer.cs` +- Modify: additional category files listed in `gaps/core-server.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/core-server.md` +- Modify: `gaps/core-server.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/core-server.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `core-server`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `core-server` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/core-server.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-events-design.md b/docs/plans/2026-02-25-gap-port-events-design.md new file mode 100644 index 0000000..4888df7 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-events-design.md @@ -0,0 +1,76 @@ +# Events Full Parity Design + +**Date:** 2026-02-25 +**Category:** `events` +**Gap Inventory:** `gaps/events.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 154 +- `PARTIAL`: 20 +- `PORTED`: 42 +- `NOT_APPLICABLE`: 23 +- `DEFERRED`: 0 +- **Open parity work items:** 174 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/events.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/events.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/events.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/events.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/events.go` +- `golang/nats-server/server/msgtrace.go` + +### Go Test Files +- `golang/nats-server/server/events_test.go` +- `golang/nats-server/server/msgtrace_test.go` + +### .NET Source Files +- `src/NATS.Server/Events/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Events/` diff --git a/docs/plans/2026-02-25-gap-port-events-plan.md b/docs/plans/2026-02-25-gap-port-events-plan.md new file mode 100644 index 0000000..e62d2da --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-events-plan.md @@ -0,0 +1,103 @@ +# Events Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `events` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/events.md` +- Primary .NET Source Anchor: `src/NATS.Server/Events/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Events/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/events.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Events/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Events/ (all files)` +- Modify: additional category files listed in `gaps/events.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/events.md` +- Modify: `gaps/events.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/events.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `events`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `events` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/events.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-gateways-design.md b/docs/plans/2026-02-25-gap-port-gateways-design.md new file mode 100644 index 0000000..97b2c9b --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-gateways-design.md @@ -0,0 +1,75 @@ +# Gateways Full Parity Design + +**Date:** 2026-02-25 +**Category:** `gateways` +**Gap Inventory:** `gaps/gateways.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 57 +- `PARTIAL`: 37 +- `PORTED`: 10 +- `NOT_APPLICABLE`: 4 +- `DEFERRED`: 0 +- **Open parity work items:** 94 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/gateways.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/gateways.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/gateways.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/gateways.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/gateway.go` + +### Go Test Files +- `golang/nats-server/server/gateway_test.go` +- `golang/nats-server/test/gateway_test.go (integration)` + +### .NET Source Files +- `src/NATS.Server/Gateways/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Gateways/` diff --git a/docs/plans/2026-02-25-gap-port-gateways-plan.md b/docs/plans/2026-02-25-gap-port-gateways-plan.md new file mode 100644 index 0000000..0937b6d --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-gateways-plan.md @@ -0,0 +1,103 @@ +# Gateways Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `gateways` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/gateways.md` +- Primary .NET Source Anchor: `src/NATS.Server/Gateways/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Gateways/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/gateways.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Gateways/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Gateways/ (all files)` +- Modify: additional category files listed in `gaps/gateways.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/gateways.md` +- Modify: `gaps/gateways.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/gateways.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `gateways`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `gateways` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/gateways.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-internal-ds-design.md b/docs/plans/2026-02-25-gap-port-internal-ds-design.md new file mode 100644 index 0000000..61dde67 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-internal-ds-design.md @@ -0,0 +1,86 @@ +# Internal Data Structures Full Parity Design + +**Date:** 2026-02-25 +**Category:** `internal-ds` +**Gap Inventory:** `gaps/internal-ds.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 10 +- `PARTIAL`: 4 +- `PORTED`: 157 +- `NOT_APPLICABLE`: 8 +- `DEFERRED`: 0 +- **Open parity work items:** 14 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/internal-ds.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/internal-ds.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/internal-ds.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/internal-ds.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/avl/seqset.go` +- `golang/nats-server/server/stree/` +- `golang/nats-server/server/thw/thw.go` +- `golang/nats-server/server/gsl/gsl.go` +- `golang/nats-server/server/pse/` +- `golang/nats-server/server/sysmem/` + +### Go Test Files +- `golang/nats-server/server/avl/seqset_test.go` +- `golang/nats-server/server/stree/stree_test.go` +- `golang/nats-server/server/thw/thw_test.go` +- `golang/nats-server/server/gsl/gsl_test.go` + +### .NET Source Files +- `src/NATS.Server/Internal/ (all files including subdirectories)` +- `src/NATS.Server/Internal/Avl/` +- `src/NATS.Server/Internal/Gsl/` +- `src/NATS.Server/Internal/SubjectTree/` +- `src/NATS.Server/Internal/TimeHashWheel/` + +### .NET Test Files +- `tests/NATS.Server.Tests/Internal/ (all subdirectories)` diff --git a/docs/plans/2026-02-25-gap-port-internal-ds-plan.md b/docs/plans/2026-02-25-gap-port-internal-ds-plan.md new file mode 100644 index 0000000..9b9c5cf --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-internal-ds-plan.md @@ -0,0 +1,103 @@ +# Internal Data Structures Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `internal-ds` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/internal-ds.md` +- Primary .NET Source Anchor: `src/NATS.Server/Internal/ (all files including subdirectories)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Internal/ (all subdirectories)` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/internal-ds.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Internal/ (all subdirectories)` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Internal/ (all files including subdirectories)` +- Modify: additional category files listed in `gaps/internal-ds.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/internal-ds.md` +- Modify: `gaps/internal-ds.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/internal-ds.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `internal-ds`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `internal-ds` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/internal-ds.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-jetstream-design.md b/docs/plans/2026-02-25-gap-port-jetstream-design.md new file mode 100644 index 0000000..3149aff --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-jetstream-design.md @@ -0,0 +1,121 @@ +# JetStream Full Parity Design + +**Date:** 2026-02-25 +**Category:** `jetstream` +**Gap Inventory:** `gaps/jetstream.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 787 +- `PARTIAL`: 313 +- `PORTED`: 309 +- `NOT_APPLICABLE`: 55 +- `DEFERRED`: 0 +- **Open parity work items:** 1100 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/jetstream.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/jetstream.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/jetstream.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/jetstream.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/jetstream.go` +- `golang/nats-server/server/jetstream_api.go` +- `golang/nats-server/server/jetstream_events.go` +- `golang/nats-server/server/jetstream_errors.go` +- `golang/nats-server/server/jetstream_errors_generated.go` +- `golang/nats-server/server/jetstream_batching.go` +- `golang/nats-server/server/jetstream_versioning.go` +- `golang/nats-server/server/stream.go` +- `golang/nats-server/server/consumer.go` +- `golang/nats-server/server/store.go` +- `golang/nats-server/server/filestore.go` +- `golang/nats-server/server/memstore.go` +- `golang/nats-server/server/dirstore.go` +- `golang/nats-server/server/disk_avail.go` +- `golang/nats-server/server/jetstream_cluster.go` + +### Go Test Files +- `golang/nats-server/server/jetstream_test.go` +- `golang/nats-server/server/jetstream_consumer_test.go` +- `golang/nats-server/server/jetstream_errors_test.go` +- `golang/nats-server/server/jetstream_batching_test.go` +- `golang/nats-server/server/jetstream_versioning_test.go` +- `golang/nats-server/server/jetstream_helpers_test.go` +- `golang/nats-server/server/jetstream_jwt_test.go` +- `golang/nats-server/server/jetstream_tpm_test.go` +- `golang/nats-server/server/jetstream_sourcing_scaling_test.go` +- `golang/nats-server/server/jetstream_benchmark_test.go` +- `golang/nats-server/server/filestore_test.go` +- `golang/nats-server/server/memstore_test.go` +- `golang/nats-server/server/dirstore_test.go` +- `golang/nats-server/server/store_test.go` +- `golang/nats-server/server/jetstream_cluster_1_test.go through _4_test.go` +- `golang/nats-server/server/jetstream_super_cluster_test.go` +- `golang/nats-server/server/jetstream_leafnode_test.go` +- `golang/nats-server/server/jetstream_cluster_long_test.go` + +### .NET Source Files +- `src/NATS.Server/JetStream/` +- `src/NATS.Server/JetStream/Api/` +- `src/NATS.Server/JetStream/Consumers/` +- `src/NATS.Server/JetStream/Models/` +- `src/NATS.Server/JetStream/MirrorSource/` +- `src/NATS.Server/JetStream/Publish/` +- `src/NATS.Server/JetStream/Snapshots/` +- `src/NATS.Server/JetStream/Validation/` +- `src/NATS.Server/JetStream/Storage/` +- `src/NATS.Server/JetStream/Cluster/` + +### .NET Test Files +- `tests/NATS.Server.Tests/JetStream/` +- `tests/NATS.Server.Tests/JetStream/Api/` +- `tests/NATS.Server.Tests/JetStream/Cluster/` +- `tests/NATS.Server.Tests/JetStream/Consumers/` +- `tests/NATS.Server.Tests/JetStream/MirrorSource/` +- `tests/NATS.Server.Tests/JetStream/Snapshots/` +- `tests/NATS.Server.Tests/JetStream/Storage/` +- `tests/NATS.Server.Tests/JetStream/Streams/` diff --git a/docs/plans/2026-02-25-gap-port-jetstream-plan.md b/docs/plans/2026-02-25-gap-port-jetstream-plan.md new file mode 100644 index 0000000..d47e1e3 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-jetstream-plan.md @@ -0,0 +1,103 @@ +# JetStream Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `jetstream` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/jetstream.md` +- Primary .NET Source Anchor: `src/NATS.Server/JetStream/` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/JetStream/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/jetstream.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/JetStream/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/JetStream/` +- Modify: additional category files listed in `gaps/jetstream.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/jetstream.md` +- Modify: `gaps/jetstream.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/jetstream.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `jetstream`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `jetstream` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/jetstream.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-leaf-nodes-design.md b/docs/plans/2026-02-25-gap-port-leaf-nodes-design.md new file mode 100644 index 0000000..417a6c9 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-leaf-nodes-design.md @@ -0,0 +1,76 @@ +# Leaf Nodes Full Parity Design + +**Date:** 2026-02-25 +**Category:** `leaf-nodes` +**Gap Inventory:** `gaps/leaf-nodes.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 55 +- `PARTIAL`: 23 +- `PORTED`: 5 +- `NOT_APPLICABLE`: 1 +- `DEFERRED`: 0 +- **Open parity work items:** 78 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/leaf-nodes.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/leaf-nodes.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/leaf-nodes.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/leaf-nodes.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/leafnode.go` + +### Go Test Files +- `golang/nats-server/server/leafnode_test.go` +- `golang/nats-server/server/leafnode_proxy_test.go` +- `golang/nats-server/test/leafnode_test.go (integration)` + +### .NET Source Files +- `src/NATS.Server/LeafNodes/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/LeafNodes/` diff --git a/docs/plans/2026-02-25-gap-port-leaf-nodes-plan.md b/docs/plans/2026-02-25-gap-port-leaf-nodes-plan.md new file mode 100644 index 0000000..55ee74a --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-leaf-nodes-plan.md @@ -0,0 +1,103 @@ +# Leaf Nodes Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `leaf-nodes` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/leaf-nodes.md` +- Primary .NET Source Anchor: `src/NATS.Server/LeafNodes/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/LeafNodes/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/leaf-nodes.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/LeafNodes/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/LeafNodes/ (all files)` +- Modify: additional category files listed in `gaps/leaf-nodes.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/leaf-nodes.md` +- Modify: `gaps/leaf-nodes.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/leaf-nodes.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `leaf-nodes`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `leaf-nodes` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/leaf-nodes.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-logging-design.md b/docs/plans/2026-02-25-gap-port-logging-design.md new file mode 100644 index 0000000..f9d3f3c --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-logging-design.md @@ -0,0 +1,80 @@ +# Logging Full Parity Design + +**Date:** 2026-02-25 +**Category:** `logging` +**Gap Inventory:** `gaps/logging.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 0 +- `PARTIAL`: 4 +- `PORTED`: 54 +- `NOT_APPLICABLE`: 7 +- `DEFERRED`: 11 +- **Open parity work items:** 4 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/logging.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/logging.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/logging.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/logging.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/log.go` +- `golang/nats-server/logger/log.go` +- `golang/nats-server/logger/syslog.go` +- `golang/nats-server/logger/syslog_windows.go` + +### Go Test Files +- `golang/nats-server/server/log_test.go` +- `golang/nats-server/logger/log_test.go` +- `golang/nats-server/logger/syslog_test.go` +- `golang/nats-server/logger/syslog_windows_test.go` + +### .NET Source Files +- `(none` + +### .NET Test Files +- `(none` diff --git a/docs/plans/2026-02-25-gap-port-logging-plan.md b/docs/plans/2026-02-25-gap-port-logging-plan.md new file mode 100644 index 0000000..574c275 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-logging-plan.md @@ -0,0 +1,103 @@ +# Logging Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `logging` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/logging.md` +- Primary .NET Source Anchor: `(none` +- Primary .NET Test Anchor: `(none` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/logging.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `(none` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `(none` +- Modify: additional category files listed in `gaps/logging.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/logging.md` +- Modify: `gaps/logging.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/logging.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `logging`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `logging` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/logging.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-misc-uncategorized-design.md b/docs/plans/2026-02-25-gap-port-misc-uncategorized-design.md new file mode 100644 index 0000000..b8842f6 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-misc-uncategorized-design.md @@ -0,0 +1,84 @@ +# Misc / Uncategorized Full Parity Design + +**Date:** 2026-02-25 +**Category:** `misc-uncategorized` +**Gap Inventory:** `gaps/misc-uncategorized.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 8 +- `PARTIAL`: 13 +- `PORTED`: 2 +- `NOT_APPLICABLE`: 6 +- `DEFERRED`: 2 +- **Open parity work items:** 21 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/misc-uncategorized.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/misc-uncategorized.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/misc-uncategorized.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/misc-uncategorized.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `Files not fitting into other categories` +- `Small Go source files that don't belong to a major subsystem` +- `Platform-specific stubs or build-tag-only files` + +### Go Test Files +- `golang/nats-server/server/ping_test.go` +- `golang/nats-server/server/closed_conns_test.go` +- `golang/nats-server/server/norace_1_test.go` +- `golang/nats-server/server/norace_2_test.go` +- `golang/nats-server/server/benchmark_publish_test.go` +- `golang/nats-server/server/core_benchmarks_test.go` +- `Various integration tests in golang/nats-server/test/` + +### .NET Source Files +- `Any .NET source files not covered by other category files` + +### .NET Test Files +- `tests/NATS.Server.Tests/Stress/` +- `tests/NATS.Server.Tests/Parity/` +- `Other root-level test files` diff --git a/docs/plans/2026-02-25-gap-port-misc-uncategorized-plan.md b/docs/plans/2026-02-25-gap-port-misc-uncategorized-plan.md new file mode 100644 index 0000000..2dfb0c8 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-misc-uncategorized-plan.md @@ -0,0 +1,103 @@ +# Misc / Uncategorized Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `misc-uncategorized` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/misc-uncategorized.md` +- Primary .NET Source Anchor: `Any .NET source files not covered by other category files` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Stress/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/misc-uncategorized.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Stress/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `Any .NET source files not covered by other category files` +- Modify: additional category files listed in `gaps/misc-uncategorized.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/misc-uncategorized.md` +- Modify: `gaps/misc-uncategorized.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/misc-uncategorized.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `misc-uncategorized`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `misc-uncategorized` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/misc-uncategorized.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-monitoring-design.md b/docs/plans/2026-02-25-gap-port-monitoring-design.md new file mode 100644 index 0000000..11500bd --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-monitoring-design.md @@ -0,0 +1,88 @@ +# Monitoring Full Parity Design + +**Date:** 2026-02-25 +**Category:** `monitoring` +**Gap Inventory:** `gaps/monitoring.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 61 +- `PARTIAL`: 42 +- `PORTED`: 63 +- `NOT_APPLICABLE`: 3 +- `DEFERRED`: 0 +- **Open parity work items:** 103 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/monitoring.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/monitoring.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/monitoring.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/monitoring.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/monitor.go` +- `golang/nats-server/server/monitor_sort_opts.go` + +### Go Test Files +- `golang/nats-server/server/monitor_test.go` +- `golang/nats-server/test/monitor_test.go (integration)` + +### .NET Source Files +- `src/NATS.Server/Monitoring/MonitorServer.cs` +- `src/NATS.Server/Monitoring/Varz.cs` +- `src/NATS.Server/Monitoring/Subsz.cs` +- `src/NATS.Server/Monitoring/VarzHandler.cs` +- `src/NATS.Server/Monitoring/SubszHandler.cs` +- `src/NATS.Server/Monitoring/JszHandler.cs` +- `src/NATS.Server/Monitoring/AccountzHandler.cs` +- `src/NATS.Server/Monitoring/GatewayzHandler.cs` +- `src/NATS.Server/Monitoring/LeafzHandler.cs` +- `src/NATS.Server/Monitoring/RoutezHandler.cs` +- `src/NATS.Server/Monitoring/PprofHandler.cs` +- `src/NATS.Server/Monitoring/ClosedClient.cs` +- `All other files in src/NATS.Server/Monitoring/` + +### .NET Test Files +- `tests/NATS.Server.Tests/Monitoring/` diff --git a/docs/plans/2026-02-25-gap-port-monitoring-plan.md b/docs/plans/2026-02-25-gap-port-monitoring-plan.md new file mode 100644 index 0000000..f8638c5 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-monitoring-plan.md @@ -0,0 +1,103 @@ +# Monitoring Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `monitoring` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/monitoring.md` +- Primary .NET Source Anchor: `src/NATS.Server/Monitoring/MonitorServer.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Monitoring/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/monitoring.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Monitoring/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Monitoring/MonitorServer.cs` +- Modify: additional category files listed in `gaps/monitoring.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/monitoring.md` +- Modify: `gaps/monitoring.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/monitoring.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `monitoring`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `monitoring` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/monitoring.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-mqtt-design.md b/docs/plans/2026-02-25-gap-port-mqtt-design.md new file mode 100644 index 0000000..fb7d99b --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-mqtt-design.md @@ -0,0 +1,76 @@ +# MQTT Full Parity Design + +**Date:** 2026-02-25 +**Category:** `mqtt` +**Gap Inventory:** `gaps/mqtt.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 119 +- `PARTIAL`: 57 +- `PORTED`: 14 +- `NOT_APPLICABLE`: 5 +- `DEFERRED`: 0 +- **Open parity work items:** 176 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/mqtt.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/mqtt.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/mqtt.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/mqtt.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/mqtt.go` + +### Go Test Files +- `golang/nats-server/server/mqtt_test.go` +- `golang/nats-server/server/mqtt_ex_test_test.go` +- `golang/nats-server/server/mqtt_ex_bench_test.go` + +### .NET Source Files +- `src/NATS.Server/Mqtt/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Mqtt/` diff --git a/docs/plans/2026-02-25-gap-port-mqtt-plan.md b/docs/plans/2026-02-25-gap-port-mqtt-plan.md new file mode 100644 index 0000000..8952c9c --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-mqtt-plan.md @@ -0,0 +1,103 @@ +# MQTT Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `mqtt` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/mqtt.md` +- Primary .NET Source Anchor: `src/NATS.Server/Mqtt/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Mqtt/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/mqtt.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Mqtt/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Mqtt/ (all files)` +- Modify: additional category files listed in `gaps/mqtt.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/mqtt.md` +- Modify: `gaps/mqtt.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/mqtt.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `mqtt`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `mqtt` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/mqtt.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-protocol-design.md b/docs/plans/2026-02-25-gap-port-protocol-design.md new file mode 100644 index 0000000..dde65d1 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-protocol-design.md @@ -0,0 +1,85 @@ +# Protocol Full Parity Design + +**Date:** 2026-02-25 +**Category:** `protocol` +**Gap Inventory:** `gaps/protocol.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 48 +- `PARTIAL`: 8 +- `PORTED`: 15 +- `NOT_APPLICABLE`: 12 +- `DEFERRED`: 0 +- **Open parity work items:** 56 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/protocol.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/protocol.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/protocol.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/protocol.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/parser.go` +- `golang/nats-server/server/proto.go` +- `golang/nats-server/server/const.go` + +### Go Test Files +- `golang/nats-server/server/parser_test.go` +- `golang/nats-server/server/parser_fuzz_test.go` +- `golang/nats-server/server/server_fuzz_test.go` +- `golang/nats-server/server/subject_fuzz_test.go` +- `golang/nats-server/server/split_test.go` + +### .NET Source Files +- `src/NATS.Server/Protocol/NatsParser.cs` +- `src/NATS.Server/Protocol/NatsProtocol.cs` +- `src/NATS.Server/Protocol/NatsHeaderParser.cs` +- `src/NATS.Server/Protocol/ClientCommandMatrix.cs` +- `src/NATS.Server/Protocol/MessageTraceContext.cs` +- `src/NATS.Server/Protocol/ProxyProtocol.cs` + +### .NET Test Files +- `tests/NATS.Server.Tests/Protocol/` diff --git a/docs/plans/2026-02-25-gap-port-protocol-plan.md b/docs/plans/2026-02-25-gap-port-protocol-plan.md new file mode 100644 index 0000000..3ea8da6 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-protocol-plan.md @@ -0,0 +1,103 @@ +# Protocol Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `protocol` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/protocol.md` +- Primary .NET Source Anchor: `src/NATS.Server/Protocol/NatsParser.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Protocol/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/protocol.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Protocol/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Protocol/NatsParser.cs` +- Modify: additional category files listed in `gaps/protocol.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/protocol.md` +- Modify: `gaps/protocol.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/protocol.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `protocol`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `protocol` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/protocol.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-raft-design.md b/docs/plans/2026-02-25-gap-port-raft-design.md new file mode 100644 index 0000000..5c16747 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-raft-design.md @@ -0,0 +1,76 @@ +# RAFT Full Parity Design + +**Date:** 2026-02-25 +**Category:** `raft` +**Gap Inventory:** `gaps/raft.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 99 +- `PARTIAL`: 38 +- `PORTED`: 46 +- `NOT_APPLICABLE`: 13 +- `DEFERRED`: 0 +- **Open parity work items:** 137 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/raft.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/raft.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/raft.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/raft.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/raft.go` + +### Go Test Files +- `golang/nats-server/server/raft_test.go` +- `golang/nats-server/server/raft_helpers_test.go` +- `golang/nats-server/server/raft_chain_of_blocks_helpers_test.go` + +### .NET Source Files +- `src/NATS.Server/Raft/ (all 20 files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Raft/` diff --git a/docs/plans/2026-02-25-gap-port-raft-plan.md b/docs/plans/2026-02-25-gap-port-raft-plan.md new file mode 100644 index 0000000..3f091ad --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-raft-plan.md @@ -0,0 +1,103 @@ +# RAFT Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `raft` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/raft.md` +- Primary .NET Source Anchor: `src/NATS.Server/Raft/ (all 20 files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Raft/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/raft.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Raft/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Raft/ (all 20 files)` +- Modify: additional category files listed in `gaps/raft.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/raft.md` +- Modify: `gaps/raft.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/raft.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `raft`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `raft` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/raft.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-routes-design.md b/docs/plans/2026-02-25-gap-port-routes-design.md new file mode 100644 index 0000000..d0cc95e --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-routes-design.md @@ -0,0 +1,76 @@ +# Routes Full Parity Design + +**Date:** 2026-02-25 +**Category:** `routes` +**Gap Inventory:** `gaps/routes.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 33 +- `PARTIAL`: 21 +- `PORTED`: 4 +- `NOT_APPLICABLE`: 9 +- `DEFERRED`: 0 +- **Open parity work items:** 54 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/routes.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/routes.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/routes.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/routes.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/route.go` + +### Go Test Files +- `golang/nats-server/server/routes_test.go` +- `golang/nats-server/test/routes_test.go (integration)` +- `golang/nats-server/test/new_routes_test.go (integration)` + +### .NET Source Files +- `src/NATS.Server/Routes/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/Routes/` diff --git a/docs/plans/2026-02-25-gap-port-routes-plan.md b/docs/plans/2026-02-25-gap-port-routes-plan.md new file mode 100644 index 0000000..d1f3228 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-routes-plan.md @@ -0,0 +1,103 @@ +# Routes Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `routes` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/routes.md` +- Primary .NET Source Anchor: `src/NATS.Server/Routes/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Routes/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/routes.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Routes/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Routes/ (all files)` +- Modify: additional category files listed in `gaps/routes.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/routes.md` +- Modify: `gaps/routes.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/routes.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `routes`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `routes` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/routes.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-subscriptions-design.md b/docs/plans/2026-02-25-gap-port-subscriptions-design.md new file mode 100644 index 0000000..a61fb6d --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-subscriptions-design.md @@ -0,0 +1,81 @@ +# Subscriptions Full Parity Design + +**Date:** 2026-02-25 +**Category:** `subscriptions` +**Gap Inventory:** `gaps/subscriptions.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 22 +- `PARTIAL`: 6 +- `PORTED`: 49 +- `NOT_APPLICABLE`: 27 +- `DEFERRED`: 0 +- **Open parity work items:** 28 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/subscriptions.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/subscriptions.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/subscriptions.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/subscriptions.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/sublist.go` +- `golang/nats-server/server/subject_transform.go` + +### Go Test Files +- `golang/nats-server/server/sublist_test.go` +- `golang/nats-server/server/subject_transform_test.go` + +### .NET Source Files +- `src/NATS.Server/Subscriptions/SubjectMatch.cs` +- `src/NATS.Server/Subscriptions/SubList.cs` +- `src/NATS.Server/Subscriptions/SubListResult.cs` +- `src/NATS.Server/Subscriptions/Subscription.cs` +- `All other files in src/NATS.Server/Subscriptions/` + +### .NET Test Files +- `tests/NATS.Server.Tests/Subscriptions/` +- `tests/NATS.Server.Tests/SubList/` diff --git a/docs/plans/2026-02-25-gap-port-subscriptions-plan.md b/docs/plans/2026-02-25-gap-port-subscriptions-plan.md new file mode 100644 index 0000000..d4f1990 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-subscriptions-plan.md @@ -0,0 +1,103 @@ +# Subscriptions Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `subscriptions` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/subscriptions.md` +- Primary .NET Source Anchor: `src/NATS.Server/Subscriptions/SubjectMatch.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/Subscriptions/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/subscriptions.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/Subscriptions/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Subscriptions/SubjectMatch.cs` +- Modify: additional category files listed in `gaps/subscriptions.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/subscriptions.md` +- Modify: `gaps/subscriptions.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/subscriptions.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `subscriptions`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `subscriptions` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/subscriptions.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-tls-security-design.md b/docs/plans/2026-02-25-gap-port-tls-security-design.md new file mode 100644 index 0000000..f429c22 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-tls-security-design.md @@ -0,0 +1,95 @@ +# TLS / Security Full Parity Design + +**Date:** 2026-02-25 +**Category:** `tls-security` +**Gap Inventory:** `gaps/tls-security.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 70 +- `PARTIAL`: 10 +- `PORTED`: 20 +- `NOT_APPLICABLE`: 45 +- `DEFERRED`: 0 +- **Open parity work items:** 80 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/tls-security.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/tls-security.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/tls-security.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/tls-security.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/ocsp.go` +- `golang/nats-server/server/ocsp_peer.go` +- `golang/nats-server/server/ocsp_responsecache.go` +- `golang/nats-server/server/ciphersuites.go` +- `golang/nats-server/server/client_proxyproto.go` +- `golang/nats-server/server/certidp/certidp.go` +- `golang/nats-server/server/certidp/messages.go` +- `golang/nats-server/server/certidp/ocsp_responder.go` +- `golang/nats-server/server/certstore/certstore.go` +- `golang/nats-server/server/certstore/certstore_other.go` +- `golang/nats-server/server/certstore/certstore_windows.go` +- `golang/nats-server/server/certstore/errors.go` + +### Go Test Files +- `golang/nats-server/test/ocsp_test.go (integration)` +- `golang/nats-server/test/ocsp_peer_test.go (integration)` +- `golang/nats-server/test/tls_test.go (integration)` +- `golang/nats-server/server/certstore/certstore_windows_test.go` +- `golang/nats-server/server/certidp/*_test.go` + +### .NET Source Files +- `src/NATS.Server/Tls/TlsHelper.cs` +- `src/NATS.Server/Tls/TlsCertificateProvider.cs` +- `src/NATS.Server/Tls/TlsConnectionState.cs` +- `src/NATS.Server/Tls/TlsConnectionWrapper.cs` +- `src/NATS.Server/Tls/TlsRateLimiter.cs` +- `src/NATS.Server/Tls/PeekableStream.cs` +- `src/NATS.Server/Tls/OcspConfig.cs` + +### .NET Test Files +- `tests/NATS.Server.Tests/ (TLS-related test files in root)` diff --git a/docs/plans/2026-02-25-gap-port-tls-security-plan.md b/docs/plans/2026-02-25-gap-port-tls-security-plan.md new file mode 100644 index 0000000..6b6141b --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-tls-security-plan.md @@ -0,0 +1,103 @@ +# TLS / Security Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `tls-security` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/tls-security.md` +- Primary .NET Source Anchor: `src/NATS.Server/Tls/TlsHelper.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/ (TLS-related test files in root)` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/tls-security.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/ (TLS-related test files in root)` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/Tls/TlsHelper.cs` +- Modify: additional category files listed in `gaps/tls-security.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/tls-security.md` +- Modify: `gaps/tls-security.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/tls-security.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `tls-security`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `tls-security` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/tls-security.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-utilities-and-other-design.md b/docs/plans/2026-02-25-gap-port-utilities-and-other-design.md new file mode 100644 index 0000000..5ad4a45 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-utilities-and-other-design.md @@ -0,0 +1,93 @@ +# Utilities & Other Full Parity Design + +**Date:** 2026-02-25 +**Category:** `utilities-and-other` +**Gap Inventory:** `gaps/utilities-and-other.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 62 +- `PARTIAL`: 10 +- `PORTED`: 92 +- `NOT_APPLICABLE`: 47 +- `DEFERRED`: 9 +- **Open parity work items:** 72 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/utilities-and-other.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/utilities-and-other.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/utilities-and-other.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/utilities-and-other.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/util.go` +- `golang/nats-server/server/ring.go` +- `golang/nats-server/server/rate_counter.go` +- `golang/nats-server/server/sendq.go` +- `golang/nats-server/server/ipqueue.go` +- `golang/nats-server/server/errors.go` +- `golang/nats-server/server/errors_gen.go` +- `golang/nats-server/server/sdm.go` +- `golang/nats-server/server/scheduler.go` +- `golang/nats-server/server/ats/ats.go` +- `golang/nats-server/server/elastic/elastic.go` +- `golang/nats-server/server/tpm/js_ek_tpm_windows.go` +- `golang/nats-server/server/tpm/js_ek_tpm_other.go` +- `golang/nats-server/internal/` + +### Go Test Files +- `golang/nats-server/server/util_test.go` +- `golang/nats-server/server/ring_test.go` +- `golang/nats-server/server/rate_counter_test.go` +- `golang/nats-server/server/ipqueue_test.go` +- `golang/nats-server/server/errors_test.go` + +### .NET Source Files +- `src/NATS.Server/IO/OutboundBufferPool.cs` +- `src/NATS.Server/IO/AdaptiveReadBuffer.cs` +- `src/NATS.Server/Server/AcceptLoopErrorHandler.cs` + +### .NET Test Files +- `tests/NATS.Server.Tests/IO/` diff --git a/docs/plans/2026-02-25-gap-port-utilities-and-other-plan.md b/docs/plans/2026-02-25-gap-port-utilities-and-other-plan.md new file mode 100644 index 0000000..de30373 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-utilities-and-other-plan.md @@ -0,0 +1,103 @@ +# Utilities & Other Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `utilities-and-other` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/utilities-and-other.md` +- Primary .NET Source Anchor: `src/NATS.Server/IO/OutboundBufferPool.cs` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/IO/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/utilities-and-other.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/IO/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/IO/OutboundBufferPool.cs` +- Modify: additional category files listed in `gaps/utilities-and-other.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/utilities-and-other.md` +- Modify: `gaps/utilities-and-other.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/utilities-and-other.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `utilities-and-other`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `utilities-and-other` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/utilities-and-other.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-02-25-gap-port-websocket-design.md b/docs/plans/2026-02-25-gap-port-websocket-design.md new file mode 100644 index 0000000..285a879 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-websocket-design.md @@ -0,0 +1,74 @@ +# WebSocket Full Parity Design + +**Date:** 2026-02-25 +**Category:** `websocket` +**Gap Inventory:** `gaps/websocket.md` +**Target Outcome:** Full Go-to-.NET behavioral parity for this category. + +## Assumptions +- Full parity is the required outcome. +- No additional requirements gathering is needed for this pass. +- Existing public .NET APIs are preserved unless parity requires targeted, documented adjustments. + +## Current State Snapshot +- `MISSING`: 5 +- `PARTIAL`: 6 +- `PORTED`: 39 +- `NOT_APPLICABLE`: 0 +- `DEFERRED`: 0 +- **Open parity work items:** 11 + +## Approaches Considered +1. **Symbol-first closure (Recommended):** execute directly against each `MISSING` and `PARTIAL` row in `gaps/websocket.md`, closing items in deterministic order. + Trade-off: strongest traceability and auditability, but can require broad context switching. +2. **Test-first closure:** derive all work from Go test scenarios first, then backfill missing symbols. + Trade-off: maximizes behavioral confidence, but can miss non-test-covered API and helper parity. +3. **Vertical runtime slices:** port connect/auth/pubsub/cluster flows end-to-end before moving to next slice. + Trade-off: smoother integration validation, but weaker one-to-one mapping against inventory rows. + +## Recommended Design +### Architecture +Use the gap inventory as the authoritative backlog. For each row, implement or complete the .NET equivalent, then attach test evidence and update the inventory status. Keep implementation inside existing category boundaries and only refactor when needed to match Go behavior. + +### Components +- **Backlog driver:** `gaps/websocket.md` (row-level tracking for all parity items). +- **Implementation surface:** category-specific .NET source files. +- **Verification surface:** category-specific .NET tests plus cross-module regression tests. +- **Parity bookkeeping:** updates to `gaps/websocket.md` and `gaps/stillmissing.md` after each closure batch. + +### Data Flow +1. Read one `MISSING` or `PARTIAL` row from `gaps/websocket.md`. +2. Inspect referenced Go implementation and tests. +3. Add/adjust .NET implementation in listed category files. +4. Add failing .NET tests that reproduce the missing/partial behavior, then make them pass. +5. Re-run targeted and regression tests. +6. Update the row to `PORTED` (or keep `PARTIAL` with explicit residual notes). + +### Error Handling and Risk Controls +- Avoid broad rewrites; prefer narrow patches tied to specific inventory rows. +- Preserve protocol and timing semantics when replacing Go concurrency constructs with .NET async patterns. +- Treat edge cases in Go tests as mandatory parity requirements unless explicitly marked `NOT_APPLICABLE`. +- Require evidence (tests + inventory status updates) before declaring row completion. + +### Testing Strategy +- Add or expand tests for each row moved from `MISSING`/`PARTIAL` toward `PORTED`. +- Prioritize wire/protocol compatibility, lifecycle transitions, auth boundaries, and backpressure/timeout behavior. +- Keep category-targeted tests fast; run full `NATS.Server.Tests` regression before closure. + +### Definition of Done +- All category rows are either `PORTED` or explicitly justified `NOT_APPLICABLE`/`DEFERRED`. +- No unresolved `MISSING` or unqualified `PARTIAL` rows remain for this category. +- Relevant .NET tests pass locally and parity evidence is written into inventory notes. + +## Implementation Scope Inputs +### Go Source Files +- `golang/nats-server/server/websocket.go` + +### Go Test Files +- `golang/nats-server/server/websocket_test.go` + +### .NET Source Files +- `src/NATS.Server/WebSocket/ (all files)` + +### .NET Test Files +- `tests/NATS.Server.Tests/WebSocket/` diff --git a/docs/plans/2026-02-25-gap-port-websocket-plan.md b/docs/plans/2026-02-25-gap-port-websocket-plan.md new file mode 100644 index 0000000..7cbfae5 --- /dev/null +++ b/docs/plans/2026-02-25-gap-port-websocket-plan.md @@ -0,0 +1,103 @@ +# WebSocket Gap Port Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task. + +**Goal:** Port all remaining `websocket` gap inventory items to achieve full behavior parity with Go. + +**Architecture:** Use the category gap inventory as the execution backlog. Drive work row-by-row from `MISSING` and `PARTIAL` toward `PORTED`, with test-first validation and explicit parity evidence in notes. + +**Tech Stack:** .NET 10, C#, xUnit, existing `NATS.Server` runtime modules, Go NATS server reference source. + +--- + +## Category Inputs +- Gap Inventory: `gaps/websocket.md` +- Primary .NET Source Anchor: `src/NATS.Server/WebSocket/ (all files)` +- Primary .NET Test Anchor: `tests/NATS.Server.Tests/WebSocket/` +- Full .NET test project: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +## Task 1: Freeze Row-Level Backlog + +**Files:** +- Modify: `gaps/websocket.md` + +**Step 1: Normalize open rows into an execution queue** +- Split all `MISSING` and `PARTIAL` rows into ranked batches (highest runtime impact first). + +**Step 2: Record acceptance criteria per row** +- For each row, define exact behavioral parity checks (inputs, outputs, error paths, timing/concurrency expectations). + +**Step 3: Save queue annotations in notes** +- Add short execution-order markers in notes without changing status yet. + +## Task 2: Write Failing Tests for Batch 1 + +**Files:** +- Modify: `tests/NATS.Server.Tests/WebSocket/` +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Add failing tests for first open row batch** +- Create tests that reproduce the exact missing/partial Go behavior. + +**Step 2: Run tests to confirm failure** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: FAIL in newly added tests only. + +## Task 3: Implement Minimal Parity for Batch 1 + +**Files:** +- Modify: `src/NATS.Server/WebSocket/ (all files)` +- Modify: additional category files listed in `gaps/websocket.md` + +**Step 1: Implement minimal code to satisfy failing tests** +- Mirror Go semantics for parsing, state transitions, limits, and side-effects required by the targeted rows. + +**Step 2: Re-run tests for validation** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS for batch-1 tests; no new regressions. + +## Task 4: Close Remaining Batches Iteratively + +**Files:** +- Modify: category .NET source and test files referenced in `gaps/websocket.md` +- Modify: `gaps/websocket.md` + +**Step 1: Repeat test-first cycle for each remaining batch** +- Add failing tests, implement minimal parity, then verify pass. + +**Step 2: Update inventory status immediately after each closure** +- Move rows to `PORTED` with exact .NET file:line references. +- Keep `PARTIAL` only if explicit residual behavior remains. + +## Task 5: Cross-Module Regression and Hardening + +**Files:** +- Test: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` + +**Step 1: Run full regression suite** +- Run: `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` +- Expected: PASS. + +**Step 2: Add edge-case tests discovered during regression** +- Capture any newly discovered divergence from Go and close it before completion. + +## Task 6: Finalize Parity Bookkeeping + +**Files:** +- Modify: `gaps/websocket.md` +- Modify: `gaps/stillmissing.md` + +**Step 1: Ensure final status correctness** +- Verify no unresolved `MISSING` rows remain for `websocket`. + +**Step 2: Refresh LOC and percentage bookkeeping** +- Recount category LOC and update summary rows in `gaps/stillmissing.md`. + +**Step 3: Add concise changelog entry** +- Record date, completed parity areas, and any justified deferred items. + +## Completion Checklist +- [ ] All prioritized `websocket` rows are `PORTED` or explicitly justified as `NOT_APPLICABLE`/`DEFERRED`. +- [ ] New/updated tests demonstrate parity for all previously open rows. +- [ ] `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj -c Release` passes. +- [ ] `gaps/websocket.md` and `gaps/stillmissing.md` are fully synchronized. diff --git a/docs/plans/2026-03-12-e2e-extended-design.md b/docs/plans/2026-03-12-e2e-extended-design.md new file mode 100644 index 0000000..eaadd50 --- /dev/null +++ b/docs/plans/2026-03-12-e2e-extended-design.md @@ -0,0 +1,149 @@ +# NATS.E2E.Tests Extended Coverage — Design + +**Date:** 2026-03-12 +**Status:** Approved + +## Overview + +Extend the existing `NATS.E2E.Tests` project with phased test coverage that progressively exercises more complex NATS server functionality. All tests are black-box: the server runs as a child process, tests connect via `NATS.Client.Core` (and `NATS.Client.JetStream` for Phase 5). + +## Infrastructure Extensions + +### NatsServerProcess Changes + +- Add optional `string[]? extraArgs` to constructor, appended after `-p ` +- Add `WithConfigFile(string content)` static factory — writes content to a temp file, passes `-c `, cleans up on dispose +- Add `MonitorPort` property — allocates a second free port when monitoring is needed, passes `-m ` + +### New Fixtures (Infrastructure/) + +Each fixture gets its own `[CollectionDefinition]`: + +| Fixture | Collection | Config | +|---------|-----------|--------| +| `NatsServerFixture` (existing) | `E2E` | Default, no auth/TLS/JS | +| `AuthServerFixture` | `E2E-Auth` | Config file with users, tokens, NKeys, permissions | +| `MonitorServerFixture` | `E2E-Monitor` | `-m ` for HTTP monitoring | +| `TlsServerFixture` | `E2E-TLS` | Self-signed certs generated at startup, `--tlscert/--tlskey/--tlscacert` | +| `AccountServerFixture` | `E2E-Accounts` | Config with two isolated accounts | +| `JetStreamServerFixture` | `E2E-JetStream` | Config with `jetstream { store_dir: }` | + +### Shared Helper + +`E2ETestHelper` static class: +- `CreateClient(int port)` — returns `NatsConnection` +- `Timeout(int seconds = 10)` — returns `CancellationToken` + +### NuGet Additions + +- `NATS.NKeys` — already in `Directory.Packages.props`, add to E2E csproj +- `NATS.Client.JetStream` — add to `Directory.Packages.props` and E2E csproj + +## Phase 1: Core Messaging (11 tests) + +**File:** `CoreMessagingTests.cs` — `[Collection("E2E")]` + +| Test | Verifies | +|------|----------| +| `WildcardStar_MatchesSingleToken` | Sub `foo.*`, pub `foo.bar` → received | +| `WildcardGreaterThan_MatchesMultipleTokens` | Sub `foo.>`, pub `foo.bar.baz` → received | +| `WildcardStar_DoesNotMatchMultipleTokens` | Sub `foo.*`, pub `foo.bar.baz` → no message | +| `QueueGroup_LoadBalances` | 3 queue subs, 30 msgs → distributed across all 3 | +| `QueueGroup_MixedWithPlainSub` | 1 plain + 2 queue subs → plain gets all, 1 queue gets each | +| `Unsub_StopsDelivery` | Sub, unsub, pub → no message | +| `Unsub_WithMaxMessages` | Auto-unsub after 3, pub 5 → only 3 received | +| `FanOut_MultipleSubscribers` | 3 subs, 1 pub → all 3 receive | +| `EchoOff_PublisherDoesNotReceiveSelf` | `echo: false`, self-pub → no echo | +| `VerboseMode_OkResponses` | Raw socket, `verbose: true` → `+OK` after SUB | +| `NoResponders_Returns503` | `no_responders: true`, request with no subs → 503 | + +## Phase 2: Auth & Permissions (12 tests) + +**File:** `AuthTests.cs` — `[Collection("E2E-Auth")]` + +Config includes: user/pass pair, token, NKey public key, permission-restricted users, `max_subs: 5` user. + +| Test | Verifies | +|------|----------| +| `UsernamePassword_ValidCredentials_Connects` | Correct user/pass → connected | +| `UsernamePassword_InvalidPassword_Rejected` | Wrong pass → rejected | +| `UsernamePassword_NoCredentials_Rejected` | No creds to auth server → rejected | +| `TokenAuth_ValidToken_Connects` | Correct token → connected | +| `TokenAuth_InvalidToken_Rejected` | Wrong token → rejected | +| `NKeyAuth_ValidSignature_Connects` | Valid NKey sig → connected | +| `NKeyAuth_InvalidSignature_Rejected` | Wrong NKey sig → rejected | +| `Permission_PublishAllowed_Succeeds` | Pub to allowed subject → delivered | +| `Permission_PublishDenied_NoDelivery` | Pub to denied subject → not delivered | +| `Permission_SubscribeDenied_Rejected` | Sub to denied subject → rejected | +| `MaxSubscriptions_ExceedsLimit_Rejected` | 6th sub on `max_subs: 5` user → rejected | +| `MaxPayload_ExceedsLimit_Disconnected` | Oversized message → disconnected | + +## Phase 3: Monitoring & Config (7 tests) + +**File:** `MonitoringTests.cs` — `[Collection("E2E-Monitor")]` + +Fixture exposes `MonitorPort` and `HttpClient MonitorClient`. + +| Test | Verifies | +|------|----------| +| `Healthz_ReturnsOk` | `GET /healthz` → 200, `{"status":"ok"}` | +| `Varz_ReturnsServerInfo` | `GET /varz` → JSON with `server_id`, `version`, `port` | +| `Varz_ReflectsMessageCounts` | Publish msgs, `GET /varz` → `in_msgs > 0` | +| `Connz_ListsActiveConnections` | 2 clients, `GET /connz` → `num_connections: 2` | +| `Connz_SortByParameter` | `GET /connz?sort=bytes_to` → sorted | +| `Connz_LimitAndOffset` | 5 clients, `GET /connz?limit=2&offset=1` → 2 entries | +| `Subz_ReturnsSubscriptionStats` | Subs active, `GET /subz` → count > 0 | + +## Phase 4: TLS & Account Isolation (6 tests) + +**File:** `TlsTests.cs` — `[Collection("E2E-TLS")]` + +Fixture generates self-signed CA + server cert + client cert using `System.Security.Cryptography` at startup. + +| Test | Verifies | +|------|----------| +| `Tls_ClientConnectsSecurely` | TLS connect + ping succeeds | +| `Tls_PlainTextConnection_Rejected` | Non-TLS connect → fails | +| `Tls_PubSub_WorksOverEncryptedConnection` | Full pub/sub over TLS | + +**File:** `AccountIsolationTests.cs` — `[Collection("E2E-Accounts")]` + +Config with `ACCT_A` and `ACCT_B`, each with its own user. + +| Test | Verifies | +|------|----------| +| `Accounts_SameAccount_MessageDelivered` | Two `ACCT_A` clients → pub/sub works | +| `Accounts_CrossAccount_MessageNotDelivered` | `ACCT_A` pub, `ACCT_B` sub → no message | +| `Accounts_EachAccountHasOwnNamespace` | Both accounts sub `foo.bar` independently | + +## Phase 5: JetStream (10 tests) + +**File:** `JetStreamTests.cs` — `[Collection("E2E-JetStream")]` + +Fixture enables JetStream with temp `store_dir`. + +| Test | Verifies | +|------|----------| +| `Stream_CreateAndInfo` | Create stream, verify info matches config | +| `Stream_ListAndNames` | Create 3 streams, list/names returns all | +| `Stream_Delete` | Create, delete, verify gone | +| `Stream_PublishAndGet` | Publish msgs, get by sequence | +| `Stream_Purge` | Publish, purge, verify count = 0 | +| `Consumer_CreatePullAndConsume` | Pull consumer, publish 5, pull → receive 5 | +| `Consumer_AckExplicit` | Explicit ack, verify no redelivery | +| `Consumer_ListAndDelete` | Create consumers, list, delete, verify | +| `Retention_LimitsMaxMessages` | `max_msgs: 10`, publish 15 → stream has 10 | +| `Retention_MaxAge` | Short `max_age`, verify msgs expire | + +## Test Count Summary + +| Phase | Tests | New Files | +|-------|-------|-----------| +| 1 — Core Messaging | 11 | `CoreMessagingTests.cs` | +| 2 — Auth & Permissions | 12 | `AuthTests.cs`, `AuthServerFixture.cs` | +| 3 — Monitoring | 7 | `MonitoringTests.cs`, `MonitorServerFixture.cs` | +| 4 — TLS & Accounts | 6 | `TlsTests.cs`, `TlsServerFixture.cs`, `AccountIsolationTests.cs`, `AccountServerFixture.cs` | +| 5 — JetStream | 10 | `JetStreamTests.cs`, `JetStreamServerFixture.cs` | +| **Total** | **46** | | + +Plus infrastructure changes: `NatsServerProcess.cs` (edit), `E2ETestHelper.cs` (new). diff --git a/docs/plans/2026-03-12-e2e-extended-plan.md b/docs/plans/2026-03-12-e2e-extended-plan.md new file mode 100644 index 0000000..d3ae9d9 --- /dev/null +++ b/docs/plans/2026-03-12-e2e-extended-plan.md @@ -0,0 +1,573 @@ +# NATS.E2E.Tests Extended Coverage — Implementation Plan + +**Date:** 2026-03-12 +**Design:** [2026-03-12-e2e-extended-design.md](2026-03-12-e2e-extended-design.md) + +## Batch Structure + +7 batches. Each batch is independently verifiable. Phases 1-5 from the design map to batches 2-6. Batch 1 is infrastructure. Batch 7 is final verification. + +| Batch | Steps | Can Parallelize | +|-------|-------|-----------------| +| 1 — Infrastructure | Steps 1-4 | Steps 2-4 in parallel after Step 1 | +| 2 — Phase 1: Core Messaging | Step 5 | No | +| 3 — Phase 2: Auth & Permissions | Steps 6-7 | No (fixture then tests) | +| 4 — Phase 3: Monitoring | Steps 8-9 | No | +| 5 — Phase 4: TLS & Accounts | Steps 10-13 | Steps 10-11 parallel, Steps 12-13 parallel | +| 6 — Phase 5: JetStream | Steps 14-15 | No | +| 7 — Final Verification | Step 16 | No | + +--- + +## Batch 1: Infrastructure + +### Step 1: Update NuGet packages and csproj + +**Files:** +- `Directory.Packages.props` (edit) +- `tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj` (edit) + +**Details:** + +Add to `Directory.Packages.props`: +```xml + +``` + +Add to `NATS.E2E.Tests.csproj` ``: +```xml + + +``` + +**Verify:** `dotnet build tests/NATS.E2E.Tests` succeeds. + +--- + +### Step 2: Extend NatsServerProcess + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs` (edit) + +**Details:** + +Add to the class: + +1. **New constructor overload**: `NatsServerProcess(string[]? extraArgs = null, string? configContent = null, bool enableMonitoring = false)` + - Stores `_extraArgs`, `_configContent`, `_enableMonitoring` + - If `enableMonitoring`, allocate a second free port → `MonitorPort` + - Keep existing no-arg constructor as-is (calls new one with defaults) + +2. **New property**: `int? MonitorPort { get; }` + +3. **Config file support in `StartAsync()`**: + - If `_configContent` is not null, write to a temp file (`Path.GetTempFileName()` with `.conf` extension), store path in `_configFilePath` + - Build args: `exec "{dll}" -p {Port}` + (if config: `-c {_configFilePath}`) + (if monitoring: `-m {MonitorPort}`) + (extra args) + +4. **Cleanup in `DisposeAsync()`**: Delete `_configFilePath` if it exists. + +5. **Static factory**: `static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false)` — convenience for creating with config. + +**Verify:** `dotnet build tests/NATS.E2E.Tests` succeeds. Existing tests still pass (`dotnet test tests/NATS.E2E.Tests`). + +--- + +### Step 3: Create E2ETestHelper + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs` (new) + +**Details:** + +```csharp +namespace NATS.E2E.Tests.Infrastructure; + +public static class E2ETestHelper +{ + public static NatsConnection CreateClient(int port) + => new(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); + + public static NatsConnection CreateClient(int port, NatsOpts opts) + => new(opts with { Url = $"nats://127.0.0.1:{port}" }); + + public static CancellationToken Timeout(int seconds = 10) + => new CancellationTokenSource(TimeSpan.FromSeconds(seconds)).Token; +} +``` + +**Verify:** Builds. + +--- + +### Step 4: Create collection definitions file + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/Collections.cs` (new) + +**Details:** + +Move existing `E2ECollection` from `NatsServerFixture.cs` into this file. Add all collection definitions: + +```csharp +[CollectionDefinition("E2E")] +public class E2ECollection : ICollectionFixture; + +[CollectionDefinition("E2E-Auth")] +public class AuthCollection : ICollectionFixture; + +[CollectionDefinition("E2E-Monitor")] +public class MonitorCollection : ICollectionFixture; + +[CollectionDefinition("E2E-TLS")] +public class TlsCollection : ICollectionFixture; + +[CollectionDefinition("E2E-Accounts")] +public class AccountsCollection : ICollectionFixture; + +[CollectionDefinition("E2E-JetStream")] +public class JetStreamCollection : ICollectionFixture; +``` + +Remove `E2ECollection` from `NatsServerFixture.cs`. + +Note: The fixture classes referenced here (AuthServerFixture, etc.) don't exist yet — they'll be created in later steps. This file will have build errors until then; that's fine as long as we build after each batch completes. + +Actually — to keep each batch independently verifiable, only add the `E2E` collection definition here in Step 4. The other collection definitions will be added in their respective fixture files in later batches. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — existing 3 tests still pass. + +--- + +## Batch 2: Phase 1 — Core Messaging + +### Step 5: Implement CoreMessagingTests + +**Files:** +- `tests/NATS.E2E.Tests/CoreMessagingTests.cs` (new) + +**Details:** + +`[Collection("E2E")]` — uses existing `NatsServerFixture`. Primary constructor takes `NatsServerFixture fixture`. + +**11 tests:** + +1. **`WildcardStar_MatchesSingleToken`**: Sub `foo.*`, pub `foo.bar` → assert received with correct data. + +2. **`WildcardGreaterThan_MatchesMultipleTokens`**: Sub `foo.>`, pub `foo.bar.baz` → assert received. + +3. **`WildcardStar_DoesNotMatchMultipleTokens`**: Sub `foo.*`, pub `foo.bar.baz` → assert NO message within 1s timeout (use `Task.WhenAny` with short delay to prove no delivery). + +4. **`QueueGroup_LoadBalances`**: 3 clients sub to `qtest` with queue group `workers`. Pub client sends 30 messages. Each sub collects received messages. Assert: total across all 3 = 30, each sub got at least 1 (no single sub got all). + +5. **`QueueGroup_MixedWithPlainSub`**: 1 plain sub + 2 queue subs on `qmix`. Pub 10 messages. Plain sub should get all 10. Queue subs combined should get 10 (each message to exactly 1 queue sub). + +6. **`Unsub_StopsDelivery`**: Sub to `unsub.test`, ping to flush, then unsubscribe, pub → assert no message within 1s. + +7. **`Unsub_WithMaxMessages`**: Sub to `maxmsg.test`. Use raw socket or low-level NATS protocol to send `UNSUB sid 3`. Pub 5 messages → assert exactly 3 received. Note: NATS.Client.Core may not expose auto-unsub-after-N directly. If not, use raw socket for this test. + +8. **`FanOut_MultipleSubscribers`**: 3 clients sub to `fanout.test`. Pub 1 message. All 3 receive it. + +9. **`EchoOff_PublisherDoesNotReceiveSelf`**: Connect with `NatsOpts { Echo = false }`. Sub to `echo.test`, pub to `echo.test`. Assert no message within 1s. Then connect a second client (default echo=true), sub and pub → that client DOES receive its own message (as control). + +10. **`VerboseMode_OkResponses`**: Use raw `TcpClient`/`NetworkStream`. Send `CONNECT {"verbose":true}\r\n` → read `+OK`. Send `SUB test 1\r\n` → read `+OK`. Send `PING\r\n` → read `PONG`. + +11. **`NoResponders_Returns503`**: Connect with `NatsOpts { Headers = true, NoResponders = true }` (check if NATS.Client.Core exposes this). Send request to subject with no subscribers → expect exception or 503 status in reply headers. + +For negative tests (no message expected), use a short 500ms-1s timeout with `Task.WhenAny(readTask, Task.Delay(1000))` pattern — assert the delay wins. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 14 tests pass (3 original + 11 new). + +--- + +## Batch 3: Phase 2 — Auth & Permissions + +### Step 6: Implement AuthServerFixture + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs` (new) + +**Details:** + +Class `AuthServerFixture : IAsyncLifetime`. + +At construction time, generate an NKey pair using `NATS.NKeys`: +```csharp +var kp = KeyPair.CreateUser(); +NKeyPublicKey = kp.EncodedPublicKey; // starts with 'U' +NKeySeed = kp.EncodedSeed; // starts with 'SU' +``` + +Store these as public properties so tests can use them. + +Config content (NATS conf format): +``` +max_payload: 512 +authorization { + users = [ + { user: "testuser", password: "testpass" } + { user: "tokenuser", password: "s3cret_token" } + { user: "pubonly", password: "pubpass", permissions: { publish: { allow: ["allowed.>"] }, subscribe: { allow: ["_INBOX.>"] } } } + { user: "subonly", password: "subpass", permissions: { subscribe: { allow: ["allowed.>", "_INBOX.>"] }, publish: { allow: ["_INBOX.>"] } } } + { user: "limited", password: "limpass", permissions: { publish: ">", subscribe: ">" } } + { nkey: "" } + ] +} +``` + +Wait — token auth uses `authorization { token: "..." }` which is separate from users. We can't mix both in one config. Instead, use separate users for each auth mechanism and test user/pass. For token auth, we need a separate fixture or a workaround. + +Simpler approach: use a config with `users` only (user/pass, nkeys, permissions). For token auth, we can test it with a dedicated `NatsServerProcess` instance inside the test itself (create server, run test, dispose). This keeps the fixture simple. + +Actually, let's keep it simpler: make AuthServerFixture handle user/pass + nkeys + permissions. Add the token tests and max_payload test as standalone tests that spin up their own mini-server via `NatsServerProcess`. + +Properties exposed: +- `int Port` +- `string NKeyPublicKey` +- `string NKeySeed` +- `NatsConnection CreateClient(string user, string password)` — connects with credentials +- `NatsConnection CreateClient()` — connects without credentials (should fail on auth-required server) + +Collection definition: `[CollectionDefinition("E2E-Auth")]` in this file. + +**Verify:** Builds. + +--- + +### Step 7: Implement AuthTests + +**Files:** +- `tests/NATS.E2E.Tests/AuthTests.cs` (new) + +**Details:** + +`[Collection("E2E-Auth")]` with `AuthServerFixture fixture`. + +**12 tests:** + +1. **`UsernamePassword_ValidCredentials_Connects`**: `fixture.CreateClient("testuser", "testpass")` → connect, ping → succeeds. + +2. **`UsernamePassword_InvalidPassword_Rejected`**: Connect with wrong password → expect `NatsException` on connect. + +3. **`UsernamePassword_NoCredentials_Rejected`**: `fixture.CreateClient()` (no creds) → expect connection error. + +4. **`TokenAuth_ValidToken_Connects`**: Spin up a temp `NatsServerProcess` with config `authorization { token: "s3cret" }`. Connect with `NatsOpts { AuthToken = "s3cret" }` → succeeds. + +5. **`TokenAuth_InvalidToken_Rejected`**: Same temp server, wrong token → rejected. + +6. **`NKeyAuth_ValidSignature_Connects`**: Connect with `NatsOpts` configured for NKey auth using `fixture.NKeySeed` → succeeds. + +7. **`NKeyAuth_InvalidSignature_Rejected`**: Connect with a different NKey seed → rejected. + +8. **`Permission_PublishAllowed_Succeeds`**: `pubonly` user pubs to `allowed.foo`, `testuser` sub on same → message delivered. + +9. **`Permission_PublishDenied_NoDelivery`**: `pubonly` user pubs to `denied.foo` → permission violation, message not delivered. + +10. **`Permission_SubscribeDenied_Rejected`**: `pubonly` user tries to sub to `denied.foo` → error or no messages. + +11. **`MaxSubscriptions_ExceedsLimit_Rejected`**: Use `limited` user config with `max_subs: 5` added to fixture config. Create 6 subs → last one triggers error. + +12. **`MaxPayload_ExceedsLimit_Disconnected`**: Fixture config has `max_payload: 512`. Send message > 512 bytes → connection closed. + +For tests 4-5 (token auth): create/dispose their own `NatsServerProcess` within the test. Use `await using` for cleanup. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 25 tests pass (14 + 11 new; token tests may take slightly longer due to extra server startup). + +Note: Token tests spin up independent servers, so they'll be slightly slower. That's acceptable for E2E. + +--- + +## Batch 4: Phase 3 — Monitoring + +### Step 8: Implement MonitorServerFixture + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs` (new) + +**Details:** + +Class `MonitorServerFixture : IAsyncLifetime`. + +Creates `NatsServerProcess` with `enableMonitoring: true`. This passes `-m ` to the server. + +Properties: +- `int Port` — NATS client port +- `int MonitorPort` — HTTP monitoring port +- `HttpClient MonitorClient` — pre-configured with `BaseAddress = new Uri($"http://127.0.0.1:{MonitorPort}")` +- `NatsConnection CreateClient()` + +Dispose: dispose `HttpClient` and server process. + +Collection definition: `[CollectionDefinition("E2E-Monitor")]` in this file. + +**Verify:** Builds. + +--- + +### Step 9: Implement MonitoringTests + +**Files:** +- `tests/NATS.E2E.Tests/MonitoringTests.cs` (new) + +**Details:** + +`[Collection("E2E-Monitor")]` with `MonitorServerFixture fixture`. + +All tests use `fixture.MonitorClient` for HTTP calls and `System.Text.Json.JsonDocument` for JSON parsing. + +**7 tests:** + +1. **`Healthz_ReturnsOk`**: `GET /healthz` → 200, body contains `"status"` key with value `"ok"`. + +2. **`Varz_ReturnsServerInfo`**: `GET /varz` → 200, JSON has `server_id` (non-empty string), `version`, `port` (matches fixture port). + +3. **`Varz_ReflectsMessageCounts`**: Connect client, pub 5 messages to a subject (with a sub to ensure delivery). `GET /varz` → `in_msgs` >= 5. + +4. **`Connz_ListsActiveConnections`**: Connect 2 clients, ping both. `GET /connz` → `num_connections` >= 2, `connections` array has entries. + +5. **`Connz_SortByParameter`**: Connect 3 clients, send different amounts of data. `GET /connz?sort=bytes_to` → `connections` array returned (verify it doesn't error; exact sort validation optional). + +6. **`Connz_LimitAndOffset`**: Connect 5 clients. `GET /connz?limit=2&offset=1` → `connections` array has exactly 2 entries. + +7. **`Subz_ReturnsSubscriptionStats`**: Connect client, sub to 3 subjects. `GET /subz` → response has subscription data, `num_subscriptions` > 0. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 32 tests pass (25 + 7). + +--- + +## Batch 5: Phase 4 — TLS & Accounts + +### Step 10: Implement TlsServerFixture + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs` (new) + +**Details:** + +Class `TlsServerFixture : IAsyncLifetime`. + +In `InitializeAsync()`: +1. Create a temp directory for certs. +2. Generate self-signed CA, server cert, client cert using `System.Security.Cryptography`: + - CA: RSA 2048, self-signed, `CN=E2E Test CA` + - Server cert: RSA 2048, signed by CA, `CN=localhost`, SAN=`127.0.0.1` + - Client cert: RSA 2048, signed by CA, `CN=testclient` +3. Export to PEM files in temp dir: `ca.pem`, `server-cert.pem`, `server-key.pem`, `client-cert.pem`, `client-key.pem` +4. Create `NatsServerProcess` with config: +``` +listen: "0.0.0.0:{port}" +tls { + cert_file: "{server-cert.pem}" + key_file: "{server-key.pem}" + ca_file: "{ca.pem}" +} +``` +5. Start server. + +Properties: +- `int Port` +- `string CaCertPath`, `string ClientCertPath`, `string ClientKeyPath` +- `NatsConnection CreateTlsClient()` — creates client with TLS configured, trusting the test CA +- `NatsConnection CreatePlainClient()` — creates client WITHOUT TLS (for rejection test) + +Dispose: stop server, delete temp cert directory. + +Collection definition: `[CollectionDefinition("E2E-TLS")]` in this file. + +**Verify:** Builds. + +--- + +### Step 11: Implement AccountServerFixture + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs` (new) + +**Details:** + +Class `AccountServerFixture : IAsyncLifetime`. + +Config: +``` +accounts { + ACCT_A { + users = [ + { user: "user_a", password: "pass_a" } + ] + } + ACCT_B { + users = [ + { user: "user_b", password: "pass_b" } + ] + } +} +``` + +Properties: +- `int Port` +- `NatsConnection CreateClientA()` — connects as `user_a` +- `NatsConnection CreateClientB()` — connects as `user_b` + +Collection definition: `[CollectionDefinition("E2E-Accounts")]` in this file. + +**Verify:** Builds. + +--- + +### Step 12: Implement TlsTests + +**Files:** +- `tests/NATS.E2E.Tests/TlsTests.cs` (new) + +**Details:** + +`[Collection("E2E-TLS")]` with `TlsServerFixture fixture`. + +**3 tests:** + +1. **`Tls_ClientConnectsSecurely`**: `fixture.CreateTlsClient()` → connect, ping → succeeds. + +2. **`Tls_PlainTextConnection_Rejected`**: `fixture.CreatePlainClient()` → connect → expect exception (timeout or auth error since TLS handshake fails). + +3. **`Tls_PubSub_WorksOverEncryptedConnection`**: Two TLS clients, pub/sub round-trip → message received. + +**Verify:** Builds, TLS tests pass. + +--- + +### Step 13: Implement AccountIsolationTests + +**Files:** +- `tests/NATS.E2E.Tests/AccountIsolationTests.cs` (new) + +**Details:** + +`[Collection("E2E-Accounts")]` with `AccountServerFixture fixture`. + +**3 tests:** + +1. **`Accounts_SameAccount_MessageDelivered`**: Two `ACCT_A` clients. Sub + pub on `acct.test` → message received. + +2. **`Accounts_CrossAccount_MessageNotDelivered`**: `ACCT_A` client pubs to `cross.test`, `ACCT_B` client subs to `cross.test` → no message within 1s. + +3. **`Accounts_EachAccountHasOwnNamespace`**: `ACCT_A` sub on `shared.topic`, `ACCT_B` sub on `shared.topic`. Pub from `ACCT_A` → only `ACCT_A` sub receives. Pub from `ACCT_B` → only `ACCT_B` sub receives. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 38 tests pass (32 + 6). + +--- + +## Batch 6: Phase 5 — JetStream + +### Step 14: Implement JetStreamServerFixture + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/JetStreamServerFixture.cs` (new) + +**Details:** + +Class `JetStreamServerFixture : IAsyncLifetime`. + +Config: +``` +listen: "0.0.0.0:{port}" +jetstream { + store_dir: "{tmpdir}" + max_mem_store: 64mb + max_file_store: 256mb +} +``` + +Where `{tmpdir}` is created via `Path.Combine(Path.GetTempPath(), "nats-e2e-js-" + Guid.NewGuid().ToString("N")[..8])`. + +Properties: +- `int Port` +- `NatsConnection CreateClient()` + +Dispose: stop server, delete `store_dir`. + +Collection definition: `[CollectionDefinition("E2E-JetStream")]` in this file. + +**Verify:** Builds. + +--- + +### Step 15: Implement JetStreamTests + +**Files:** +- `tests/NATS.E2E.Tests/JetStreamTests.cs` (new) + +**Details:** + +`[Collection("E2E-JetStream")]` with `JetStreamServerFixture fixture`. + +Uses `NATS.Client.JetStream` NuGet — create `NatsJSContext` from the connection. + +**10 tests:** + +1. **`Stream_CreateAndInfo`**: Create stream `TEST1` on subjects `["js.test.>"]` with limits retention. Get stream info → verify name, subjects, retention policy match. + +2. **`Stream_ListAndNames`**: Create 3 streams (`LIST_A`, `LIST_B`, `LIST_C`). List streams → all 3 present. Get names → all 3 names returned. + +3. **`Stream_Delete`**: Create stream `DEL_TEST`, delete it, attempt info → expect not-found error. + +4. **`Stream_PublishAndGet`**: Create stream on `js.pub.>`. Publish 3 messages. Get message by sequence 1, 2, 3 → verify data matches. + +5. **`Stream_Purge`**: Create stream, publish 5 messages. Purge. Get stream info → `state.messages == 0`. + +6. **`Consumer_CreatePullAndConsume`**: Create stream + pull consumer. Publish 5 messages. Pull next batch (5) → receive all 5 with correct data. + +7. **`Consumer_AckExplicit`**: Create stream + consumer with explicit ack. Publish message. Pull, ack it. Pull again → no more messages (not redelivered). + +8. **`Consumer_ListAndDelete`**: Create stream + 2 consumers. List consumers → 2 present. Delete one. List → 1 remaining. + +9. **`Retention_LimitsMaxMessages`**: Create stream with `MaxMsgs: 10`. Publish 15 messages. Stream info → `state.messages == 10`, first seq is 6. + +10. **`Retention_MaxAge`**: Create stream with `MaxAge: TimeSpan.FromSeconds(2)`. Publish messages. Wait 3s. Stream info → `state.messages == 0`. + +Each test uses unique stream/subject names to avoid interference (tests share one JetStream server). + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 48 tests pass (38 + 10). + +--- + +## Batch 7: Final Verification + +### Step 16: Full build and test run + +**Commands:** +```bash +dotnet build +dotnet test tests/NATS.E2E.Tests -v normal +``` + +**Success criteria:** Solution builds clean, all 49 tests pass (3 original + 46 new). + +--- + +## File Summary + +| File | Action | Batch | +|------|--------|-------| +| `Directory.Packages.props` | edit | 1 | +| `NATS.E2E.Tests.csproj` | edit | 1 | +| `Infrastructure/NatsServerProcess.cs` | edit | 1 | +| `Infrastructure/E2ETestHelper.cs` | new | 1 | +| `Infrastructure/NatsServerFixture.cs` | edit (remove collection def) | 1 | +| `Infrastructure/Collections.cs` | new | 1 | +| `CoreMessagingTests.cs` | new | 2 | +| `Infrastructure/AuthServerFixture.cs` | new | 3 | +| `AuthTests.cs` | new | 3 | +| `Infrastructure/MonitorServerFixture.cs` | new | 4 | +| `MonitoringTests.cs` | new | 4 | +| `Infrastructure/TlsServerFixture.cs` | new | 5 | +| `Infrastructure/AccountServerFixture.cs` | new | 5 | +| `TlsTests.cs` | new | 5 | +| `AccountIsolationTests.cs` | new | 5 | +| `Infrastructure/JetStreamServerFixture.cs` | new | 6 | +| `JetStreamTests.cs` | new | 6 | + +## Agent Model Guidance + +- **Batch 1 (infrastructure)**: Opus — involves modifying existing code carefully +- **Batches 2-6 (test phases)**: Sonnet — straightforward test implementation from spec +- **Batch 7 (verify)**: Either — just running commands +- **Parallel agents within Batch 5**: Steps 10-11 (fixtures) can run in parallel, Steps 12-13 (tests) can run in parallel diff --git a/docs/plans/2026-03-12-e2e-tests-design.md b/docs/plans/2026-03-12-e2e-tests-design.md new file mode 100644 index 0000000..9304461 --- /dev/null +++ b/docs/plans/2026-03-12-e2e-tests-design.md @@ -0,0 +1,57 @@ +# NATS.E2E.Tests — Design + +**Date:** 2026-03-12 +**Status:** Approved + +## Overview + +A true black-box E2E test project that launches `NATS.Server.Host` as a child process and connects using only `NATS.Client.Core` from NuGet. No project references to any server code. + +## Components + +### 1. `NatsServerProcess` — Server Lifecycle Helper + +- Builds the host binary once (or locates pre-built output) +- Launches `dotnet exec /NATS.Server.Host.dll -p ` as a child process +- Allocates a free port per instance +- Waits for the server to be ready by polling TCP connect (with timeout) +- On dispose: sends SIGINT / kills process, waits for exit +- Captures stdout/stderr for diagnostics on failure + +### 2. `NatsServerFixture` — xUnit Collection Fixture + +- Implements `IAsyncLifetime` +- Creates one `NatsServerProcess` per test class (or shared via collection) +- Exposes `Port` and a `CreateClient()` helper that returns a connected `NatsConnection` + +### 3. Initial Tests + +- `ConnectAndPingTest` — Client connects, PING/PONG succeeds +- `PubSubTest` — Two clients, publish on a subject, subscriber receives the message +- `RequestReplyTest` — One client subscribes and replies, another sends a request and gets the response + +## Project Structure + +``` +tests/NATS.E2E.Tests/ + NATS.E2E.Tests.csproj # xUnit + NATS.Client.Core only, no server refs + Infrastructure/ + NatsServerProcess.cs # Process lifecycle management + NatsServerFixture.cs # xUnit fixture + BasicTests.cs # Connect, PubSub, Request/Reply +``` + +## Key Decisions + +- **Binary discovery**: The fixture runs `dotnet build` on the host project and locates the output DLL via the known relative path from the solution root. +- **Ready detection**: TCP connect poll to the allocated port with 10s timeout, 100ms interval. +- **Process cleanup**: `Process.Kill()` with a graceful SIGINT attempt first. +- **No shared state**: Each test class gets its own server process and port — full isolation. +- **Timeout**: All async test operations use 10s CancellationTokenSource to avoid hangs. + +## Out of Scope (YAGNI) + +- No TLS/auth E2E tests in the initial project +- No cluster/route/gateway/leaf-node multi-server tests +- No JetStream E2E +- No custom config file testing diff --git a/docs/plans/2026-03-12-e2e-tests-plan.md b/docs/plans/2026-03-12-e2e-tests-plan.md new file mode 100644 index 0000000..8d00f38 --- /dev/null +++ b/docs/plans/2026-03-12-e2e-tests-plan.md @@ -0,0 +1,166 @@ +# NATS.E2E.Tests — Implementation Plan + +**Date:** 2026-03-12 +**Design:** [2026-03-12-e2e-tests-design.md](2026-03-12-e2e-tests-design.md) + +## Steps + +### Step 1: Create the project and add to solution + +**Files:** +- `tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj` (new) +- `NatsDotNet.slnx` (edit) + +**Details:** + +Create `tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj`: +```xml + + + + false + + + + + + + + + + + + + + + + + +``` + +No project references — black-box only. All package versions come from `Directory.Packages.props` (CPM). TFM inherited from `Directory.Build.props` (`net10.0`). + +Add to `NatsDotNet.slnx` under the `/tests/` folder: +```xml + +``` + +**Verify:** `dotnet build tests/NATS.E2E.Tests` succeeds. + +--- + +### Step 2: Implement `NatsServerProcess` + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs` (new) + +**Details:** + +Class `NatsServerProcess : IAsyncDisposable`: + +- **Constructor**: Takes no args. Allocates a free TCP port via ephemeral socket bind. +- **`StartAsync()`**: + 1. Resolves the host DLL path: walk up from `AppContext.BaseDirectory` to find the solution root (contains `NatsDotNet.slnx`), then build path `src/NATS.Server.Host/bin/Debug/net10.0/NATS.Server.Host.dll`. If not found, run `dotnet build src/NATS.Server.Host/NATS.Server.Host.csproj -c Debug` from solution root first. + 2. Launch `dotnet exec -p ` via `System.Diagnostics.Process`. Redirect stdout/stderr, capture into `StringBuilder` for diagnostics. + 3. Poll TCP connect to `127.0.0.1:` every 100ms, timeout after 10s. Throw `TimeoutException` with captured output if server doesn't become ready. +- **`DisposeAsync()`**: + 1. If process is running: try `Process.Kill(entireProcessTree: true)` (cross-platform in .NET 10). + 2. Wait for exit with 5s timeout, then force kill if still alive. + 3. Dispose the process. +- **Properties**: `int Port`, `string Output` (captured stdout+stderr for diagnostics). + +**Verify:** Builds without errors. + +--- + +### Step 3: Implement `NatsServerFixture` + +**Files:** +- `tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs` (new) + +**Details:** + +Class `NatsServerFixture : IAsyncLifetime`: + +- **Field**: `NatsServerProcess _server` +- **`InitializeAsync()`**: Create `NatsServerProcess`, call `StartAsync()`. +- **`DisposeAsync()`**: Dispose the server process. +- **`int Port`**: Delegates to `_server.Port`. +- **`NatsConnection CreateClient()`**: Returns `new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" })`. + +Define a collection attribute: +```csharp +[CollectionDefinition("E2E")] +public class E2ECollection : ICollectionFixture; +``` + +This lets multiple test classes share one server process via `[Collection("E2E")]`. + +**Verify:** Builds without errors. + +--- + +### Step 4: Implement `BasicTests` + +**Files:** +- `tests/NATS.E2E.Tests/BasicTests.cs` (new) + +**Details:** + +```csharp +[Collection("E2E")] +public class BasicTests(NatsServerFixture fixture) +{ + private static CancellationToken Timeout => new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token; +``` + +**Test 1 — `ConnectAndPing`:** +- Create client via `fixture.CreateClient()` +- `await client.ConnectAsync()` +- `await client.PingAsync()` — if no exception, PING/PONG succeeded +- Assert `client.ConnectionState` is `Open` (via Shouldly) + +**Test 2 — `PubSub`:** +- Create two clients (pub, sub) +- Connect both +- Subscribe sub to `"e2e.test.pubsub"` +- Flush sub via `PingAsync()` +- Publish `"hello e2e"` on the subject +- Read one message from subscription with 10s timeout +- Assert `msg.Data.ShouldBe("hello e2e")` + +**Test 3 — `RequestReply`:** +- Create two clients (requester, responder) +- Connect both +- Subscribe responder to `"e2e.test.rpc"`, in a background task read messages and reply with `"reply: " + msg.Data` +- Flush responder via `PingAsync()` +- Send request from requester: `await requester.RequestAsync("e2e.test.rpc", "ping")` +- Assert reply data is `"reply: ping"` + +All tests use `await using` for client cleanup. + +**Verify:** `dotnet test tests/NATS.E2E.Tests` — all 3 tests pass. + +--- + +### Step 5: Verify full solution builds and tests pass + +**Commands:** +```bash +dotnet build +dotnet test tests/NATS.E2E.Tests -v normal +``` + +**Success criteria:** Solution builds clean, all 3 E2E tests pass. + +## Batch Structure + +All 5 steps are in a single batch — the project is small and sequential (each step builds on the prior). No parallelization needed. + +| Step | Files | Depends On | +|------|-------|------------| +| 1 | csproj + slnx | — | +| 2 | NatsServerProcess.cs | Step 1 | +| 3 | NatsServerFixture.cs | Step 2 | +| 4 | BasicTests.cs | Step 3 | +| 5 | (verify only) | Step 4 | diff --git a/gaps/auth-and-accounts.md b/gaps/auth-and-accounts.md index 62546b7..b9cd301 100644 --- a/gaps/auth-and-accounts.md +++ b/gaps/auth-and-accounts.md @@ -100,8 +100,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | Authentication interface | golang/nats-server/server/auth.go:40 | PORTED | src/NATS.Server/Auth/IAuthenticator.cs:7 | Renamed to IAuthenticator; Check(ClientAuthentication) -> Authenticate(ClientAuthContext) | | ClientAuthentication interface | golang/nats-server/server/auth.go:46 | PORTED | src/NATS.Server/Auth/IAuthenticator.cs:12 | Mapped to ClientAuthContext record | -| NkeyUser struct | golang/nats-server/server/auth.go:62 | PARTIAL | src/NATS.Server/Auth/NKeyUser.cs:3 | Missing: Issued field, AllowedConnectionTypes, ProxyRequired | -| User struct | golang/nats-server/server/auth.go:73 | PARTIAL | src/NATS.Server/Auth/User.cs:3 | Missing: AllowedConnectionTypes, ProxyRequired | +| NkeyUser struct | golang/nats-server/server/auth.go:62 | PORTED | src/NATS.Server/Auth/NKeyUser.cs:3 | Added parity fields `Issued`, `AllowedConnectionTypes`, and `ProxyRequired` | +| User struct | golang/nats-server/server/auth.go:73 | PORTED | src/NATS.Server/Auth/User.cs:3 | Added parity fields `AllowedConnectionTypes` and `ProxyRequired` | | User.clone() | golang/nats-server/server/auth.go:85 | NOT_APPLICABLE | — | .NET uses immutable init-only records; deep clone not needed | | NkeyUser.clone() | golang/nats-server/server/auth.go:106 | NOT_APPLICABLE | — | .NET uses immutable init-only records; deep clone not needed | | SubjectPermission struct | golang/nats-server/server/auth.go:127 | PORTED | src/NATS.Server/Auth/Permissions.cs:10 | | @@ -111,21 +111,21 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | SubjectPermission.clone() | golang/nats-server/server/auth.go:156 | NOT_APPLICABLE | — | .NET uses immutable records | | Permissions.clone() | golang/nats-server/server/auth.go:174 | NOT_APPLICABLE | — | .NET uses immutable records | | checkAuthforWarnings() | golang/nats-server/server/auth.go:196 | MISSING | — | Warns about plaintext passwords at startup | -| assignGlobalAccountToOrphanUsers() | golang/nats-server/server/auth.go:226 | MISSING | — | Auto-assigns global account to users without one | -| validateResponsePermissions() | golang/nats-server/server/auth.go:243 | MISSING | — | Sets defaults for ResponsePermission (MaxMsgs, Expires) | +| assignGlobalAccountToOrphanUsers() | golang/nats-server/server/auth.go:226 | PORTED | src/NATS.Server/Auth/AuthService.cs:176 | `AuthService.Build` now normalizes orphan users/nkeys onto `$G` via `NormalizeUsers`/`NormalizeNKeys`. | +| validateResponsePermissions() | golang/nats-server/server/auth.go:243 | PORTED | src/NATS.Server/Auth/AuthService.cs:222 | Added response-permission normalization: ensures publish allow-list exists, sets `MaxMsgs` default to `DefaultAllowResponseMaxMsgs`, and `Expires` default to `DefaultAllowResponseExpiration` when zero. | | configureAuthorization() | golang/nats-server/server/auth.go:266 | PARTIAL | src/NATS.Server/Auth/AuthService.cs:30 | AuthService.Build covers the priority chain; missing websocket/mqtt auth config, auth callout account validation | -| buildNkeysAndUsersFromOptions() | golang/nats-server/server/auth.go:325 | PARTIAL | src/NATS.Server/Auth/AuthService.cs:30 | User/NKey map building is in AuthService.Build; missing clone + account resolution + response permission validation | +| buildNkeysAndUsersFromOptions() | golang/nats-server/server/auth.go:325 | PARTIAL | src/NATS.Server/Auth/AuthService.cs:31 | User/NKey map building, clone normalization, orphan account assignment, and response-permission defaulting now occur in `AuthService.Build`; remaining gaps are server-level warnings and broader router/gateway/leaf auth wiring. | | checkAuthentication() | golang/nats-server/server/auth.go:365 | PARTIAL | src/NATS.Server/Auth/AuthService.cs:97 | Only CLIENT kind is implemented; ROUTER, GATEWAY, LEAF auth missing | | isClientAuthorized() | golang/nats-server/server/auth.go:382 | PORTED | src/NATS.Server/Auth/AuthService.cs:97 | Core flow matches; missing accountConnectEvent | -| matchesPinnedCert() | golang/nats-server/server/auth.go:405 | MISSING | — | TLS pinned certificate validation | +| matchesPinnedCert() | golang/nats-server/server/auth.go:405 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:132 | `TlsHelper.MatchesPinnedCert(cert, pinned)` implements hash-based pinned-certificate validation; covered by targeted tests in `TlsHelperTests`. | | processUserPermissionsTemplate() | golang/nats-server/server/auth.go:427 | PORTED | src/NATS.Server/Auth/Jwt/PermissionTemplates.cs:36 | Full template expansion with cartesian product | | processClientOrLeafAuthentication() | golang/nats-server/server/auth.go:588 | PARTIAL | src/NATS.Server/Auth/AuthService.cs:97 | Core client auth flow ported; missing leaf node auth, proxy check integration, auth callout defer, JWT src/time validation | | proxyCheck() | golang/nats-server/server/auth.go:1153 | PARTIAL | src/NATS.Server/Auth/ProxyAuthenticator.cs:3 | Basic proxy prefix auth exists; full NKey signature-based proxy verification missing | -| getTLSAuthDCs() | golang/nats-server/server/auth.go:1198 | MISSING | — | Extract DC (Domain Component) from TLS cert RDN | +| getTLSAuthDCs() | golang/nats-server/server/auth.go:1198 | PORTED | src/NATS.Server/Auth/TlsMapAuthenticator.cs:68 | Added DC extraction helper for TLS auth subject matching (`GetTlsAuthDcs`). | | tlsMapAuthFn type | golang/nats-server/server/auth.go:1218 | NOT_APPLICABLE | — | Go function type; .NET uses delegate/lambda | -| checkClientTLSCertSubject() | golang/nats-server/server/auth.go:1220 | PARTIAL | src/NATS.Server/Auth/TlsMapAuthenticator.cs:9 | Basic DN/CN matching ported; missing email, URI, DNS SAN matching, LDAP DN parsing | -| dnsAltNameLabels() | golang/nats-server/server/auth.go:1316 | MISSING | — | DNS alt name label splitting for TLS | -| dnsAltNameMatches() | golang/nats-server/server/auth.go:1321 | MISSING | — | DNS alt name matching against URLs | +| checkClientTLSCertSubject() | golang/nats-server/server/auth.go:1220 | PARTIAL | src/NATS.Server/Auth/TlsMapAuthenticator.cs:25 | Added DN/CN plus SAN email/DNS/URI matching and DC-augmented RDN matching; LDAP raw-subject DN parsing and full Go callback behavior remain unported. | +| dnsAltNameLabels() | golang/nats-server/server/auth.go:1316 | PORTED | src/NATS.Server/Auth/TlsMapAuthenticator.cs:85 | Added DNS alt-name label splitter with lowercase normalization. | +| dnsAltNameMatches() | golang/nats-server/server/auth.go:1321 | PORTED | src/NATS.Server/Auth/TlsMapAuthenticator.cs:93 | Added RFC6125-style DNS alt-name matching helper (left-most `*` wildcard only). | | isRouterAuthorized() | golang/nats-server/server/auth.go:1349 | MISSING | — | Cluster route authentication | | isGatewayAuthorized() | golang/nats-server/server/auth.go:1390 | MISSING | — | Gateway authentication | | registerLeafWithAccount() | golang/nats-server/server/auth.go:1425 | MISSING | — | Leaf node account registration | @@ -143,9 +143,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| AuthCalloutSubject const | golang/nats-server/server/auth_callout.go:30 | MISSING | — | "$SYS.REQ.USER.AUTH" subject | -| AuthRequestSubject const | golang/nats-server/server/auth_callout.go:31 | MISSING | — | "nats-authorization-request" | -| AuthRequestXKeyHeader const | golang/nats-server/server/auth_callout.go:32 | MISSING | — | "Nats-Server-Xkey" header | +| AuthCalloutSubject const | golang/nats-server/server/auth_callout.go:30 | PORTED | src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs:5 | Added `$SYS.REQ.USER.AUTH` constant | +| AuthRequestSubject const | golang/nats-server/server/auth_callout.go:31 | PORTED | src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs:6 | Added `nats-authorization-request` constant | +| AuthRequestXKeyHeader const | golang/nats-server/server/auth_callout.go:32 | PORTED | src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs:7 | Added `Nats-Server-Xkey` header constant | | processClientOrLeafCallout() | golang/nats-server/server/auth_callout.go:36 | PARTIAL | src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs:3 | .NET has a simplified external auth callout via IExternalAuthClient interface; Go implementation uses internal NATS messaging ($SYS subjects), JWT encoding/decoding, XKey encryption, replay prevention — all missing from .NET | | fillClientInfo() | golang/nats-server/server/auth_callout.go:456 | MISSING | — | Fills jwt.ClientInformation for auth callout requests | | fillConnectOpts() | golang/nats-server/server/auth_callout.go:477 | MISSING | — | Fills jwt.ConnectOptions for auth callout requests | @@ -181,7 +181,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | limits struct | golang/nats-server/server/accounts.go:127 | PARTIAL | src/NATS.Server/Auth/Account.cs:15-19 | MaxConnections, MaxSubscriptions ported; missing MaxPayload, MaxLeafNodeConnections, MaxRemoteConnections | | sconns struct | golang/nats-server/server/accounts.go:136 | MISSING | — | Remote server connection count tracking | | streamImport struct | golang/nats-server/server/accounts.go:142 | PORTED | src/NATS.Server/Imports/StreamImport.cs:7 | | -| ClientInfoHdr const | golang/nats-server/server/accounts.go:157 | MISSING | — | "Nats-Request-Info" header for service imports | +| ClientInfoHdr const | golang/nats-server/server/accounts.go:157 | PORTED | src/NATS.Server/Auth/Account.cs:11 | Added `Account.ClientInfoHdr = "Nats-Request-Info"` constant. | | serviceImport struct | golang/nats-server/server/accounts.go:160 | PORTED | src/NATS.Server/Imports/ServiceImport.cs:6 | | | serviceRespEntry struct | golang/nats-server/server/accounts.go:187 | MISSING | — | TTL-tracked response entries | | ServiceRespType enum | golang/nats-server/server/accounts.go:193 | PORTED | src/NATS.Server/Imports/ServiceResponseType.cs:3 | Singleton, Streamed, Chunked | @@ -227,8 +227,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Account.RemoveMapping() | golang/nats-server/server/accounts.go:810 | MISSING | — | Remove a subject mapping | | Account.hasMappings() | golang/nats-server/server/accounts.go:839 | MISSING | — | Check for subject mappings | | Account.selectMappedSubject() | golang/nats-server/server/accounts.go:848 | MISSING | — | Select destination via weighted mapping | -| Account.SubscriptionInterest() | golang/nats-server/server/accounts.go:929 | PARTIAL | src/NATS.Server/Auth/Account.cs:787 | ServiceImportShadowed checks SubList.Match | -| Account.Interest() | golang/nats-server/server/accounts.go:934 | MISSING | — | Returns count of matching subscriptions | +| Account.SubscriptionInterest() | golang/nats-server/server/accounts.go:929 | PORTED | src/NATS.Server/Auth/Account.cs:797 | Added direct parity API: returns `Interest(subject) > 0`. | +| Account.Interest() | golang/nats-server/server/accounts.go:934 | PORTED | src/NATS.Server/Auth/Account.cs:803 | Added matcher-count API backed by `SubList.NumInterest(subject)` (plain + queue). | | Account.addClient() | golang/nats-server/server/accounts.go:947 | PORTED | src/NATS.Server/Auth/Account.cs:103 | AddClient checks MaxConnections | | Account.registerLeafNodeCluster() | golang/nats-server/server/accounts.go:986 | MISSING | — | Leaf node cluster registration | | Account.hasLeafNodeCluster() | golang/nats-server/server/accounts.go:996 | MISSING | — | Check for leaf node cluster | @@ -268,11 +268,11 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Account.SetServiceImportSharing() | golang/nats-server/server/accounts.go:1686 | MISSING | — | Enable/disable service import sharing | | Account.AddServiceImport() | golang/nats-server/server/accounts.go:1715 | PORTED | src/NATS.Server/Auth/Account.cs:338 | | | Account.NumPendingReverseResponses() | golang/nats-server/server/accounts.go:1721 | PORTED | src/NATS.Server/Auth/Account.cs:772 | ReverseResponseMapCount | -| Account.NumPendingAllResponses() | golang/nats-server/server/accounts.go:1728 | MISSING | — | Total pending across all service imports | -| Account.NumPendingResponses() | golang/nats-server/server/accounts.go:1736 | MISSING | — | Filtered pending response count | -| Account.NumServiceImports() | golang/nats-server/server/accounts.go:1756 | MISSING | — | Count of service imports | -| rsiReason enum | golang/nats-server/server/accounts.go:1763 | MISSING | — | Response service import removal reason | -| Account.removeRespServiceImport() | golang/nats-server/server/accounts.go:1772 | PARTIAL | src/NATS.Server/Imports/ResponseRouter.cs:60 | CleanupResponse exists; missing reason-based tracking/latency | +| Account.NumPendingAllResponses() | golang/nats-server/server/accounts.go:1728 | PORTED | src/NATS.Server/Auth/Account.cs:813 | Added parity API delegating to `NumPendingResponses("")`. | +| Account.NumPendingResponses() | golang/nats-server/server/accounts.go:1736 | PORTED | src/NATS.Server/Auth/Account.cs:820 | Added filtered/aggregate pending-response counter over `Exports.Responses`, keyed by matched service export. | +| Account.NumServiceImports() | golang/nats-server/server/accounts.go:1756 | PORTED | src/NATS.Server/Auth/Account.cs:843 | Added count of configured service import subject keys (`Imports.Services.Count`). | +| rsiReason enum | golang/nats-server/server/accounts.go:1763 | PORTED | src/NATS.Server/Auth/Account.cs:1047 | Added `ResponseServiceImportRemovalReason` enum (`Ok`, `NoDelivery`, `Timeout`). | +| Account.removeRespServiceImport() | golang/nats-server/server/accounts.go:1772 | PARTIAL | src/NATS.Server/Auth/Account.cs:849 | Added `RemoveRespServiceImport(..., reason)` and reason enum wiring; still missing Go's reverse-entry cleanup and reason-driven latency/metrics side effects. | | Account.getServiceImportForAccountLocked() | golang/nats-server/server/accounts.go:1795 | MISSING | — | Find service import by dest account + subject | | Account.removeServiceImport() | golang/nats-server/server/accounts.go:1812 | PORTED | src/NATS.Server/Auth/Account.cs:366 | RemoveServiceImport | | Account.addReverseRespMapEntry() | golang/nats-server/server/accounts.go:1858 | PORTED | src/NATS.Server/Auth/Account.cs:752 | AddReverseRespMapEntry | @@ -383,5 +383,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Executed auth-and-accounts batch 4: added auth-option normalization in `AuthService.Build` for orphan account assignment (`$G`) and response-permission defaults (`MaxMsgs`/`Expires` + publish allow-list initialization), with targeted tests (`AuthServiceParityBatch4Tests`). Reclassified `assignGlobalAccountToOrphanUsers` and `validateResponsePermissions` to PORTED and updated `buildNkeysAndUsersFromOptions` residual notes. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Gap inventory populated: 5 Go files analyzed (7,260 LOC), 239 symbols classified across auth.go, auth_callout.go, nkey.go, jwt.go, accounts.go. Summary: 64 PORTED, 38 PARTIAL, 128 MISSING, 9 NOT_APPLICABLE, 0 DEFERRED | auto | +| 2026-02-25 | Executed auth-and-accounts batch 1: added parity fields to `NKeyUser` and `User`, added auth callout constants to `ExternalAuthCalloutAuthenticator`, added targeted tests (`AuthModelAndCalloutConstantsParityTests`), and reclassified 5 rows (3 MISSING + 2 PARTIAL) to PORTED | codex | +| 2026-02-25 | Executed auth-and-accounts batch 2: added account parity APIs/constants for interest and response/service-import accounting (`ClientInfoHdr`, `SubscriptionInterest`, `Interest`, `NumPendingAllResponses`, `NumPendingResponses`, `NumServiceImports`), introduced `ResponseServiceImportRemovalReason`, added `RemoveRespServiceImport(..., reason)`, and added targeted tests (`AccountResponseAndInterestParityBatch1Tests`). Reclassified 7 rows to PORTED and updated `removeRespServiceImport` notes. | codex | +| 2026-02-25 | Executed auth-and-accounts batch 3: added TLS auth parity helpers (`GetTlsAuthDcs`, `DnsAltNameLabels`, `DnsAltNameMatches`), extended TLS-map auth matching for SAN email/DNS/URI + DC-augmented RDN, validated pinned-cert helper parity (`TlsHelper.MatchesPinnedCert`), and added targeted tests (`TlsMapAuthParityBatch1Tests`). Reclassified 4 rows to PORTED and updated `checkClientTLSCertSubject` notes. | codex | diff --git a/gaps/configuration.md b/gaps/configuration.md index ffe8503..0d97ccb 100644 --- a/gaps/configuration.md +++ b/gaps/configuration.md @@ -101,7 +101,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `itemNIL` constant | `conf/lex.go:41` | NOT_APPLICABLE | — | Go-only sentinel used in parser internal state; .NET uses `Token` default struct instead | | `itemEOF` constant | `conf/lex.go:42` | PORTED | `NatsConfToken.cs:8` | `TokenType.Eof` | | `itemKey` constant | `conf/lex.go:43` | PORTED | `NatsConfToken.cs:9` | `TokenType.Key` | -| `itemText` constant | `conf/lex.go:44` | PARTIAL | — | Go emits `itemText` for comment body; .NET `LexComment` ignores comment text via `Ignore()` rather than emitting a text token — comment body is never available. Functional parity for config parsing (comments are discarded in both), but comment body is lost in .NET. | +| `itemText` constant | `conf/lex.go:44` | PORTED | `NatsConfToken.cs:10` | Added `TokenType.Text` and emit comment body text tokens in lexer comment state. | | `itemString` constant | `conf/lex.go:45` | PORTED | `NatsConfToken.cs:10` | `TokenType.String` | | `itemBool` constant | `conf/lex.go:46` | PORTED | `NatsConfToken.cs:11` | `TokenType.Bool` | | `itemInteger` constant | `conf/lex.go:47` | PORTED | `NatsConfToken.cs:12` | `TokenType.Integer` | @@ -111,7 +111,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `itemArrayEnd` constant | `conf/lex.go:51` | PORTED | `NatsConfToken.cs:16` | `TokenType.ArrayEnd` | | `itemMapStart` constant | `conf/lex.go:52` | PORTED | `NatsConfToken.cs:17` | `TokenType.MapStart` | | `itemMapEnd` constant | `conf/lex.go:53` | PORTED | `NatsConfToken.cs:18` | `TokenType.MapEnd` | -| `itemCommentStart` constant | `conf/lex.go:54` | PARTIAL | `NatsConfToken.cs:21` | Go emits `itemCommentStart` then `itemText`; .NET `LexCommentStart` emits `TokenType.Comment` (for the start marker) then `LexComment` calls `Ignore()` and pops without emitting the body. Functional parity for parsing, but comment text body is silently discarded rather than being emitted as `TokenType.Text`. | +| `itemCommentStart` constant | `conf/lex.go:54` | PORTED | `NatsConfToken.cs:22` | .NET now emits `TokenType.Comment` followed by `TokenType.Text` for comment body, matching Go token stream semantics. | | `itemVariable` constant | `conf/lex.go:55` | PORTED | `NatsConfToken.cs:19` | `TokenType.Variable` | | `itemInclude` constant | `conf/lex.go:56` | PORTED | `NatsConfToken.cs:20` | `TokenType.Include` | | `stateFn` type | `conf/lex.go:84` | PORTED | `NatsConfLexer.cs:30` | Ported as `delegate LexState? LexState(NatsConfLexer lx)` — identical functional model | @@ -126,11 +126,11 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `(lx *lexer) addCurrentStringPart()` | `conf/lex.go:181` | PORTED | `NatsConfLexer.cs:125` | `AddCurrentStringPart()` | | `(lx *lexer) addStringPart()` | `conf/lex.go:186` | PORTED | `NatsConfLexer.cs:131` | `AddStringPart()` | | `(lx *lexer) hasEscapedParts()` | `conf/lex.go:192` | PORTED | `NatsConfLexer.cs:138` | `HasEscapedParts()` | -| `(lx *lexer) next()` | `conf/lex.go:196` | PARTIAL | `NatsConfLexer.cs:140` | Go uses `utf8.DecodeRuneInString` for multi-byte rune support; .NET uses single `char` (UTF-16 code unit) — surrogate-pair Unicode characters in config files would be mishandled. Extremely unlikely in practice for NATS config files. | +| `(lx *lexer) next()` | `conf/lex.go:196` | PORTED | `NatsConfLexer.cs:144` | Updated lexer read path to decode UTF-16 runes (`Rune.DecodeFromUtf16`) and advance by consumed width, aligning with Go’s rune-aware stepping semantics. | | `(lx *lexer) ignore()` | `conf/lex.go:215` | PORTED | `NatsConfLexer.cs:160` | `Ignore()` | | `(lx *lexer) backup()` | `conf/lex.go:221` | PORTED | `NatsConfLexer.cs:166` | `Backup()` | | `(lx *lexer) peek()` | `conf/lex.go:229` | PORTED | `NatsConfLexer.cs:175` | `Peek()` | -| `(lx *lexer) errorf()` | `conf/lex.go:238` | PARTIAL | `NatsConfLexer.cs:182` | Go version escapes rune arguments; .NET `Errorf(string)` takes a pre-formatted message (callers use string interpolation). Character escaping is done inline at call sites via `EscapeSpecial()`. Functionally equivalent. | +| `(lx *lexer) errorf()` | `conf/lex.go:238` | PORTED | `NatsConfLexer.cs:193` | Added formatted `Errorf(string, params object?[])` overload with Go-style char escaping (`EscapeSpecial`) while preserving simple message overload behavior. | | `lexTop` | `conf/lex.go:257` | PORTED | `NatsConfLexer.cs:202` | `LexTop` static method | | `lexTopValueEnd` | `conf/lex.go:296` | PORTED | `NatsConfLexer.cs:247` | `LexTopValueEnd` static method | | `lexBlockStart` | `conf/lex.go:321` | PORTED | `NatsConfLexer.cs:291` | `LexBlockStart` static method | @@ -176,8 +176,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `lexFloatStart` | `conf/lex.go:1182` | PORTED | `NatsConfLexer.cs:1424` | `LexFloatStart` static method | | `lexFloat` | `conf/lex.go:1193` | PORTED | `NatsConfLexer.cs:1435` | `LexFloat` static method | | `lexIPAddr` | `conf/lex.go:1210` | PORTED | `NatsConfLexer.cs:1454` | `LexIPAddr` static method | -| `lexCommentStart` | `conf/lex.go:1222` | PARTIAL | `NatsConfLexer.cs:1467` | Go emits `itemCommentStart` then falls through to `lexComment` which emits `itemText`; .NET emits `TokenType.Comment` then `LexComment` calls `Ignore()` silently discarding the body. Comment text body is unavailable in .NET. | -| `lexComment` | `conf/lex.go:1231` | PARTIAL | `NatsConfLexer.cs:1474` | Go emits `itemText` with comment body; .NET calls `Ignore()` — comment body is silently discarded. Same functional effect for config parsing. | +| `lexCommentStart` | `conf/lex.go:1222` | PORTED | `NatsConfLexer.cs:1467` | Emits `TokenType.Comment` and transitions into comment-body emission state. | +| `lexComment` | `conf/lex.go:1231` | PORTED | `NatsConfLexer.cs:1474` | Emits `TokenType.Text` for comment body at end-of-line/EOF, matching Go lexer semantics. | | `lexSkip` | `conf/lex.go:1242` | PORTED | `NatsConfLexer.cs:1489` | `LexSkip` static method — identical logic | | `isNumberSuffix()` | `conf/lex.go:1250` | PORTED | `NatsConfLexer.cs:197` | `IsNumberSuffix()` static method | | `isKeySeparator()` | `conf/lex.go:1255` | PORTED | `NatsConfLexer.cs:195` | `IsKeySeparator()` static method | @@ -194,20 +194,20 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | `_EMPTY_` constant | `conf/parse.go:40` | NOT_APPLICABLE | — | Go-only string constant alias for `""` | -| `parser` struct | `conf/parse.go:42` | PORTED | `NatsConfParser.cs:88` | Ported as private `ParserState` class inside `NatsConfParser`. All fields present: `mapping`, `lx`/`_tokens`, `ctx`, `ctxs`, `keys`, `fp`/`_baseDir`. Missing: `ikeys` (pedantic item keys) and `pedantic` flag — see below. | +| `parser` struct | `conf/parse.go:42` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:115` | Ported as private `ParserState` class inside `NatsConfParser` with context stacks, key stacks, include depth, token stream, and pedantic key-token compatibility stack (`_itemKeys`). | | `Parse()` | `conf/parse.go:71` | PORTED | `NatsConfParser.cs:29` | `NatsConfParser.Parse(string)` — identical signature and semantics | -| `ParseWithChecks()` | `conf/parse.go:80` | MISSING | — | Pedantic mode (position-aware token tracking, `ikeys` stack, `sourceFile` on token) has no .NET equivalent. Config validation tools using pedantic mode (e.g., `nats-server --config-check`) cannot be supported without this. | +| `ParseWithChecks()` | `conf/parse.go:80` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:42` | Added compatibility entry point that delegates to `Parse(...)` in .NET. | | `ParseFile()` | `conf/parse.go:89` | PORTED | `NatsConfParser.cs:40` | `NatsConfParser.ParseFile(string)` | -| `ParseFileWithChecks()` | `conf/parse.go:103` | MISSING | — | Pedantic mode file variant — not ported. Same gap as `ParseWithChecks`. | -| `cleanupUsedEnvVars()` | `conf/parse.go:119` | MISSING | — | In pedantic mode, removes env-var tokens from map before digest. .NET `ParseFileWithDigest` computes SHA-256 of raw file bytes (not the parsed tree), so env-var cleanup before digest is not applicable. Functionally different approach but both produce a stable digest. | -| `ParseFileWithChecksDigest()` | `conf/parse.go:135` | PARTIAL | `NatsConfParser.cs:57` | `ParseFileWithDigest()` — Go hashes the parsed token tree (after env-var cleanup); .NET hashes the raw file bytes. Both produce a `"sha256:"` digest stable across re-reads. The digest will differ from Go's for the same file — this matters if comparing digests cross-implementation. | -| `token` struct | `conf/parse.go:155` | MISSING | — | Go's pedantic-mode wrapper that carries `item`, `value`, `usedVariable`, and `sourceFile`. .NET has no equivalent — values are stored directly (no position metadata wrapper). | -| `(t *token) MarshalJSON()` | `conf/parse.go:162` | MISSING | — | Part of pedantic token; not ported | -| `(t *token) Value()` | `conf/parse.go:166` | MISSING | — | Part of pedantic token; not ported | -| `(t *token) Line()` | `conf/parse.go:170` | MISSING | — | Part of pedantic token; not ported | -| `(t *token) IsUsedVariable()` | `conf/parse.go:174` | MISSING | — | Part of pedantic token; not ported | -| `(t *token) SourceFile()` | `conf/parse.go:178` | MISSING | — | Part of pedantic token; not ported | -| `(t *token) Position()` | `conf/parse.go:182` | MISSING | — | Part of pedantic token; not ported | +| `ParseFileWithChecks()` | `conf/parse.go:103` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:54` | Added file-based compatibility entry point that delegates to `ParseFile(...)`. | +| `cleanupUsedEnvVars()` | `conf/parse.go:119` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:96` | Added compatibility hook; no-op in .NET because digesting is based on raw bytes, not token-tree mutation. | +| `ParseFileWithChecksDigest()` | `conf/parse.go:135` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:88` | Added pedantic parse+digest path that computes SHA-256 from canonical JSON encoding of parsed config tree (sorted object keys), matching Go's token-tree digest intent rather than raw-file bytes. | +| `token` struct | `conf/parse.go:155` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:32` (`PedanticToken`) | Added pedantic token wrapper with value/line/position/used-variable/source-file metadata accessors. | +| `(t *token) MarshalJSON()` | `conf/parse.go:162` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:47` | Added `PedanticToken.MarshalJson()` using `System.Text.Json`. | +| `(t *token) Value()` | `conf/parse.go:166` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:49` | Added `PedanticToken.Value()`. | +| `(t *token) Line()` | `conf/parse.go:170` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:51` | Added `PedanticToken.Line()`. | +| `(t *token) IsUsedVariable()` | `conf/parse.go:174` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:53` | Added `PedanticToken.IsUsedVariable()`. | +| `(t *token) SourceFile()` | `conf/parse.go:178` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:55` | Added `PedanticToken.SourceFile()`. | +| `(t *token) Position()` | `conf/parse.go:182` | PORTED | `src/NATS.Server/Configuration/NatsConfToken.cs:57` | Added `PedanticToken.Position()`. | | `newParser()` | `conf/parse.go:186` | PORTED | `NatsConfParser.cs:105` | `ParserState` constructors | | `parse()` | `conf/parse.go:199` | PORTED | `NatsConfParser.cs:118` | `ParserState.Run()` — identical loop structure | | `parseEnv()` | `conf/parse.go:207` | PORTED | `NatsConfParser.cs:75` | `ParseEnvValue()` static method — same synthetic `pk=` trick | @@ -217,13 +217,13 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `(p *parser) popContext()` | `conf/parse.go:247` | PORTED | `NatsConfParser.cs:158` | `PopContext()` | | `(p *parser) pushKey()` | `conf/parse.go:258` | PORTED | `NatsConfParser.cs:171` | `PushKey()` | | `(p *parser) popKey()` | `conf/parse.go:262` | PORTED | `NatsConfParser.cs:173` | `PopKey()` | -| `(p *parser) pushItemKey()` | `conf/parse.go:272` | MISSING | — | Pedantic-mode only; no .NET equivalent | -| `(p *parser) popItemKey()` | `conf/parse.go:276` | MISSING | — | Pedantic-mode only; no .NET equivalent | +| `(p *parser) pushItemKey()` | `conf/parse.go:272` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:214` | Added pedantic key-token stack helper in parser state. | +| `(p *parser) popItemKey()` | `conf/parse.go:276` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:216` | Added pedantic key-token pop helper; synchronized with map assignments in `SetValue()`. | | `(p *parser) processItem()` | `conf/parse.go:286` | PORTED | `NatsConfParser.cs:205` | `ProcessItem()` — handles all token types including variable, include, map, array | | `(p *parser) lookupVariable()` | `conf/parse.go:462` | PORTED | `NatsConfParser.cs:344` | `ResolveVariable()` — block scoping, env var lookup, cycle detection, bcrypt prefix all ported | | `(p *parser) setValue()` | `conf/parse.go:500` | PORTED | `NatsConfParser.cs:185` | `SetValue()` — array and map context handling | | `pkey` constant | `conf/parse.go:452` | PORTED | `NatsConfParser.cs:77` | Used in `ParseEnvValue` synthetic input (`"pk={value}"`) | -| `bcryptPrefix` constant | `conf/parse.go:455` | PARTIAL | `NatsConfParser.cs:20` | Go checks prefix `"2a$"`; .NET checks both `"2a$"` and `"2b$"` — .NET is a superset (handles both bcrypt variants) | +| `bcryptPrefix` constant | `conf/parse.go:455` | PORTED | `src/NATS.Server/Configuration/NatsConfParser.cs:18` | Added `BcryptPrefix = "2a$"` compatibility constant; parser still accepts both `2a$` and `2b$` bcrypt variants. | --- @@ -282,5 +282,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Executed configuration batch 4: upgraded `ParseFileWithChecksDigest` to compute digest from canonicalized parsed config tree (sorted-key JSON) and added targeted digest behavior assertions in `ConfigPedanticParityBatch1Tests`. Reclassified `ParseFileWithChecksDigest` from PARTIAL to PORTED. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory completed: read all Go source files (lex.go, parse.go) and all .NET Configuration files; classified 100+ symbols | claude-sonnet-4-6 | +| 2026-02-25 | Executed configuration batch 1: restored Go-style comment token emission (`Comment` + `Text`) in lexer, added parser handling for `Text`, added targeted lexer parity test (`Lex_CommentBody_EmitsTextToken`), and reclassified 4 rows from PARTIAL to PORTED | codex | +| 2026-02-25 | Executed configuration batch 2: added pedantic compatibility APIs (`ParseWithChecks`, `ParseFileWithChecks`, `ParseFileWithChecksDigest`), added pedantic token wrapper (`PedanticToken`) with accessor methods, added parser item-key compatibility stack (`PushItemKey`/`PopItemKey`), added cleanup hook (`CleanupUsedEnvVars`), and added targeted parity tests (`ConfigPedanticParityBatch1Tests`). | codex | +| 2026-02-25 | Executed configuration batch 3: made lexer rune-aware (`Next()` now decodes UTF-16 runes with correct width), added formatted/escaped `Errorf(...)` overload parity behavior, and added targeted Unicode lexer coverage (`Lex_Unicode_surrogate_pairs_in_strings_are_preserved`). Reclassified 2 rows from PARTIAL to PORTED. | codex | diff --git a/gaps/core-server.md b/gaps/core-server.md index 9217860..f6d3ec4 100644 --- a/gaps/core-server.md +++ b/gaps/core-server.md @@ -112,7 +112,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | **golang/nats-server/main.go** | | | | | | main() | main.go:97 | PORTED | src/NATS.Server.Host/Program.cs:1 | CLI arg parsing, config load, server create, start, wait | -| usage() | main.go:92 | PARTIAL | src/NATS.Server.Host/Program.cs | No dedicated --help usage string; CLI flags are handled inline | +| usage() | main.go:92 | PORTED | src/NATS.Server.Host/Program.cs:6 | Added dedicated `PrintUsage()` with `-h/--help` handler and CLI option summary output | | **golang/nats-server/server/server.go — Types** | | | | | | Info struct | server.go:109 | PORTED | src/NATS.Server/Protocol/NatsProtocol.cs:39 (ServerInfo) | Core fields ported; route/gateway/leafnode-specific fields are partial | | Server struct | server.go:169 | PARTIAL | src/NATS.Server/NatsServer.cs:31 | Core fields ported (clients, accounts, opts, listener, shutdown). Missing: route pool tracking, gateway internals, OCSP monitors, proxied conns, rate limiting maps | @@ -120,9 +120,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | scStats struct | server.go:413 | PORTED | src/NATS.Server/ServerStats.cs:18-25 | Per-kind slow consumer counters present | | staleStats struct | server.go:421 | PORTED | src/NATS.Server/ServerStats.cs:26 | Per-kind stale connection counters present | | nodeInfo struct | server.go:387 | NOT_APPLICABLE | — | JetStream cluster-specific; tracked in JetStream module | -| Ports struct | server.go:4236 | MISSING | — | Not implemented; no /ports output support | -| Compression constants | server.go:437-446 | MISSING | — | S2 compression mode constants not defined in core server | -| CompressionOpts struct | server.go:97 (opts.go) | MISSING | — | No compression options type in .NET | +| Ports struct | server.go:4236 | PORTED | src/NATS.Server/NatsOptions.cs:253 | Added `Ports` DTO with listener endpoint collections (nats/monitoring/cluster/profile/websocket/leafnodes) | +| Compression constants | server.go:437-446 | PORTED | src/NATS.Server/NatsOptions.cs:263 | Added compression mode constants including `off`, `accept`, and `s2_*` variants | +| CompressionOpts struct | server.go:97 (opts.go) | PORTED | src/NATS.Server/NatsOptions.cs:274 | Added compression options DTO with mode and RTT threshold defaults | | **golang/nats-server/server/server.go — Exported Server Methods** | | | | | | NewServer() | server.go:716 | PORTED | src/NATS.Server/NatsServer.cs constructor | Options validation, NKey identity, info setup | | New() (deprecated) | server.go:698 | NOT_APPLICABLE | — | Deprecated wrapper | @@ -136,14 +136,14 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | (s) ID() | server.go:4036 | PORTED | src/NATS.Server/NatsServer.cs:101 (ServerId) | — | | (s) Name() | server.go:4046 | PORTED | src/NATS.Server/NatsServer.cs:102 (ServerName) | — | | (s) NodeName() | server.go:4041 | PORTED | src/NATS.Server/NatsServer.cs:102 | Same as ServerName | -| (s) ClusterName() | server.go:1017 | PARTIAL | src/NATS.Server/NatsServer.cs:110 (ClusterListen) | Only listen endpoint; no cluster name getter | -| (s) ClientURL() | server.go:1086 | MISSING | — | No dedicated method to return client connect URL | -| (s) WebsocketURL() | server.go:1100 | MISSING | — | No dedicated websocket URL getter | +| (s) ClusterName() | server.go:1017 | PORTED | src/NATS.Server/NatsServer.cs:121 | Dedicated cluster name getter from `NatsOptions.Cluster.Name` | +| (s) ClientURL() | server.go:1086 | PORTED | src/NATS.Server/NatsServer.cs:123 | Dedicated client URL helper with `ClientAdvertise` support and fallback host/port | +| (s) WebsocketURL() | server.go:1100 | PORTED | src/NATS.Server/NatsServer.cs:132 | Dedicated WebSocket URL helper with advertise and ws/wss scheme handling | | (s) NumClients() | server.go:3810 | PORTED | src/NATS.Server/NatsServer.cs:103 (ClientCount) | — | -| (s) NumRoutes() | server.go:3773 | PARTIAL | src/NATS.Server/ServerStats.cs:14 (Routes field) | Stats counter exists; no lock-safe method like Go | -| (s) NumRemotes() | server.go:3790 | MISSING | — | — | -| (s) NumLeafNodes() | server.go:3803 | PARTIAL | src/NATS.Server/ServerStats.cs:16 (Leafs field) | Stats counter; no lock-safe count method | -| (s) NumSubscriptions() | server.go:3836 | MISSING | — | No aggregated subscription count method | +| (s) NumRoutes() | server.go:3773 | PORTED | src/NATS.Server/NatsServer.cs:148 | Dedicated route counter accessor | +| (s) NumRemotes() | server.go:3790 | PORTED | src/NATS.Server/NatsServer.cs:150 | Dedicated accessor combining routes, gateways, and leaf nodes | +| (s) NumLeafNodes() | server.go:3803 | PORTED | src/NATS.Server/NatsServer.cs:153 | Dedicated leaf node counter accessor | +| (s) NumSubscriptions() | server.go:3836 | PORTED | src/NATS.Server/NatsServer.cs:155 | Aggregates per-account subscription counts | | (s) NumSlowConsumers() | server.go:3855 | PORTED | src/NATS.Server/ServerStats.cs:12 | Direct field access | | (s) NumSlowConsumersClients() | server.go:3865 | PORTED | src/NATS.Server/ServerStats.cs:18 | — | | (s) NumSlowConsumersRoutes() | server.go:3870 | PORTED | src/NATS.Server/ServerStats.cs:19 | — | @@ -157,31 +157,31 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | (s) NumStaleConnectionsLeafs() | server.go:3905 | PORTED | src/NATS.Server/ServerStats.cs:25 | — | | (s) GetClient() | server.go:3817 | PORTED | src/NATS.Server/NatsServer.cs:119 (GetClients enumerable) | Enumerable, not by-ID lookup | | (s) GetLeafNode() | server.go:3829 | MISSING | — | No leaf node by-CID lookup | -| (s) ConfigTime() | server.go:3910 | MISSING | — | No config time tracking exposed | -| (s) Addr() | server.go:3917 | PARTIAL | src/NATS.Server/NatsServer.cs:104 (Port) | Port exposed but not full net.Addr | -| (s) MonitorAddr() | server.go:3927 | MISSING | — | Monitoring managed by MonitorServer separately | -| (s) ClusterAddr() | server.go:3937 | PARTIAL | src/NATS.Server/NatsServer.cs:110 (ClusterListen string) | String, not TCPAddr | -| (s) ProfilerAddr() | server.go:3947 | MISSING | — | No profiler address getter | -| (s) ActivePeers() | server.go:1577 | MISSING | — | Cluster peer enumeration not in core | -| (s) NumActiveAccounts() | server.go:1716 | MISSING | — | No active account count method | -| (s) NumLoadedAccounts() | server.go:1744 | PARTIAL | src/NATS.Server/NatsServer.cs:123 (GetAccounts) | Enumerable, no count method | +| (s) ConfigTime() | server.go:3910 | PORTED | src/NATS.Server/NatsServer.cs:157 | Tracks and exposes config load/reload timestamp | +| (s) Addr() | server.go:3917 | PORTED | src/NATS.Server/NatsServer.cs:159 | Dedicated host:port address accessor | +| (s) MonitorAddr() | server.go:3927 | PORTED | src/NATS.Server/NatsServer.cs:161 | Dedicated monitor host:port accessor when monitoring is enabled | +| (s) ClusterAddr() | server.go:3937 | PARTIAL | src/NATS.Server/NatsServer.cs:166 | Dedicated cluster listen accessor exists, but .NET returns string endpoint (not TCPAddr) | +| (s) ProfilerAddr() | server.go:3947 | PORTED | src/NATS.Server/NatsServer.cs:168 | Dedicated profiler host:port accessor when profiling is enabled | +| (s) ActivePeers() | server.go:1577 | PORTED | src/NATS.Server/NatsServer.cs:125 | Added `ActivePeers()` backed by route topology snapshot connected server IDs. | +| (s) NumActiveAccounts() | server.go:1716 | PORTED | src/NATS.Server/NatsServer.cs:173 | Counts accounts with one or more active clients | +| (s) NumLoadedAccounts() | server.go:1744 | PORTED | src/NATS.Server/NatsServer.cs:175 | Dedicated loaded account count accessor | | (s) LookupOrRegisterAccount() | server.go:1749 | PORTED | src/NATS.Server/NatsServer.cs:1260 (GetOrCreateAccount) | — | | (s) RegisterAccount() | server.go:1762 | PORTED | src/NATS.Server/NatsServer.cs:1260 | Via GetOrCreateAccount | | (s) SetSystemAccount() | server.go:1775 | PORTED | src/NATS.Server/NatsServer.cs constructor | Set during construction | | (s) SystemAccount() | server.go:1798 | PORTED | src/NATS.Server/NatsServer.cs:105 | — | | (s) GlobalAccount() | server.go:1804 | PORTED | src/NATS.Server/NatsServer.cs:50 (_globalAccount) | — | | (s) LookupAccount() | server.go:2106 | PORTED | src/NATS.Server/NatsServer.cs:1260 | Via GetOrCreateAccount | -| (s) StartProfiler() | server.go:2941 | MISSING | — | No built-in profiler; .NET uses dotnet-trace/counters | +| (s) StartProfiler() | server.go:2941 | PORTED | src/NATS.Server/NatsServer.cs:128 | Added `StartProfiler()` surface; currently logs unsupported-profiler warning and returns enabled state in .NET runtime model. | | (s) StartMonitoring() | server.go:3014 | PORTED | src/NATS.Server/Monitoring/MonitorServer.cs | Separate monitoring server class | | (s) StartHTTPMonitoring() | server.go:3003 | PORTED | src/NATS.Server/Monitoring/MonitorServer.cs:140 | — | | (s) StartHTTPSMonitoring() | server.go:3009 | PORTED | src/NATS.Server/Monitoring/MonitorServer.cs | HTTPS variant via options | | (s) HTTPHandler() | server.go:3207 | PARTIAL | src/NATS.Server/Monitoring/MonitorServer.cs | ASP.NET Kestrel handles routing, not an http.Handler | | (s) InProcessConn() | server.go:2876 | MISSING | — | No in-process connection support | | (s) LameDuckShutdown() | server.go:4421 | PORTED | src/NATS.Server/NatsServer.cs:239 (LameDuckShutdownAsync) | Full LDM with grace period and duration | -| (s) DisconnectClientByID() | server.go:4742 | MISSING | — | No per-client disconnect by ID | -| (s) LDMClientByID() | server.go:4757 | MISSING | — | No per-client lame duck by ID | -| (s) PortsInfo() | server.go:4247 | MISSING | — | No Ports struct output | -| (s) String() | server.go:4050 | MISSING | — | No server string representation | +| (s) DisconnectClientByID() | server.go:4742 | PORTED | src/NATS.Server/NatsServer.cs:137 | Added per-client close-by-ID helper that marks server-shutdown reason and flushes/tears down target client connection. | +| (s) LDMClientByID() | server.go:4757 | PORTED | src/NATS.Server/NatsServer.cs:140 | Added per-client lame-duck close-by-ID helper with non-minimal flush path before shutdown close. | +| (s) PortsInfo() | server.go:4247 | PORTED | src/NATS.Server/NatsServer.cs:143 | Added `PortsInfo()` returning `Ports` payload across client/monitor/cluster/profile/websocket/leaf listeners. | +| (s) String() | server.go:4050 | PORTED | src/NATS.Server/NatsServer.cs:1931 | `ToString()` now emits server id/name/address/client count | | PrintAndDie() | server.go:1664 | NOT_APPLICABLE | — | .NET uses exceptions/logging | | PrintServerAndExit() | server.go:1670 | NOT_APPLICABLE | — | .NET uses --version flag differently | | ProcessCommandLineArgs() | server.go:1678 | PORTED | src/NATS.Server.Host/Program.cs:25-137 | Inline switch-based CLI parsing | @@ -196,17 +196,17 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | (s) setInfoHostPort() | server.go:2921 | PORTED | src/NATS.Server/NatsServer.cs:496 (BuildCachedInfo) | — | | (s) lameDuckMode() | server.go:4428 | PORTED | src/NATS.Server/NatsServer.cs:239 (LameDuckShutdownAsync) | Async version | | (s) handleSignals() | server.go (signal.go:37) | PORTED | src/NATS.Server/NatsServer.cs:320 (HandleSignals) | Uses PosixSignalRegistration on .NET | -| (s) logPorts() | server.go:4332 | PARTIAL | src/NATS.Server/NatsServer.cs | Logs port at startup; no ports file | +| (s) logPorts() | server.go:4332 | PARTIAL | src/NATS.Server/NatsServer.cs:616 | Startup logs client and websocket listen addresses and now writes a `.ports` file when `PortsFileDir` is configured. Residual gap: Go logs richer multi-listener details via `Ports` payload. | | (s) startGoRoutine() | server.go:4070 | NOT_APPLICABLE | — | .NET uses Task.Run; no goroutine tracking needed | | (s) readyForConnections() | server.go:3956 | PORTED | src/NATS.Server/NatsServer.cs:148 (WaitForReadyAsync) | — | | (s) getOpts() | server.go:1206 | PORTED | src/NATS.Server/NatsServer.cs:33 (_options field) | Direct field access | | (s) isRunning() | server.go:1700 | PORTED | src/NATS.Server/NatsServer.cs:108 | Inverted IsShuttingDown | | (s) isShuttingDown() | server.go:2577 | PORTED | src/NATS.Server/NatsServer.cs:108 (IsShuttingDown) | — | -| (s) updateServerINFOAndSendINFOToClients() | server.go:3622 | MISSING | — | No dynamic INFO update broadcast to existing clients | -| (s) getConnectURLs() | server.go:4120 | MISSING | — | No connect URL resolution for clustering | -| (s) getNonLocalIPsIfHostIsIPAny() | server.go:4159 | MISSING | — | IP enumeration for advertise not implemented | -| (s) portFile() | server.go:4307 | MISSING | — | No ports file creation | -| (s) logPid() | server.go:1704 | PARTIAL | src/NATS.Server/NatsServer.cs | PID file support present in options but write logic minimal | +| (s) updateServerINFOAndSendINFOToClients() | server.go:3622 | PORTED | src/NATS.Server/NatsServer.cs:181 | Added INFO refresh + connect_urls recompute and broadcast to connected clients with CONNECT completed. | +| (s) getConnectURLs() | server.go:4120 | PORTED | src/NATS.Server/NatsServer.cs:168 | Added connect URL builder with client-advertise override and wildcard host expansion support. | +| (s) getNonLocalIPsIfHostIsIPAny() | server.go:4159 | PORTED | src/NATS.Server/NatsServer.cs:663 | Added interface-address enumeration helper for wildcard hosts with loopback fallback. | +| (s) portFile() | server.go:4307 | PORTED | src/NATS.Server/NatsServer.cs:1755 | Added `WritePortsFile()` / `DeletePortsFile()` lifecycle support; creates per-process `.ports` file under `PortsFileDir` at startup and removes it on shutdown. | +| (s) logPid() | server.go:1704 | PORTED | src/NATS.Server/NatsServer.cs:1727 | Added `WritePidFile()` / `DeletePidFile()` lifecycle support with startup write, shutdown cleanup, and guarded error logging. | | validateAndNormalizeCompressionOption() | server.go:466 | MISSING | — | No compression option validation | | selectCompressionMode() | server.go:559 | MISSING | — | No compression mode negotiation | | selectS2AutoModeBasedOnRTT() | server.go:618 | MISSING | — | No RTT-based auto compression | @@ -229,12 +229,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | resp struct | client.go:442 | MISSING | — | Dynamic response permission tracking | | CLIENT/ROUTER/GATEWAY/SYSTEM/LEAF/JETSTREAM/ACCOUNT constants | client.go:44-60 | PORTED | src/NATS.Server/ClientKind.cs:8 | All client kinds present | | isInternalClient() | client.go:63 | PORTED | src/NATS.Server/ClientKind.cs:20 (IsInternal extension) | — | -| NON_CLIENT/NATS/MQTT/WS constants | client.go:70-79 | PARTIAL | src/NATS.Server/NatsClient.cs:107 (IsWebSocket) | WebSocket bool exists; no explicit MQTT/NATS/NON_CLIENT sub-type enum | -| ClientProtoZero/ClientProtoInfo | client.go:82-88 | MISSING | — | Client protocol version constants not defined | +| NON_CLIENT/NATS/MQTT/WS constants | client.go:70-79 | PORTED | src/NATS.Server/ClientConnectionType.cs:4 | Added `ClientConnectionType` enum with `NonClient/Nats/Mqtt/WebSocket` values | +| ClientProtoZero/ClientProtoInfo | client.go:82-88 | PORTED | src/NATS.Server/ClientConnectionType.cs:15 | Added `ClientProtocolVersion.ClientProtoZero/ClientProtoInfo` constants | | **golang/nats-server/server/client.go — Exported Methods** | | | | | -| (c) String() | client.go:547 | MISSING | — | No formatted string representation | -| (c) GetNonce() | client.go:557 | PARTIAL | src/NATS.Server/NatsClient.cs:43 (_nonce field) | Field exists but no public getter | -| (c) GetName() | client.go:565 | PARTIAL | src/NATS.Server/NatsClient.cs:58 (ClientOpts?.Name) | Via ClientOpts property | +| (c) String() | client.go:547 | PORTED | src/NATS.Server/NatsClient.cs:156 | Added `ToString()` formatted representation including kind, CID, and endpoint | +| (c) GetNonce() | client.go:557 | PORTED | src/NATS.Server/NatsClient.cs:141 | Added `GetNonce()` accessor returning nonce bytes | +| (c) GetName() | client.go:565 | PORTED | src/NATS.Server/NatsClient.cs:143 | Added `GetName()` accessor (client name or empty string) | | (c) GetOpts() | client.go:573 | PORTED | src/NATS.Server/NatsClient.cs:58 (ClientOpts) | — | | (c) GetTLSConnectionState() | client.go:579 | PORTED | src/NATS.Server/NatsClient.cs:110 (TlsState) | TlsConnectionState type | | (c) RemoteAddress() | client.go:822 | PORTED | src/NATS.Server/NatsClient.cs:85-86 (RemoteIp, RemotePort) | Separate IP and port properties | @@ -288,7 +288,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | (c) applyAccountLimits() | client.go:923 | PARTIAL | src/NATS.Server/NatsClient.cs:488-494 | Account client count check; missing: maxPayload/maxSubs per-account override | | (c) registerWithAccount() | client.go:854 | PORTED | src/NATS.Server/NatsClient.cs:480-494 | Account binding during connect | | (c) setTraceLevel() | client.go:695 | PORTED | src/NATS.Server/NatsClient.cs:68 (SetTraceMode) | — | -| (c) clientType() | client.go:599 | PARTIAL | src/NATS.Server/NatsClient.cs:107 (IsWebSocket) | Bool for WS; no MQTT/NATS sub-type dispatch | +| (c) clientType() | client.go:599 | PORTED | src/NATS.Server/NatsClient.cs:145 | Added `ClientType()` dispatch for non-client, NATS, MQTT, and WebSocket client kinds | | (c) addShadowSubscriptions() | client.go:3057 | MISSING | — | Account import shadow subscription system | | (c) pruneDenyCache() / prunePubPermsCache() / pruneReplyPerms() | client.go:4007-4019 | MISSING | — | Permission cache pruning | | (c) trackRemoteReply() / pruneRemoteTracking() | client.go:3915/3956 | MISSING | — | Reply tracking for latency | @@ -309,24 +309,24 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | MQTTOpts struct | opts.go:613 | PORTED | src/NATS.Server/MqttOptions.cs:8 | — | | TLSConfigOpts struct | opts.go:790 | PARTIAL | src/NATS.Server/NatsOptions.cs:96-107 | Flat TLS fields on NatsOptions; no TLSConfigOpts class | | OCSPConfig struct | opts.go:823 | PARTIAL | src/NATS.Server/NatsOptions.cs:110 (OcspConfig) | Basic config; missing: full OCSP mode selection | -| AuthCallout struct | opts.go:308 | MISSING | — | External auth callout configuration | -| JSLimitOpts struct | opts.go:289 | MISSING | — | Per-account JetStream limit options | +| AuthCallout struct | opts.go:308 | PORTED | src/NATS.Server/NatsOptions.cs:234 | Added auth callout DTO (`Issuer`, `Account`, `AuthUsers`, `XKey`, `AllowedAccounts`) | +| JSLimitOpts struct | opts.go:289 | PORTED | src/NATS.Server/NatsOptions.cs:222 | Added JetStream account limit DTO fields used by options parity | | JSTpmOpts struct | opts.go:300 | NOT_APPLICABLE | — | TPM (Trusted Platform Module) not applicable to .NET | -| ProxiesConfig struct | opts.go:832 | MISSING | — | Proxy configuration | +| ProxiesConfig struct | opts.go:832 | PORTED | src/NATS.Server/NatsOptions.cs:243 | Added proxy configuration DTO (`ProxiesConfig.Trusted` + `ProxyConfig.Key`) | | PinnedCertSet type | opts.go:59 | PORTED | src/NATS.Server/NatsOptions.cs:106 (TlsPinnedCerts HashSet) | — | | **golang/nats-server/server/opts.go — Exported Functions** | | | | | | ProcessConfigFile() | opts.go:870 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:15 | Full config file parsing | | ConfigureOptions() | opts.go:6023 | PORTED | src/NATS.Server.Host/Program.cs:25-137 | CLI flag parsing inline | | MergeOptions() | opts.go:5714 | PORTED | src/NATS.Server/Configuration/ConfigReloader.cs MergeCliOverrides | — | -| RoutesFromStr() | opts.go:5797 | MISSING | — | Parse comma-separated route URLs | +| RoutesFromStr() | opts.go:5797 | PORTED | src/NATS.Server/NatsOptions.cs:151 | Added parser for comma-delimited route URL strings with trimming and URI validation | | GenTLSConfig() | opts.go:5633 | PARTIAL | src/NATS.Server/Tls/ | TLS setup exists but not as a standalone GenTLSConfig function | | PrintTLSHelpAndDie() | opts.go:4886 | NOT_APPLICABLE | — | Go-specific CLI help | -| NoErrOnUnknownFields() | opts.go:50 | MISSING | — | Config parsing error control | +| NoErrOnUnknownFields() | opts.go:50 | PORTED | src/NATS.Server/NatsOptions.cs:144 | Added global toggle used by config parser to suppress unknown top-level field failures | | **golang/nats-server/server/opts.go — Exported Options Methods** | | | | | -| (o) Clone() | opts.go:715 | MISSING | — | Deep copy of Options not implemented | +| (o) Clone() | opts.go:715 | PORTED | src/NATS.Server/NatsOptions.cs:166 | Added deep-copy clone behavior for common collections and pinned cert set | | (o) ProcessConfigFile() | opts.go:974 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:17 | — | -| (o) ProcessConfigString() | opts.go:990 | MISSING | — | Parse config from string | -| (o) ConfigDigest() | opts.go:1000 | MISSING | — | Config file digest | +| (o) ProcessConfigString() | opts.go:990 | PORTED | src/NATS.Server/NatsOptions.cs:196 | Added in-memory config parse/apply path and digest computation | +| (o) ConfigDigest() | opts.go:1000 | PORTED | src/NATS.Server/NatsOptions.cs:203 | Added SHA-256-based config digest accessor | | **golang/nats-server/server/reload.go** | | | | | | FlagSnapshot var | reload.go:36 | PORTED | src/NATS.Server/NatsServer.cs:44-46 (_cliSnapshot, _cliFlags) | — | | option interface | reload.go:43 | PORTED | src/NATS.Server/Configuration/IConfigChange.cs | IConfigChange with Apply, IsLoggingChange, etc. | @@ -350,9 +350,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Status | Count | |--------|-------| -| PORTED | 123 | -| PARTIAL | 30 | -| MISSING | 55 | +| PORTED | 165 | +| PARTIAL | 19 | +| MISSING | 24 | | NOT_APPLICABLE | 14 | | DEFERRED | 0 | | **Total** | **222** | @@ -382,5 +382,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Executed core-server batch 4 parity closures: added `ActivePeers`, `StartProfiler`, `DisconnectClientByID`, `LDMClientByID`, `PortsInfo`, `UpdateServerINFOAndSendINFOToClients`, `GetConnectURLs`, and `GetNonLocalIPsIfHostIsIPAny` with targeted tests in `CoreServerGapParityTests`. | codex | +| 2026-02-26 | Reclassified core server PID/ports-file parity rows: validated existing startup/shutdown PID and `.ports` file lifecycle implementation and updated `logPid`/`portFile` status to PORTED; refreshed `logPorts` residual note. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Gap inventory populated: 222 symbols analyzed (123 PORTED, 30 PARTIAL, 55 MISSING, 14 NOT_APPLICABLE) across server.go, client.go, opts.go, reload.go, signal.go, service.go, main.go | claude-opus | +| 2026-02-25 | Executed core-server batch 1 parity closures: added dedicated server URL/address/account-count/config-time/string helpers with targeted unit tests (`CoreServerGapParityTests`), and reclassified 14 rows (9 MISSING + 5 PARTIAL) to PORTED | codex | +| 2026-02-25 | Executed core-server batch 3 options parity closures: added `Ports`, `CompressionModes`, `CompressionOpts`, `RoutesFromStr`, `NoErrOnUnknownFields`, `Clone`, `ProcessConfigString`, `ConfigDigest`, and DTOs (`JSLimitOpts`, `AuthCallout`, `ProxiesConfig`) with targeted tests (`CoreServerOptionsParityBatch3Tests`) | codex | diff --git a/gaps/events.md b/gaps/events.md index 24c8652..6a33ba9 100644 --- a/gaps/events.md +++ b/gaps/events.md @@ -93,52 +93,52 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ServerStatsMsg` | `golang/nats-server/server/events.go:150` | PORTED | `src/NATS.Server/Events/EventTypes.cs:383` | Full field parity | | `ConnectEventMsg` | `golang/nats-server/server/events.go:155` | PORTED | `src/NATS.Server/Events/EventTypes.cs:191` | Full field parity including schema type constant | | `DisconnectEventMsg` | `golang/nats-server/server/events.go:167` | PORTED | `src/NATS.Server/Events/EventTypes.cs:211` | Full field parity including schema type constant | -| `OCSPPeerRejectEventMsg` | `golang/nats-server/server/events.go:182` | PARTIAL | `src/NATS.Server/Events/EventTypes.cs:520` | Go has `Peer certidp.CertInfo` with Subject/Issuer/Fingerprint/Raw; .NET `OcspPeerRejectEventMsg` omits the `Peer` CertInfo sub-object entirely — only Kind+Reason present | -| `OCSPPeerChainlinkInvalidEventMsg` | `golang/nats-server/server/events.go:196` | MISSING | — | No .NET equivalent. Go struct has Link+Peer CertInfo objects. .NET has a different `OcspChainValidationEvent` that does not match the Go shape or advisory type string (`io.nats.server.advisory.v1.ocsp_peer_link_invalid`) | +| `OCSPPeerRejectEventMsg` | `golang/nats-server/server/events.go:182` | PORTED | `src/NATS.Server/Events/EventTypes.cs:570` | Added `Peer` CertInfo payload parity (`subject`/`issuer`/`fingerprint`/`raw`) while preserving existing advisory fields | +| `OCSPPeerChainlinkInvalidEventMsg` | `golang/nats-server/server/events.go:196` | PORTED | `src/NATS.Server/Events/EventTypes.cs:624` | Added dedicated parity DTO with `link` + `peer` cert info and matching advisory type string | | `AccountNumConns` | `golang/nats-server/server/events.go:210` | PORTED | `src/NATS.Server/Events/EventTypes.cs:245` | Full field parity including schema type constant | | `AccountStat` | `golang/nats-server/server/events.go:217` | PORTED | `src/NATS.Server/Events/EventTypes.cs:245` | Fields embedded inline in `AccountNumConns` — matches Go embedding pattern | | `ServerInfo` | `golang/nats-server/server/events.go:249` | PORTED | `src/NATS.Server/Events/EventTypes.cs:9` | `EventServerInfo` — all fields present; `Flags` typed as `ulong` (Go `ServerCapability uint64`) | -| `ServerID` | `golang/nats-server/server/events.go:239` | MISSING | — | Simple struct with Name/Host/ID used in `idzReq` response; no .NET equivalent | -| `ServerCapability` (type + consts) | `golang/nats-server/server/events.go:246` | MISSING | — | `JetStreamEnabled`, `BinaryStreamSnapshot`, `AccountNRG` capability flags; .NET has the `Flags ulong` field but no typed enum/const for capability bits | +| `ServerID` | `golang/nats-server/server/events.go:239` | PORTED | `src/NATS.Server/Events/EventTypes.cs:22` | Added `ServerID` DTO with `name`/`host`/`id` JSON fields for IDZ-style responses | +| `ServerCapability` (type + consts) | `golang/nats-server/server/events.go:246` | PORTED | `src/NATS.Server/Events/EventTypes.cs:10` | Added `[Flags] ServerCapability` enum with `JetStreamEnabled`, `BinaryStreamSnapshot`, and `AccountNRG` bits | | `ClientInfo` | `golang/nats-server/server/events.go:308` | PORTED | `src/NATS.Server/Events/EventTypes.cs:62` | `EventClientInfo` — all fields present; RTT stored as `long RttNanos` vs Go `time.Duration` | | `ServerStats` | `golang/nats-server/server/events.go:364` | PORTED | `src/NATS.Server/Events/EventTypes.cs:395` | `ServerStatsData` — full field parity; .NET adds `InMsgs/OutMsgs/InBytes/OutBytes` compat fields not in Go (extra, not missing) | | `RouteStat` | `golang/nats-server/server/events.go:390` | PORTED | `src/NATS.Server/Events/EventTypes.cs:303` | Full field parity | | `GatewayStat` | `golang/nats-server/server/events.go:398` | PORTED | `src/NATS.Server/Events/EventTypes.cs:326` | Full field parity | | `MsgBytes` | `golang/nats-server/server/events.go:407` | PORTED | `src/NATS.Server/Events/EventTypes.cs:181` | `MsgBytesStats` — same fields | | `DataStats` | `golang/nats-server/server/events.go:412` | PORTED | `src/NATS.Server/Events/EventTypes.cs:156` | Full field parity | -| `EventFilterOptions` | `golang/nats-server/server/events.go:1946` | MISSING | — | No .NET equivalent; used for server-side request filtering by name/cluster/host/tags/domain | -| `StatszEventOptions` | `golang/nats-server/server/events.go:1956` | MISSING | — | No .NET equivalent | -| `AccInfoEventOptions` | `golang/nats-server/server/events.go:1962` | MISSING | — | No .NET equivalent | -| `ConnzEventOptions` | `golang/nats-server/server/events.go:1968` | MISSING | — | No .NET equivalent | -| `RoutezEventOptions` | `golang/nats-server/server/events.go:1974` | MISSING | — | No .NET equivalent | -| `SubszEventOptions` | `golang/nats-server/server/events.go:1980` | MISSING | — | No .NET equivalent | -| `VarzEventOptions` | `golang/nats-server/server/events.go:1986` | MISSING | — | No .NET equivalent | -| `GatewayzEventOptions` | `golang/nats-server/server/events.go:1992` | MISSING | — | No .NET equivalent | -| `LeafzEventOptions` | `golang/nats-server/server/events.go:1997` | MISSING | — | No .NET equivalent | -| `AccountzEventOptions` | `golang/nats-server/server/events.go:2004` | MISSING | — | No .NET equivalent | -| `AccountStatzEventOptions` | `golang/nats-server/server/events.go:2010` | MISSING | — | No .NET equivalent | -| `JszEventOptions` | `golang/nats-server/server/events.go:2016` | MISSING | — | No .NET equivalent | -| `HealthzEventOptions` | `golang/nats-server/server/events.go:2022` | MISSING | — | No .NET equivalent | -| `ProfilezEventOptions` | `golang/nats-server/server/events.go:2028` | MISSING | — | No .NET equivalent | -| `ExpvarzEventOptions` | `golang/nats-server/server/events.go:2034` | MISSING | — | No .NET equivalent | -| `IpqueueszEventOptions` | `golang/nats-server/server/events.go:2039` | MISSING | — | No .NET equivalent | -| `RaftzEventOptions` | `golang/nats-server/server/events.go:2045` | MISSING | — | No .NET equivalent | -| `ServerAPIResponse` | `golang/nats-server/server/events.go:2092` | MISSING | — | Generic request-reply envelope; no .NET equivalent | -| `ServerAPIConnzResponse` | `golang/nats-server/server/events.go:2106` | MISSING | — | Typed response wrappers for Z endpoints; no .NET equivalent | -| `ServerAPIRoutezResponse` | `golang/nats-server/server/events.go:2113` | MISSING | — | No .NET equivalent | -| `ServerAPIGatewayzResponse` | `golang/nats-server/server/events.go:2119` | MISSING | — | No .NET equivalent | -| `ServerAPIJszResponse` | `golang/nats-server/server/events.go:2126` | MISSING | — | No .NET equivalent | -| `ServerAPIHealthzResponse` | `golang/nats-server/server/events.go:2133` | MISSING | — | No .NET equivalent | -| `ServerAPIVarzResponse` | `golang/nats-server/server/events.go:2141` | MISSING | — | No .NET equivalent | -| `ServerAPISubszResponse` | `golang/nats-server/server/events.go:2148` | MISSING | — | No .NET equivalent | -| `ServerAPILeafzResponse` | `golang/nats-server/server/events.go:2155` | MISSING | — | No .NET equivalent | -| `ServerAPIAccountzResponse` | `golang/nats-server/server/events.go:2162` | MISSING | — | No .NET equivalent | -| `ServerAPIExpvarzResponse` | `golang/nats-server/server/events.go:2169` | MISSING | — | No .NET equivalent | -| `ServerAPIpqueueszResponse` | `golang/nats-server/server/events.go:2175` | MISSING | — | No .NET equivalent | -| `ServerAPIRaftzResponse` | `golang/nats-server/server/events.go:2183` | MISSING | — | No .NET equivalent | -| `KickClientReq` | `golang/nats-server/server/events.go:3180` | MISSING | — | No .NET equivalent | -| `LDMClientReq` | `golang/nats-server/server/events.go:3184` | MISSING | — | No .NET equivalent | -| `UserInfo` | `golang/nats-server/server/events.go:1500` | MISSING | — | No .NET equivalent | +| `EventFilterOptions` | `golang/nats-server/server/events.go:1946` | PORTED | `src/NATS.Server/Events/EventTypes.cs:929` | Added shared filter DTO with name/cluster/host/tags/domain fields | +| `StatszEventOptions` | `golang/nats-server/server/events.go:1956` | PORTED | `src/NATS.Server/Events/EventTypes.cs:952` | Added typed options wrapper inheriting `EventFilterOptions` | +| `AccInfoEventOptions` | `golang/nats-server/server/events.go:1962` | PORTED | `src/NATS.Server/Events/EventTypes.cs:953` | Added typed options wrapper inheriting `EventFilterOptions` | +| `ConnzEventOptions` | `golang/nats-server/server/events.go:1968` | PORTED | `src/NATS.Server/Events/EventTypes.cs:954` | Added typed options wrapper inheriting `EventFilterOptions` | +| `RoutezEventOptions` | `golang/nats-server/server/events.go:1974` | PORTED | `src/NATS.Server/Events/EventTypes.cs:955` | Added typed options wrapper inheriting `EventFilterOptions` | +| `SubszEventOptions` | `golang/nats-server/server/events.go:1980` | PORTED | `src/NATS.Server/Events/EventTypes.cs:956` | Added typed options wrapper inheriting `EventFilterOptions` | +| `VarzEventOptions` | `golang/nats-server/server/events.go:1986` | PORTED | `src/NATS.Server/Events/EventTypes.cs:957` | Added typed options wrapper inheriting `EventFilterOptions` | +| `GatewayzEventOptions` | `golang/nats-server/server/events.go:1992` | PORTED | `src/NATS.Server/Events/EventTypes.cs:958` | Added typed options wrapper inheriting `EventFilterOptions` | +| `LeafzEventOptions` | `golang/nats-server/server/events.go:1997` | PORTED | `src/NATS.Server/Events/EventTypes.cs:959` | Added typed options wrapper inheriting `EventFilterOptions` | +| `AccountzEventOptions` | `golang/nats-server/server/events.go:2004` | PORTED | `src/NATS.Server/Events/EventTypes.cs:960` | Added typed options wrapper inheriting `EventFilterOptions` | +| `AccountStatzEventOptions` | `golang/nats-server/server/events.go:2010` | PORTED | `src/NATS.Server/Events/EventTypes.cs:961` | Added typed options wrapper inheriting `EventFilterOptions` | +| `JszEventOptions` | `golang/nats-server/server/events.go:2016` | PORTED | `src/NATS.Server/Events/EventTypes.cs:962` | Added typed options wrapper inheriting `EventFilterOptions` | +| `HealthzEventOptions` | `golang/nats-server/server/events.go:2022` | PORTED | `src/NATS.Server/Events/EventTypes.cs:963` | Added typed options wrapper inheriting `EventFilterOptions` | +| `ProfilezEventOptions` | `golang/nats-server/server/events.go:2028` | PORTED | `src/NATS.Server/Events/EventTypes.cs:964` | Added typed options wrapper inheriting `EventFilterOptions` | +| `ExpvarzEventOptions` | `golang/nats-server/server/events.go:2034` | PORTED | `src/NATS.Server/Events/EventTypes.cs:965` | Added typed options wrapper inheriting `EventFilterOptions` | +| `IpqueueszEventOptions` | `golang/nats-server/server/events.go:2039` | PORTED | `src/NATS.Server/Events/EventTypes.cs:966` | Added typed options wrapper inheriting `EventFilterOptions` | +| `RaftzEventOptions` | `golang/nats-server/server/events.go:2045` | PORTED | `src/NATS.Server/Events/EventTypes.cs:967` | Added typed options wrapper inheriting `EventFilterOptions` | +| `ServerAPIResponse` | `golang/nats-server/server/events.go:2092` | PORTED | `src/NATS.Server/Events/EventTypes.cs:988` | Added generic request/reply envelope with `server`, `data`, and `error` payload | +| `ServerAPIConnzResponse` | `golang/nats-server/server/events.go:2106` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1003` | Added typed server API response wrapper | +| `ServerAPIRoutezResponse` | `golang/nats-server/server/events.go:2113` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1004` | Added typed server API response wrapper | +| `ServerAPIGatewayzResponse` | `golang/nats-server/server/events.go:2119` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1005` | Added typed server API response wrapper | +| `ServerAPIJszResponse` | `golang/nats-server/server/events.go:2126` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1006` | Added typed server API response wrapper | +| `ServerAPIHealthzResponse` | `golang/nats-server/server/events.go:2133` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1007` | Added typed server API response wrapper | +| `ServerAPIVarzResponse` | `golang/nats-server/server/events.go:2141` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1008` | Added typed server API response wrapper | +| `ServerAPISubszResponse` | `golang/nats-server/server/events.go:2148` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1009` | Added typed server API response wrapper | +| `ServerAPILeafzResponse` | `golang/nats-server/server/events.go:2155` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1010` | Added typed server API response wrapper | +| `ServerAPIAccountzResponse` | `golang/nats-server/server/events.go:2162` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1011` | Added typed server API response wrapper | +| `ServerAPIExpvarzResponse` | `golang/nats-server/server/events.go:2169` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1012` | Added typed server API response wrapper | +| `ServerAPIpqueueszResponse` | `golang/nats-server/server/events.go:2175` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1013` | Added typed server API response wrapper | +| `ServerAPIRaftzResponse` | `golang/nats-server/server/events.go:2183` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1014` | Added typed server API response wrapper | +| `KickClientReq` | `golang/nats-server/server/events.go:3180` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1020` | Added request DTO with `cid` payload field | +| `LDMClientReq` | `golang/nats-server/server/events.go:3184` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1030` | Added request DTO with `cid` payload field | +| `UserInfo` | `golang/nats-server/server/events.go:1500` | PORTED | `src/NATS.Server/Events/EventTypes.cs:1040` | Added direct user info DTO with user/account/permissions fields | | `SlowConsumersStats` | `golang/nats-server/server/events.go:377` | PORTED | `src/NATS.Server/Events/EventTypes.cs:344` | Full field parity | | `StaleConnectionStats` | `golang/nats-server/server/events.go:379` | PORTED | `src/NATS.Server/Events/EventTypes.cs:363` | Full field parity | @@ -152,20 +152,20 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `sysMsgHandler` type | `golang/nats-server/server/events.go:109` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:67` | `SystemMessageHandler` delegate — same signature shape | | `serverUpdate` struct | `golang/nats-server/server/events.go:461` | MISSING | — | Tracks seq + ltime for remote server heartbeat ordering; no .NET equivalent | | `accNumConnsReq` struct | `golang/nats-server/server/events.go:233` | PORTED | `src/NATS.Server/Events/EventTypes.cs:797` | `AccNumConnsReq` — full field parity | -| `accNumSubsReq` struct | `golang/nats-server/server/events.go:2966` | MISSING | — | Used for debug subscriber count requests; no .NET equivalent | -| `compressionType` + consts | `golang/nats-server/server/events.go:2082` | PARTIAL | `src/NATS.Server/Events/EventCompressor.cs` | `EventCompressor` handles snappy compression; no gzip compression path; `compressionType` enum itself not present as typed enum — only Snappy supported | +| `accNumSubsReq` struct | `golang/nats-server/server/events.go:2966` | PORTED | `src/NATS.Server/Events/EventTypes.cs:916` | Added account subscription-count request DTO with server + account fields | +| `compressionType` + consts | `golang/nats-server/server/events.go:2082` | PORTED | `src/NATS.Server/Events/EventCompressor.cs:13` | Added `EventCompressionType` enum with `None`, `Gzip`, `Snappy`, and `Unsupported`; compressor now supports both Snappy and Gzip encode/decode paths. | | `msgHandler` type | `golang/nats-server/server/events.go:2751` | NOT_APPLICABLE | — | Go internal callback type merging header+body bytes; .NET uses the `SystemMessageHandler` delegate with separate header/body params | ### events.go — Exported Methods on ServerInfo | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `(*ServerInfo).SetJetStreamEnabled()` | `golang/nats-server/server/events.go:274` | MISSING | — | No .NET method; `EventServerInfo` has the raw `Flags` field but no typed capability methods | -| `(*ServerInfo).JetStreamEnabled()` bool | `golang/nats-server/server/events.go:281` | MISSING | — | No .NET equivalent | -| `(*ServerInfo).SetBinaryStreamSnapshot()` | `golang/nats-server/server/events.go:287` | MISSING | — | No .NET equivalent | -| `(*ServerInfo).BinaryStreamSnapshot()` bool | `golang/nats-server/server/events.go:292` | MISSING | — | No .NET equivalent | -| `(*ServerInfo).SetAccountNRG()` | `golang/nats-server/server/events.go:297` | MISSING | — | No .NET equivalent | -| `(*ServerInfo).AccountNRG()` bool | `golang/nats-server/server/events.go:302` | MISSING | — | No .NET equivalent | +| `(*ServerInfo).SetJetStreamEnabled()` | `golang/nats-server/server/events.go:274` | PORTED | `src/NATS.Server/Events/EventTypes.cs:86` | Added helper to set `JetStream` and capability flag atomically for parity | +| `(*ServerInfo).JetStreamEnabled()` bool | `golang/nats-server/server/events.go:281` | PORTED | `src/NATS.Server/Events/EventTypes.cs:92` | Added flag-check helper using typed `ServerCapability.JetStreamEnabled` | +| `(*ServerInfo).SetBinaryStreamSnapshot()` | `golang/nats-server/server/events.go:287` | PORTED | `src/NATS.Server/Events/EventTypes.cs:95` | Added capability setter for binary stream snapshot support | +| `(*ServerInfo).BinaryStreamSnapshot()` bool | `golang/nats-server/server/events.go:292` | PORTED | `src/NATS.Server/Events/EventTypes.cs:98` | Added capability getter for binary stream snapshot support | +| `(*ServerInfo).SetAccountNRG()` | `golang/nats-server/server/events.go:297` | PORTED | `src/NATS.Server/Events/EventTypes.cs:101` | Added capability setter for account NRG support | +| `(*ServerInfo).AccountNRG()` bool | `golang/nats-server/server/events.go:302` | PORTED | `src/NATS.Server/Events/EventTypes.cs:104` | Added capability getter for account NRG support | ### events.go — Unexported Methods on ClientInfo @@ -205,8 +205,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `(*Server).sendAccConnsUpdate()` | `golang/nats-server/server/events.go:2407` | MISSING | — | No .NET equivalent | | `(*Server).accConnsUpdate()` | `golang/nats-server/server/events.go:2506` | MISSING | — | No .NET equivalent | | `(*Server).nextEventID()` | `golang/nats-server/server/events.go:2516` | PARTIAL | `src/NATS.Server/Events/EventTypes.cs:943` | `EventBuilder.GenerateEventId()` uses `Guid.NewGuid()`; Go uses nuid (faster nano-ID generator) | -| `(*Server).accountConnectEvent()` | `golang/nats-server/server/events.go:2522` | PARTIAL | `src/NATS.Server/Events/InternalEventSystem.cs:303` | `SendConnectEvent()` exists; missing: JWT/IssuerKey/Tags/NameTag/Kind/ClientType/MQTTClientID fields in ConnectEventDetail | -| `(*Server).accountDisconnectEvent()` | `golang/nats-server/server/events.go:2569` | PARTIAL | `src/NATS.Server/Events/InternalEventSystem.cs:329` | `SendDisconnectEvent()` exists; missing: RTT/JWT/IssuerKey/Tags/NameTag/Kind/ClientType/MQTTClientID fields in DisconnectEventDetail | +| `(*Server).accountConnectEvent()` | `golang/nats-server/server/events.go:2522` | PORTED | `src/NATS.Server/Events/InternalEventSystem.cs:322` | Extended `ConnectEventDetail` and event mapping to include JWT/issuer/tags/name-tag/kind/client-type/MQTT client id parity fields | +| `(*Server).accountDisconnectEvent()` | `golang/nats-server/server/events.go:2569` | PORTED | `src/NATS.Server/Events/InternalEventSystem.cs:355` | Extended `DisconnectEventDetail` and event mapping to include RTT + JWT/issuer/tags/name-tag/kind/client-type/MQTT client id parity fields | | `(*Server).sendAuthErrorEvent()` | `golang/nats-server/server/events.go:2631` | PARTIAL | `src/NATS.Server/Events/InternalEventSystem.cs:277` | `SendAuthErrorEvent()` exists; Go uses `DisconnectEventMsg` shape for auth errors (surprising but correct); .NET uses `AuthErrorEventMsg` with different schema type | | `(*Server).sendAccountAuthErrorEvent()` | `golang/nats-server/server/events.go:2690` | MISSING | — | Account-level auth error event to account subject; no .NET equivalent | | `(*Server).sendOCSPPeerRejectEvent()` | `golang/nats-server/server/events.go:3267` | PARTIAL | `src/NATS.Server/Events/EventTypes.cs:612` | `OcspEventBuilder.BuildPeerReject()` helper exists; no publishing method on server; missing `Peer` CertInfo payload | @@ -257,7 +257,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | `getHash()` | `golang/nats-server/server/events.go:1141` | PORTED | `src/NATS.Server/Events/InternalEventSystem.cs:118` | SHA-256 8-char hash; same algorithm | -| `getHashSize()` | `golang/nats-server/server/events.go:1146` | MISSING | — | Parameterized size variant; only 8-char version ported | +| `getHashSize()` | `golang/nats-server/server/events.go:1146` | PORTED | `src/NATS.Server/Events/InternalEventSystem.cs:143` | Added `GetHashSize()` and size-aware `GetHash(string, int)` helpers; constructor now uses them for server hash generation | | `routeStat()` | `golang/nats-server/server/events.go:859` | NOT_APPLICABLE | — | Route stat collection; clustering not yet ported | | `newPubMsg()` | `golang/nats-server/server/events.go:435` | NOT_APPLICABLE | — | Pool-based pubMsg factory; .NET uses `PublishMessage` record without pooling | | `(*pubMsg).returnToPool()` | `golang/nats-server/server/events.go:452` | NOT_APPLICABLE | — | Pool return; .NET has no pool | @@ -266,7 +266,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `clearTimer()` | `golang/nats-server/server/events.go:3244` | NOT_APPLICABLE | — | Go timer management idiom; .NET uses `PeriodicTimer` / `CancellationToken` | | `totalSubs()` | `golang/nats-server/server/events.go:2973` | MISSING | — | No .NET equivalent | | `remoteLatencySubjectForResponse()` | `golang/nats-server/server/events.go:2860` | NOT_APPLICABLE | — | Latency tracking; not yet ported | -| `getAcceptEncoding()` | `golang/nats-server/server/events.go:2238` | MISSING | — | Parses Accept-Encoding header for compression type; no .NET equivalent | +| `getAcceptEncoding()` | `golang/nats-server/server/events.go:2238` | PORTED | `src/NATS.Server/Events/EventCompressor.cs:192` | Added `EventCompressor.GetAcceptEncoding(string?)` parser: prefers `snappy`/`s2`, falls back to `gzip`, otherwise `unsupported`, matching Go behavior. | | `(*Account).statz()` | `golang/nats-server/server/events.go:2446` | NOT_APPLICABLE | — | Account stats snapshot; Account class not yet in Events module | ### events.go — Constants / Subjects @@ -286,27 +286,27 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `serverPingReqSubj` | `golang/nats-server/server/events.go:68` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:35` | `ServerPing` — matching | | `accDirectReqSubj` | `golang/nats-server/server/events.go:51` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:38` | `AccountReq` — matching | | `inboxRespSubj` | `golang/nats-server/server/events.go:73` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:41` | `InboxResponse` — matching | -| `ocspPeerRejectEventSubj` | `golang/nats-server/server/events.go:95` | PARTIAL | `src/NATS.Server/Events/EventSubjects.cs:45` | Go: `$SYS.SERVER.%s.OCSP.PEER.CONN.REJECT`; .NET: `$SYS.SERVER.{0}.OCSP.PEER.REJECT` — different path segment | -| `ocspPeerChainlinkInvalidEventSubj` | `golang/nats-server/server/events.go:96` | MISSING | — | Go: `$SYS.SERVER.%s.OCSP.PEER.LINK.INVALID`; .NET `OcspChainValidation` uses `$SYS.SERVER.{0}.OCSP.CHAIN.VALIDATION` — different subject | -| `leafNodeConnectEventSubj` | `golang/nats-server/server/events.go:71` | PARTIAL | `src/NATS.Server/Events/EventSubjects.cs:28` | .NET subject `$SYS.SERVER.{0}.LEAFNODE.CONNECT` differs from Go `$SYS.ACCOUNT.%s.LEAFNODE.CONNECT` | -| `remoteLatencyEventSubj` | `golang/nats-server/server/events.go:72` | MISSING | — | No .NET equivalent | -| `userDirectInfoSubj` | `golang/nats-server/server/events.go:76` | MISSING | — | No .NET equivalent | -| `userDirectReqSubj` | `golang/nats-server/server/events.go:77` | MISSING | — | No .NET equivalent | -| `accNumSubsReqSubj` | `golang/nats-server/server/events.go:81` | MISSING | — | No .NET equivalent | -| `accSubsSubj` | `golang/nats-server/server/events.go:84` | MISSING | — | No .NET equivalent | -| `clientKickReqSubj` | `golang/nats-server/server/events.go:62` | MISSING | — | No .NET equivalent | -| `clientLDMReqSubj` | `golang/nats-server/server/events.go:63` | MISSING | — | No .NET equivalent | -| `serverStatsPingReqSubj` | `golang/nats-server/server/events.go:69` | MISSING | — | No .NET equivalent | -| `serverReloadReqSubj` | `golang/nats-server/server/events.go:70` | MISSING | — | No .NET equivalent | +| `ocspPeerRejectEventSubj` | `golang/nats-server/server/events.go:95` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:54` | Corrected to `$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT` | +| `ocspPeerChainlinkInvalidEventSubj` | `golang/nats-server/server/events.go:96` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:55` | Added `$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID` subject constant | +| `leafNodeConnectEventSubj` | `golang/nats-server/server/events.go:71` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:28` | Corrected to account-scoped subject `$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT` | +| `remoteLatencyEventSubj` | `golang/nats-server/server/events.go:72` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:30` | Added remote latency response subject constant | +| `userDirectInfoSubj` | `golang/nats-server/server/events.go:76` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:40` | Added user info request subject constant | +| `userDirectReqSubj` | `golang/nats-server/server/events.go:77` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:41` | Added per-user direct info request subject constant | +| `accNumSubsReqSubj` | `golang/nats-server/server/events.go:81` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:42` | Added account subscription-count request subject constant | +| `accSubsSubj` | `golang/nats-server/server/events.go:84` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:43` | Added account subscription-count response subject constant | +| `clientKickReqSubj` | `golang/nats-server/server/events.go:62` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:44` | Added kick-client request subject constant | +| `clientLDMReqSubj` | `golang/nats-server/server/events.go:63` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:45` | Added lame-duck-mode client request subject constant | +| `serverStatsPingReqSubj` | `golang/nats-server/server/events.go:69` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:46` | Added wildcard statsz ping request subject constant | +| `serverReloadReqSubj` | `golang/nats-server/server/events.go:70` | PORTED | `src/NATS.Server/Events/EventSubjects.cs:47` | Added server reload request subject constant | | `accPingReqSubj` | `golang/nats-server/server/events.go:52` | MISSING | — | No .NET equivalent | | `connsRespSubj` | `golang/nats-server/server/events.go:57` | MISSING | — | No .NET equivalent | | `accLookupReqSubj` / JWT subjects | `golang/nats-server/server/events.go:43` | NOT_APPLICABLE | — | JWT operator resolver subjects; not yet ported | | `InboxPrefix` | `golang/nats-server/server/events.go:2946` | MISSING | — | No .NET equivalent | -| `acceptEncodingHeader` / `contentEncodingHeader` | `golang/nats-server/server/events.go:2232` | MISSING | — | Header name constants for compression negotiation; no .NET equivalent | +| `acceptEncodingHeader` / `contentEncodingHeader` | `golang/nats-server/server/events.go:2232` | PORTED | `src/NATS.Server/Events/EventCompressor.cs:28` | Added `AcceptEncodingHeader` and `ContentEncodingHeader` constants for compression negotiation headers. | | `ConnectEventMsgType` | `golang/nats-server/server/events.go:163` | PORTED | `src/NATS.Server/Events/EventTypes.cs:193` | `ConnectEventMsg.EventType` constant — matching value | | `DisconnectEventMsgType` | `golang/nats-server/server/events.go:177` | PORTED | `src/NATS.Server/Events/EventTypes.cs:214` | `DisconnectEventMsg.EventType` — matching value | | `OCSPPeerRejectEventMsgType` | `golang/nats-server/server/events.go:191` | PORTED | `src/NATS.Server/Events/EventTypes.cs:522` | `OcspPeerRejectEventMsg.EventType` — matching value | -| `OCSPPeerChainlinkInvalidEventMsgType` | `golang/nats-server/server/events.go:205` | MISSING | — | No .NET equivalent with matching type string `io.nats.server.advisory.v1.ocsp_peer_link_invalid` | +| `OCSPPeerChainlinkInvalidEventMsgType` | `golang/nats-server/server/events.go:205` | PORTED | `src/NATS.Server/Events/EventTypes.cs:626` | Added `OcspPeerChainlinkInvalidEventMsg.EventType` constant with matching advisory type | | `AccountNumConnsMsgType` | `golang/nats-server/server/events.go:229` | PORTED | `src/NATS.Server/Events/EventTypes.cs:247` | `AccountNumConns.EventType` — matching value | --- @@ -406,5 +406,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Ported events compression parity helpers: added typed compression enum, gzip/snappy encode-decode support, Accept-Encoding parser, and header-name constants with focused events compression tests. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated from Go source analysis | auto | +| 2026-02-25 | Ported Events API option DTOs, server API response wrappers, OCSP peer/link advisory payloads, missing request subjects, account connect/disconnect parity fields, and hash-size helpers; validated with focused Events tests | codex | diff --git a/gaps/execution.md b/gaps/execution.md new file mode 100644 index 0000000..85ad931 --- /dev/null +++ b/gaps/execution.md @@ -0,0 +1,90 @@ +# Category Execution Instructions + +Use this runbook to execute one category end-to-end using the `executeplan` skill without running the full test suite. + +## Inputs +- `CATEGORY` (example: `protocol`) +- `gaps/plans.md` row for that category (contains category gap file, design file, and plan file paths) + +## Required Skills +- `executeplan` +- `using-git-worktrees` (required before implementation) +- `finishing-a-development-branch` (required after implementation) + +## Execution Flow +1. Resolve the category row from `gaps/plans.md`. +2. Read the category's design and plan files from the resolved row. +3. Announce: `I'm using executeplan to implement this plan.` +4. Create a brand-new git worktree on a new branch and verify clean status. +5. Execute the plan in batches (`executeplan` default batching), with checkpoints between batches. +6. Run only targeted unit tests for the category after each batch; do not run full-suite tests. +7. After implementation, verify the category gap file reflects the completed work. +8. Update the `Status` column in `gaps/plans.md` for the category: + - `complete` if no `MISSING` or `PARTIAL` rows remain + - ` remaining` where `N = MISSING + PARTIAL` + +## Strict Test Scope Policy +- Never run unscoped full test commands such as: + - `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj` +- Always use targeted test execution, for example: + - `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~Protocol"` + - `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~JetStream"` + - `dotnet test tests/NATS.Server.Tests/NATS.Server.Tests.csproj --filter "FullyQualifiedName~Auth"` +- If one filter is too broad, split into multiple narrow filters and run them separately. + +## Suggested Category Filter Tokens +Use these as starting points for `--filter "FullyQualifiedName~"`: + +| Category | Token(s) | +|---|---| +| `core-server` | `Server`, `Client` | +| `protocol` | `Protocol`, `Parser` | +| `subscriptions` | `Subscription` | +| `auth-and-accounts` | `Auth`, `Account` | +| `configuration` | `Configuration`, `Config` | +| `routes` | `Route` | +| `gateways` | `Gateway` | +| `leaf-nodes` | `Leaf` | +| `jetstream` | `JetStream` | +| `raft` | `Raft` | +| `mqtt` | `Mqtt` | +| `websocket` | `WebSocket` | +| `monitoring` | `Monitoring` | +| `events` | `Event` | +| `tls-security` | `Tls`, `Security` | +| `internal-ds` | `Internal` | +| `logging` | `Log`, `Logging` | +| `utilities-and-other` | `IO`, `Server` | +| `misc-uncategorized` | `Misc`, `Server` | + +## Gap Verification Rules (Category Gap File) +For the category gap file (`gaps/.md`): +1. Every implemented item must be updated in the Gap Inventory. +2. Each newly completed row must include: + - `Status = PORTED` + - concrete `.NET Equivalent` file:line + - concise notes for parity behavior +3. If behavior is still partial, keep `PARTIAL` and document what is still missing. +4. Do not mark `complete` in `gaps/plans.md` until both `MISSING` and `PARTIAL` counts are zero. + +## Status Update Command Snippet +After finishing a category, compute remaining gaps: + +```bash +remaining=$(awk -F'|' ' + NF >= 4 { + s=$4 + gsub(/^ +| +$/, "", s) + if (s=="MISSING" || s=="PARTIAL") c++ + } + END { print c+0 } +' "gaps/${CATEGORY}.md") + +if [ "$remaining" -eq 0 ]; then + status_value="complete" +else + status_value="${remaining} remaining" +fi +``` + +Then write `status_value` into that category row in `gaps/plans.md`. diff --git a/gaps/gateways.md b/gaps/gateways.md index 8b2f81c..3b391e8 100644 --- a/gaps/gateways.md +++ b/gaps/gateways.md @@ -94,9 +94,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `GatewayDoNotForceInterestOnlyMode` | gateway.go:130 | NOT_APPLICABLE | — | Test-only global flag; disables forced interest-only mode in tests. Go-specific test hook. | | `GatewayInterestMode` (enum: Optimistic/Transitioning/InterestOnly) | gateway.go:94–111 | PORTED | `src/NATS.Server/Gateways/GatewayInterestTracker.cs:16` | `GatewayInterestMode` enum with identical three values. | | `GatewayInterestMode.String()` | gateway.go:113 | NOT_APPLICABLE | — | Go stringer pattern; C# uses `ToString()` automatically. | -| `gwReplyPrefix` / `gwReplyPrefixLen` / `gwHashLen` / offset constants | gateway.go:49–58 | PARTIAL | `src/NATS.Server/Gateways/ReplyMapper.cs:12` | `GatewayReplyPrefix = "_GR_."` is present. Hash length and byte-offset arithmetic constants are missing; .NET uses string-segment parsing instead of fixed-width offsets. | -| `oldGWReplyPrefix` / `oldGWReplyPrefixLen` / `oldGWReplyStart` | gateway.go:43–46 | MISSING | — | Old `$GR.` reply prefix for backward-compat with pre-v2.9 servers is not represented in .NET. `ReplyMapper.TryRestoreGatewayReply` handles it partially via numeric-hash detection but has no dedicated constant. | -| `gatewayTLSInsecureWarning` | gateway.go:71 | MISSING | — | TLS insecure warning string; no TLS gateway support yet. | +| `gwReplyPrefix` / `gwReplyPrefixLen` / `gwHashLen` / offset constants | gateway.go:49–58 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:12–17` | Reply-prefix/length/hash-length constants are explicitly defined (`GatewayReplyPrefix`, `GatewayReplyPrefixLen`, `GatewayHashLen`, plus legacy counterparts). | +| `oldGWReplyPrefix` / `oldGWReplyPrefixLen` / `oldGWReplyStart` | gateway.go:43–46 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:13,15,31` | Legacy `$GR.` prefix constants and old-prefix detection are implemented via `IsGatewayRoutedSubject(..., out isOldPrefix)`. | +| `gatewayTLSInsecureWarning` | gateway.go:71 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:68` | Gateway TLS insecure warning constant is defined for parity/documentation and diagnostic use. | #### Structs / Types @@ -104,7 +104,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | `srvGateway` struct | gateway.go:134 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:66` | `GatewayManager` covers outbound/inbound maps, accept loop, discovery, registration, and stats. Missing: RTT-ordered outbound list (`outo`), `totalQSubs` atomic counter, `pasi` per-account subscription interest map, `rsubs` recent-subscription sync.Map, `sIDHash`/`routesIDByHash` for reply routing, `sqbsz`/`recSubExp`, and `oldHash`/`oldReplyPfx` for backward compat. | | `sitally` struct | gateway.go:189 | MISSING | — | Subject-interest tally (ref count + queue flag) used in `pasi.m`. No equivalent in .NET. | -| `gatewayCfg` struct | gateway.go:194 | PARTIAL | `src/NATS.Server/Configuration/GatewayOptions.cs:27` (`RemoteGatewayOptions`) | `RemoteGatewayOptions` covers name and URLs. Missing: `hash`/`oldHash` byte arrays, `implicit` flag, `connAttempts` counter, `tlsName`, `varzUpdateURLs`, URL management methods (`addURLs`, `updateURLs`, `getURLs`, `saveTLSHostname`), and per-remote TLS config. | +| `gatewayCfg` struct | gateway.go:194 | PARTIAL | `src/NATS.Server/Configuration/GatewayOptions.cs:27` (`RemoteGatewayOptions`) | Added parity state and helpers for `hash`/`oldHash`, `implicit`, connection attempts, TLS host capture, URL add/update/get flows, and varz URL-update flag. Remaining gap: per-remote TLS config wiring into active gateway dial/handshake path. | | `gateway` struct (per-client) | gateway.go:207 | MISSING | — | Per-connection gateway state (outbound flag, `outsim` sync.Map, `insim` map, `connectURL`, `useOldPrefix`, `interestOnlyMode`, `remoteName`). Absorbed into `GatewayConnection` but without full per-message interest tracking. | | `outsie` struct | gateway.go:229 | PARTIAL | `src/NATS.Server/Gateways/GatewayInterestTracker.cs` | `GatewayInterestTracker.AccountState` covers mode and no-interest set. Missing: per-account `sl *Sublist` for queue-sub tracking in InterestOnly mode, `qsubs` counter. | | `insie` struct | gateway.go:256 | MISSING | — | Inbound per-account no-interest set with mode tracking. No distinct type in .NET; `GatewayInterestTracker` handles outbound-side equivalent but not the inbound RS-/RS+ tracking map. | @@ -131,9 +131,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `validateGatewayOptions` | gateway.go:306 | MISSING | — | Validates gateway config (name, port, remote URLs). No validation equivalent in .NET. | -| `getGWHash` (standalone) | gateway.go:335 | MISSING | — | Computes 6-char hash for gateway name. Used for reply prefix construction. Not ported. | -| `getOldHash` (standalone) | gateway.go:339 | MISSING | — | SHA-256-based 4-char hash for old `$GR.` prefix. Not ported. | +| `validateGatewayOptions` | gateway.go:306 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:113` (`ValidateGatewayOptions`) | Basic gateway config validation implemented for required name, valid port range, and non-empty remotes. | +| `getGWHash` (standalone) | gateway.go:335 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:75` (`ComputeGatewayHash`) | Deterministic short gateway hash helper implemented (6-char hex). | +| `getOldHash` (standalone) | gateway.go:339 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:85` (`ComputeOldGatewayHash`) | Deterministic legacy short hash helper implemented (4-char hex). | | `Server.newGateway` | gateway.go:350 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:90` | `GatewayManager` constructor covers basic setup. Missing: hash computation, reply prefix assembly, `oldReplyPfx`, `pasi` init, resolver config. | | `Server.startGateways` | gateway.go:487 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:161` (`StartAsync`) | `StartAsync` starts accept loop and connects to remotes. Missing: solicit delay, cluster-formation wait. | | `Server.startGatewayAcceptLoop` | gateway.go:511 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:326` (`AcceptLoopAsync`) | Accept loop exists. Missing: TLS config, `authRequired`, reject-unknown check, advertising, `GatewayIOM` flag, INFO protocol send. | @@ -141,7 +141,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.solicitGateways` | gateway.go:668 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:173` (`StartAsync` remote loop) | Connects to configured remotes. Missing: implicit vs explicit distinction, goroutine-per-remote with proper lifecycle. | | `Server.reconnectGateway` | gateway.go:689 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:149` (`ReconnectGatewayAsync`) | Exponential backoff reconnect delay with jitter. | | `Server.solicitGateway` | gateway.go:706 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:358` (`ConnectWithRetryAsync`) | Retry loop with multiple URL support. Missing: random URL selection, `shouldReportConnectErr` throttling, implicit gateway retry limits, DNS resolution via `resolver`, `ConnectBackoff` flag. | -| `srvGateway.hasInbound` | gateway.go:790 | MISSING | — | Checks if an inbound connection for a named gateway exists. Not implemented. | +| `srvGateway.hasInbound` | gateway.go:790 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:345` (`HasInbound`) | Inbound-connection presence check is implemented by remote server id. | | `Server.createGateway` | gateway.go:805 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:344` (`HandleInboundAsync`) and `ConnectWithRetryAsync` | Creates client connection for inbound/outbound. Missing: full client lifecycle, TLS handshake, CONNECT/INFO protocol, `expectConnect` flag, ping timer setup, temp-client registration. | | `client.sendGatewayConnect` | gateway.go:958 | PARTIAL | `src/NATS.Server/Gateways/GatewayConnection.cs:113` (`PerformOutboundHandshakeAsync`) | Handshake sends server ID. Go sends full CONNECT JSON with auth, TLS flag, gateway name. .NET sends a simplified `GATEWAY {serverId}` line. | | `client.processGatewayConnect` | gateway.go:993 | MISSING | — | Parses CONNECT from inbound gateway; validates gateway field, rejects wrong port/unknown gateways. No protocol parsing in .NET. | @@ -155,32 +155,32 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.processGatewayInfoFromRoute` | gateway.go:1394 | MISSING | — | Handles gateway gossip INFO received via a cluster route connection. | | `Server.sendGatewayConfigsToRoute` | gateway.go:1406 | MISSING | — | Sends known outbound gateway configs to a new route connection. | | `Server.processImplicitGateway` | gateway.go:1453 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:119` (`ProcessImplicitGateway`) | Records discovered gateway name. Missing: URL augmentation of existing config, creation of `gatewayCfg`, launching `solicitGateway` goroutine for the new implicit remote. | -| `Server.NumOutboundGateways` | gateway.go:1501 | MISSING | — | Public test-facing count of outbound connections. No exact equivalent. | -| `Server.numOutboundGateways` | gateway.go:1506 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:284` (`GetConnectedGatewayCount`) | Returns connected count from registrations. Does not distinguish inbound vs outbound. | -| `Server.numInboundGateways` | gateway.go:1514 | MISSING | — | Count of inbound gateway connections. No equivalent. | +| `Server.NumOutboundGateways` | gateway.go:1501 | PORTED | `src/NATS.Server/NatsServer.cs:155` (`NumOutboundGateways`) | Public server-facing outbound gateway count now exposed. | +| `Server.numOutboundGateways` | gateway.go:1506 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:331` (`NumOutboundGateways`) | Manager now computes outbound count from live connection direction (`IsOutbound`). | +| `Server.numInboundGateways` | gateway.go:1514 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:338` (`NumInboundGateways`), `src/NATS.Server/NatsServer.cs:156` | Inbound gateway count is now tracked and exposed. | | `Server.getRemoteGateway` | gateway.go:1522 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:264` (`GetRegistration`) | Returns registration by name. Missing: returns `gatewayCfg` in Go (with TLS, URLs, hash); .NET returns `GatewayRegistration` (only state/stats). | -| `gatewayCfg.bumpConnAttempts` | gateway.go:1530 | MISSING | — | Test helper to increment connection attempts counter. | -| `gatewayCfg.getConnAttempts` | gateway.go:1537 | MISSING | — | Test helper to read connection attempts. | -| `gatewayCfg.resetConnAttempts` | gateway.go:1545 | MISSING | — | Test helper to reset connection attempts. | -| `gatewayCfg.isImplicit` | gateway.go:1552 | MISSING | — | Returns whether a gateway config was discovered (implicit) vs configured (explicit). | -| `gatewayCfg.getURLs` | gateway.go:1561 | MISSING | — | Returns randomly shuffled URL slice for connection attempts. | -| `gatewayCfg.getURLsAsStrings` | gateway.go:1576 | MISSING | — | Returns URL host strings for gossip INFO. | -| `gatewayCfg.updateURLs` | gateway.go:1588 | MISSING | — | Rebuilds URL map from config + INFO-discovered URLs. | -| `gatewayCfg.saveTLSHostname` | gateway.go:1612 | MISSING | — | Saves TLS ServerName from a URL hostname. TLS not implemented. | -| `gatewayCfg.addURLs` | gateway.go:1621 | MISSING | — | Adds newly discovered URLs into the URL map. | +| `gatewayCfg.bumpConnAttempts` | gateway.go:1530 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:57` (`RemoteGatewayOptions.BumpConnAttempts`) | Added connection-attempt increment helper. | +| `gatewayCfg.getConnAttempts` | gateway.go:1537 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:59` (`RemoteGatewayOptions.GetConnAttempts`) | Added connection-attempt read helper. | +| `gatewayCfg.resetConnAttempts` | gateway.go:1545 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:61` (`RemoteGatewayOptions.ResetConnAttempts`) | Added connection-attempt reset helper. | +| `gatewayCfg.isImplicit` | gateway.go:1552 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:63` (`RemoteGatewayOptions.IsImplicit`) | Added implicit-vs-explicit gateway config query. | +| `gatewayCfg.getURLs` | gateway.go:1561 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:65` (`RemoteGatewayOptions.GetUrls`) | Added normalized + shuffled URL list helper for connection attempts. | +| `gatewayCfg.getURLsAsStrings` | gateway.go:1576 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:84` (`RemoteGatewayOptions.GetUrlsAsStrings`) | Added URL string projection helper for gossip/config sync paths. | +| `gatewayCfg.updateURLs` | gateway.go:1588 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:92` (`RemoteGatewayOptions.UpdateUrls`) | Added merged configured+discovered URL rebuild helper with normalization/deduplication. | +| `gatewayCfg.saveTLSHostname` | gateway.go:1612 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:100` (`RemoteGatewayOptions.SaveTlsHostname`) | Added TLS hostname extraction/storage from URL host. | +| `gatewayCfg.addURLs` | gateway.go:1621 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:106` (`RemoteGatewayOptions.AddUrls`) | Added incremental discovered-URL add helper with normalization/deduplication. | | `Server.addGatewayURL` | gateway.go:1648 | MISSING | — | Adds a URL to the server's gateway URL set and regenerates INFO JSON. | | `Server.removeGatewayURL` | gateway.go:1661 | MISSING | — | Removes a URL from the gateway URL set and regenerates INFO JSON. | | `Server.sendAsyncGatewayInfo` | gateway.go:1676 | MISSING | — | Sends updated INFO to all inbound gateway connections (e.g., after URL change). | -| `Server.getGatewayURL` | gateway.go:1688 | MISSING | — | Returns this server's gateway listen URL string. | -| `Server.getGatewayName` | gateway.go:1697 | MISSING | — | Returns this server's gateway cluster name. | +| `Server.getGatewayURL` | gateway.go:1688 | PORTED | `src/NATS.Server/NatsServer.cs:259` | Added gateway listen URL accessor that returns configured listen endpoint when gateway manager is present. | +| `Server.getGatewayName` | gateway.go:1697 | PORTED | `src/NATS.Server/NatsServer.cs:260` | Added gateway name accessor returning configured gateway cluster name. | | `Server.getAllGatewayConnections` | gateway.go:1703 | MISSING | — | Collects all inbound + outbound gateway clients into a map. | | `Server.registerInboundGatewayConnection` | gateway.go:1720 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:392` (`Register`) | `Register` adds connection to dictionary. Missing: separate inbound map keyed by CID. | | `Server.registerOutboundGatewayConnection` | gateway.go:1728 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:392` (`Register`) | `Register` handles registration. Missing: duplicate-prevention logic (return false if name already exists), RTT-ordered `outo` list. | -| `Server.getOutboundGatewayConnection` | gateway.go:1743 | MISSING | — | Returns outbound connection by name. No named lookup of outbound connections. | -| `Server.getOutboundGatewayConnections` | gateway.go:1752 | MISSING | — | Returns all outbound connections in RTT order. | -| `Server.getInboundGatewayConnections` | gateway.go:1778 | MISSING | — | Returns all inbound connections. | +| `Server.getOutboundGatewayConnection` | gateway.go:1743 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:352` (`GetOutboundGatewayConnection`) | Outbound connection lookup by remote server id is implemented. | +| `Server.getOutboundGatewayConnections` | gateway.go:1752 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:359` (`GetOutboundGatewayConnections`) | Outbound connection snapshot enumeration is implemented. | +| `Server.getInboundGatewayConnections` | gateway.go:1778 | PORTED | `src/NATS.Server/Gateways/GatewayManager.cs:366` (`GetInboundGatewayConnections`) | Inbound connection snapshot enumeration is implemented. | | `Server.removeRemoteGatewayConnection` | gateway.go:1788 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:416` (`WatchConnectionAsync`) | Removes connection and decrements stats. Missing: outbound-specific cleanup (delete from `outo`/`out`, remove qsub tracking from `totalQSubs`), inbound-specific cleanup (remove `_R_` subscriptions). | -| `Server.GatewayAddr` | gateway.go:1862 | MISSING | — | Returns `*net.TCPAddr` for the gateway listener. No equivalent (only `ListenEndpoint` string). | +| `Server.GatewayAddr` | gateway.go:1862 | PORTED | `src/NATS.Server/NatsServer.cs:258` | Added gateway address accessor. .NET returns `host:port` string endpoint rather than Go `*net.TCPAddr`. | | `client.processGatewayAccountUnsub` | gateway.go:1875 | PARTIAL | `src/NATS.Server/Gateways/GatewayInterestTracker.cs:86` (`TrackNoInterest`) | Tracks no-interest at account level. Missing: handling of queue subs (reset `ni` map but keep entry if `qsubs > 0`), Go's nil-vs-entry distinction in `outsim`. | | `client.processGatewayAccountSub` | gateway.go:1904 | PARTIAL | `src/NATS.Server/Gateways/GatewayInterestTracker.cs:61` (`TrackInterest`) | Clears no-interest in optimistic mode. Missing: queue-sub check (don't delete entry if `qsubs > 0`). | | `client.processGatewayRUnsub` | gateway.go:1934 | MISSING | — | Parses RS- protocol; for optimistic mode stores in ni map, for InterestOnly/queue removes from sublist. Full RS- processing not ported. | @@ -191,7 +191,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.sendQueueSubOrUnsubToGateways` | gateway.go:2335 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:199` (`PropagateLocalUnsubscription`) | Propagates queue sub changes. Missing: wire RS+/RS- protocol, A- clearing logic. | | `Server.gatewayUpdateSubInterest` | gateway.go:2391 | MISSING | — | Ref-counted `pasi` map update + recent-sub tracking + triggers send to gateways. Core subscription-interest accounting not ported. | | `isGWRoutedReply` (standalone) | gateway.go:2484 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:17` (`HasGatewayReplyPrefix`) | Detects `_GR_.` prefix with length guard. | -| `isGWRoutedSubjectAndIsOldPrefix` (standalone) | gateway.go:2490 | PARTIAL | `src/NATS.Server/Gateways/ReplyMapper.cs:17` | `HasGatewayReplyPrefix` checks new prefix only. Old `$GR.` prefix detection missing. | +| `isGWRoutedSubjectAndIsOldPrefix` (standalone) | gateway.go:2490 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:31` (`IsGatewayRoutedSubject`) | Implemented explicit routed-subject detection with old-prefix flag output (`isOldPrefix`) for `_GR_.` and `$GR.`. | | `hasGWRoutedReplyPrefix` (standalone) | gateway.go:2502 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:17` (`HasGatewayReplyPrefix`) | Equivalent prefix check. | | `srvGateway.shouldMapReplyForGatewaySend` | gateway.go:2507 | MISSING | — | Checks `rsubs` sync.Map to decide if a reply subject needs gateway mapping. No `rsubs` equivalent. | | `client.sendMsgToGateways` | gateway.go:2540 | PARTIAL | `src/NATS.Server/Gateways/GatewayManager.cs:181` (`ForwardMessageAsync`) | Iterates connections and sends. Missing: direct-send path for `_GR_` subjects (hash routing), queue group filtering, reply subject mapping, header stripping for non-header peers, message tracing, per-account stats, RTT-ordered iteration. | @@ -211,7 +211,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.trackGWReply` | gateway.go:3324 | PARTIAL | `src/NATS.Server/Gateways/ReplyMapper.cs:231` (`ReplyMapCache.Set`) | Caches reply mapping with TTL. Missing: per-client vs per-account duality, `gwrm.m` sync.Map for background cleanup, `check int32` atomic flag, `gwrm.ch` channel to trigger expiry timer. | | `Server.startGWReplyMapExpiration` | gateway.go:3371 | PARTIAL | `src/NATS.Server/Gateways/ReplyMapper.cs:267` (`ReplyMapCache.PurgeExpired`) | Purge is manual on-demand. Go runs a dedicated goroutine with timer reset on new entries via channel. No background expiry goroutine in .NET. | | `gwReplyMapping.get` | gateway.go:280 | PORTED | `src/NATS.Server/Gateways/ReplyMapper.cs:202` (`ReplyMapCache.TryGet`) | LRU get with TTL check. | -| `RemoteGatewayOpts.clone` | gateway.go:290 | MISSING | — | Deep-copies a `RemoteGatewayOpts` including TLS config clone. No clone method on `RemoteGatewayOptions`. | +| `RemoteGatewayOpts.clone` | gateway.go:290 | PORTED | `src/NATS.Server/Configuration/GatewayOptions.cs:36` (`RemoteGatewayOptions.Clone`) | Deep-copy helper implemented for remote gateway option name + URL list. | #### Additional .NET-Only Types (No Go Equivalent) @@ -247,5 +247,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Executed gateways batch 4 accessor parity slice: added server gateway accessors (`GatewayAddr`, `GetGatewayURL`, `GetGatewayName`) and targeted tests (`GatewayServerAccessorParityBatch4Tests`). Reclassified 3 rows to PORTED. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory completed: analyzed all ~3,427 lines of gateway.go; classified 80+ Go symbols against 6 .NET source files. Final counts: 9 PORTED, 35 PARTIAL, 37 MISSING, 5 NOT_APPLICABLE. | claude-sonnet-4-6 | +| 2026-02-25 | Ported gateway parity helper batch: reply-prefix/hash constants and old-prefix detection, gateway hash helpers (`getGWHash`/`getOldHash` analogs), gateway option validator, TLS warning constant, and `RemoteGatewayOptions.Clone`; added focused tests and updated status rows. | codex | +| 2026-02-25 | Ported gateway connection-direction parity batch: added inbound/outbound classification (`IsOutbound`), count/lookups (`NumOutboundGateways`, `NumInboundGateways`, `HasInbound`, outbound/inbound connection snapshots), and server wrappers with focused tests. | codex | +| 2026-02-25 | Ported gateway remote-config parity batch: added `RemoteGatewayOptions` attempt counters and URL lifecycle helpers (`Bump/Get/ResetConnAttempts`, `IsImplicit`, `GetUrls`, `GetUrlsAsStrings`, `UpdateUrls`, `SaveTlsHostname`, `AddUrls`) with focused tests (`GatewayRemoteConfigParityBatch3Tests`). | codex | diff --git a/gaps/internal-ds.md b/gaps/internal-ds.md index 8c18ef1..01869d2 100644 --- a/gaps/internal-ds.md +++ b/gaps/internal-ds.md @@ -120,7 +120,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `SequenceSet.Union` (method) | `golang/nats-server/server/avl/seqset.go:191` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:203` | | | `Union` (function) | `golang/nats-server/server/avl/seqset.go:208` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:228` | `SequenceSet.CreateUnion` static method | | `SequenceSet.EncodeLen` | `golang/nats-server/server/avl/seqset.go:238` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:252` | `EncodeLength()` in .NET | -| `SequenceSet.Encode` | `golang/nats-server/server/avl/seqset.go:242` | PARTIAL | `src/NATS.Server/Internal/Avl/SequenceSet.cs:255` | Go signature takes optional buf to reuse; .NET always allocates new. Behavior equivalent. | +| `SequenceSet.Encode` | `golang/nats-server/server/avl/seqset.go:242` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:266` | Added destination-buffer overload (`Encode(byte[] destination)`) enabling caller buffer reuse parity | | `ErrBadEncoding` | `golang/nats-server/server/avl/seqset.go:276` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:288` | Thrown as `InvalidOperationException` | | `ErrBadVersion` | `golang/nats-server/server/avl/seqset.go:277` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:295` | Thrown as `InvalidOperationException` | | `ErrSetNotEmpty` | `golang/nats-server/server/avl/seqset.go:278` | PORTED | `src/NATS.Server/Internal/Avl/SequenceSet.cs:93` | Thrown as `InvalidOperationException` | @@ -166,9 +166,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `SubjectTree.match` (internal) | `golang/nats-server/server/stree/stree.go:318` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:383` | `MatchInternal` | | `SubjectTree.iter` (internal) | `golang/nats-server/server/stree/stree.go:418` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:512` | `IterInternal` | | `LazyIntersect[TL,TR]` | `golang/nats-server/server/stree/stree.go:463` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:584` | `SubjectTreeHelper.LazyIntersect` static | -| `IntersectGSL[T,SL]` | `golang/nats-server/server/stree/stree.go:488` | MISSING | — | No .NET equivalent. Intersects stree with gsl.GenericSublist. Used in JetStream consumer NumPending. | -| `_intersectGSL` (internal) | `golang/nats-server/server/stree/stree.go:496` | MISSING | — | Helper for IntersectGSL | -| `hasInterestForTokens` (internal) | `golang/nats-server/server/stree/stree.go:521` | MISSING | — | Token-boundary interest check for GSL intersection | +| `IntersectGSL[T,SL]` | `golang/nats-server/server/stree/stree.go:488` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:675` | Added GSL intersection traversal for subject tree entries | +| `_intersectGSL` (internal) | `golang/nats-server/server/stree/stree.go:496` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:686` | Added recursive intersection helper | +| `hasInterestForTokens` (internal) | `golang/nats-server/server/stree/stree.go:521` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:717` | Added token-boundary short-circuit using `HasInterestStartingIn` | | `bytesToString` (internal) | `golang/nats-server/server/stree/stree.go:534` | NOT_APPLICABLE | — | Go `unsafe` zero-copy string conversion. In .NET, `System.Text.Encoding.UTF8.GetString` or `MemoryMarshal.Cast` used instead | --- @@ -276,10 +276,10 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `SubjectTree.Dump` | `golang/nats-server/server/stree/dump.go:23` | MISSING | — | Debug utility for printing tree structure to an `io.Writer`. No .NET equivalent. Low priority — debug/diagnostic only. | -| `SubjectTree.dump` (internal) | `golang/nats-server/server/stree/dump.go:29` | MISSING | — | Internal helper for Dump | -| `dumpPre` | `golang/nats-server/server/stree/dump.go:59` | MISSING | — | Indentation helper for Dump | -| `leaf.kind`, `node4.kind`, etc. | `golang/nats-server/server/stree/dump.go:51` | PARTIAL | `src/NATS.Server/Internal/SubjectTree/Nodes.cs` | `INode.Kind` property exists on each node type, but `Dump` method is not implemented | +| `SubjectTree.Dump` | `golang/nats-server/server/stree/dump.go:23` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:172` | Added debug dump writer with root traversal and trailing newline | +| `SubjectTree.dump` (internal) | `golang/nats-server/server/stree/dump.go:29` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:180` | Added recursive node/leaf dump helper | +| `dumpPre` | `golang/nats-server/server/stree/dump.go:59` | PORTED | `src/NATS.Server/Internal/SubjectTree/SubjectTree.cs:205` | Added indentation helper matching Go depth formatting | +| `leaf.kind`, `node4.kind`, etc. | `golang/nats-server/server/stree/dump.go:51` | PORTED | `src/NATS.Server/Internal/SubjectTree/Nodes.cs:21` | `INode.Kind` values are now actively consumed by `SubjectTree.Dump` output | --- @@ -291,7 +291,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ErrInvalidVersion` | `golang/nats-server/server/thw/thw.go:28` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:289` | Thrown as `InvalidOperationException` | | `slot` (struct) | `golang/nats-server/server/thw/thw.go:39` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:406` | `Slot` internal class | | `HashWheel` (struct) | `golang/nats-server/server/thw/thw.go:45` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:17` | `HashWheel` class | -| `HashWheelEntry` (struct) | `golang/nats-server/server/thw/thw.go:52` | MISSING | — | Go uses this struct for entry representation in some contexts; .NET uses `(ulong, long)` tuples inline instead | +| `HashWheelEntry` (struct) | `golang/nats-server/server/thw/thw.go:52` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:407` | Added `HashWheelEntry` record struct (`Sequence`, `Expires`) | | `NewHashWheel` | `golang/nats-server/server/thw/thw.go:58` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:34` | Constructor in .NET | | `HashWheel.getPosition` (internal) | `golang/nats-server/server/thw/thw.go:66` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:50` | `GetPosition` private static | | `newSlot` (internal) | `golang/nats-server/server/thw/thw.go:71` | PORTED | `src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs:406` | Inline slot initialization in .NET | @@ -315,7 +315,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ErrNotFound` | `golang/nats-server/server/gsl/gsl.go:42` | PORTED | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:12` | `GslErrors.NotFound` | | `ErrNilChan` | `golang/nats-server/server/gsl/gsl.go:43` | NOT_APPLICABLE | — | Go channel-specific error; channels don't apply to .NET pattern | | `ErrAlreadyRegistered` | `golang/nats-server/server/gsl/gsl.go:44` | NOT_APPLICABLE | — | Used by notification channels in Go; no notification channel pattern in .NET port | -| `SimpleSublist` (type alias) | `golang/nats-server/server/gsl/gsl.go:49` | PARTIAL | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:650` | `SimpleSubjectList` uses `int` instead of `struct{}`. Functionally equivalent but not a true zero-allocation alias | +| `SimpleSublist` (type alias) | `golang/nats-server/server/gsl/gsl.go:49` | PORTED | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:656` | `SimpleSubjectList` now aliases `GenericSubjectList` where marker value mirrors Go's empty struct payload | | `NewSimpleSublist` | `golang/nats-server/server/gsl/gsl.go:52` | PORTED | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:650` | `new SimpleSubjectList()` | | `GenericSublist[T]` (struct) | `golang/nats-server/server/gsl/gsl.go:57` | PORTED | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:76` | `GenericSubjectList` | | `node[T]` (struct) | `golang/nats-server/server/gsl/gsl.go:64` | PORTED | `src/NATS.Server/Internal/Gsl/GenericSubjectList.cs:52` | `Node` | @@ -359,7 +359,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ProcUsage` (freebsd/netbsd/openbsd/dragonfly/solaris/zos) | `golang/nats-server/server/pse/pse_*.go` | NOT_APPLICABLE | — | Platform-specific Go build-tagged files. .NET runtime abstracts these OS differences. | | `ProcUsage` (wasm/rumprun) | `golang/nats-server/server/pse/pse_wasm.go` | NOT_APPLICABLE | — | Stub/no-op implementations for unsupported platforms; not needed in .NET | | `updateUsage` (darwin, internal) | `golang/nats-server/server/pse/pse_darwin.go:56` | PORTED | `src/NATS.Server/Monitoring/VarzHandler.cs:39` | CPU sampling logic in VarzHandler | -| `periodic` (darwin, internal) | `golang/nats-server/server/pse/pse_darwin.go:76` | PARTIAL | `src/NATS.Server/Monitoring/VarzHandler.cs:39` | Go runs periodic background timer; .NET samples on each `/varz` request with 1s cache. Semantics slightly different. | +| `periodic` (darwin, internal) | `golang/nats-server/server/pse/pse_darwin.go:76` | PORTED | `src/NATS.Server/Monitoring/VarzHandler.cs:30` | Added 1s background timer sampler (`SampleCpuUsage`) with synchronized cached CPU reads in `/varz`, matching periodic semantics instead of request-only sampling | --- @@ -367,9 +367,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `Memory` (darwin) | `golang/nats-server/server/sysmem/mem_darwin.go:18` | MISSING | — | Queries total physical RAM via `hw.memsize` sysctl. No .NET equivalent in codebase. Used by JetStream for sizing decisions. | -| `Memory` (linux) | `golang/nats-server/server/sysmem/mem_linux.go:20` | MISSING | — | Queries via `syscall.Sysinfo`. No .NET equivalent found. | -| `Memory` (windows) | `golang/nats-server/server/sysmem/mem_windows.go` | MISSING | — | No .NET equivalent found. Can be implemented via `GC.GetGCMemoryInfo().TotalAvailableMemoryBytes`. | +| `Memory` (darwin) | `golang/nats-server/server/sysmem/mem_darwin.go:18` | PORTED | `src/NATS.Server/Internal/SysMem/SystemMemory.cs:12` | Added cross-platform `SystemMemory.Memory()` backed by runtime memory info | +| `Memory` (linux) | `golang/nats-server/server/sysmem/mem_linux.go:20` | PORTED | `src/NATS.Server/Internal/SysMem/SystemMemory.cs:12` | Added cross-platform `SystemMemory.Memory()` backed by runtime memory info | +| `Memory` (windows) | `golang/nats-server/server/sysmem/mem_windows.go` | PORTED | `src/NATS.Server/Internal/SysMem/SystemMemory.cs:12` | Added cross-platform `SystemMemory.Memory()` backed by runtime memory info | | `Memory` (bsd/solaris/wasm/zos) | `golang/nats-server/server/sysmem/mem_bsd.go` etc. | NOT_APPLICABLE | — | Platform-specific stubs; .NET runtime abstracts these. `GCMemoryInfo` is the cross-platform equivalent. | | `sysctlInt64` | `golang/nats-server/server/sysmem/sysctl.go:23` | NOT_APPLICABLE | — | Darwin/BSD internal helper using unsafe sysctl; .NET abstracts this entirely | @@ -400,3 +400,4 @@ After porting work is completed: |------|--------|----| | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: 157 PORTED, 4 PARTIAL, 10 MISSING, 8 NOT_APPLICABLE, 0 DEFERRED | auto | +| 2026-02-25 | Completed periodic PSE parity by moving CPU sampling to a 1s background timer in `VarzHandler` and adding targeted parity test coverage | codex | diff --git a/gaps/jetstream.md b/gaps/jetstream.md index 9dde20a..5017c8c 100644 --- a/gaps/jetstream.md +++ b/gaps/jetstream.md @@ -140,12 +140,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| JetStreamConfig (struct) | golang/nats-server/server/jetstream.go:42 | PARTIAL | src/NATS.Server/Configuration/JetStreamOptions.cs:5 | .NET has MaxMemoryStore, MaxFileStore, MaxStreams, MaxConsumers, Domain. Missing: SyncInterval, SyncAlways, CompressOK, UniqueTag, Strict fields | -| JetStreamStats (struct) | golang/nats-server/server/jetstream.go:55 | MISSING | — | Server-level usage stats (Memory, Store, ReservedMemory, ReservedStore, Accounts, HAAssets, API) not modeled | -| JetStreamAccountLimits (struct) | golang/nats-server/server/jetstream.go:65 | PARTIAL | src/NATS.Server/Configuration/JetStreamOptions.cs:5 | MaxStreams/MaxConsumers present. Missing: MaxAckPending, MemoryMaxStreamBytes, StoreMaxStreamBytes, MaxBytesRequired, tiered limits | -| JetStreamTier (struct) | golang/nats-server/server/jetstream.go:76 | MISSING | — | Tiered accounting not implemented | +| JetStreamConfig (struct) | golang/nats-server/server/jetstream.go:42 | PORTED | src/NATS.Server/Configuration/JetStreamOptions.cs | Added missing config fields: `SyncInterval`, `SyncAlways`, `CompressOk`, `UniqueTag`, `Strict` (plus parser wiring) | +| JetStreamStats (struct) | golang/nats-server/server/jetstream.go:55 | PORTED | src/NATS.Server/JetStream/JetStreamParityModels.cs (`JetStreamStats`) | Added server-usage model with `Memory`, `Store`, `ReservedMemory`, `ReservedStore`, `Accounts`, `HaAssets`, and `Api` | +| JetStreamAccountLimits (struct) | golang/nats-server/server/jetstream.go:65 | PORTED | src/NATS.Server/JetStream/JetStreamParityModels.cs (`JetStreamAccountLimits`), src/NATS.Server/Configuration/JetStreamOptions.cs | Added missing limits fields: `MaxAckPending`, `MemoryMaxStreamBytes`, `StoreMaxStreamBytes`, `MaxBytesRequired`, and tier map support | +| JetStreamTier (struct) | golang/nats-server/server/jetstream.go:76 | PORTED | src/NATS.Server/JetStream/JetStreamParityModels.cs (`JetStreamTier`) | Added per-tier model (`Name`, `Memory`, `Store`, `Streams`, `Consumers`) | | JetStreamAccountStats (struct) | golang/nats-server/server/jetstream.go:87 | PARTIAL | src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs:105 | JetStreamAccountInfo has Streams/Consumers counts only. Missing: memory/store usage, tiers, domain, API stats | -| JetStreamAPIStats (struct) | golang/nats-server/server/jetstream.go:95 | MISSING | — | API level/total/errors/inflight stats not tracked | +| JetStreamAPIStats (struct) | golang/nats-server/server/jetstream.go:95 | PORTED | src/NATS.Server/JetStream/JetStreamParityModels.cs (`JetStreamApiStats`) | Added API stats model with `Level`, `Total`, `Errors`, `Inflight` | | jetStream (internal struct) | golang/nats-server/server/jetstream.go:103 | PARTIAL | src/NATS.Server/JetStream/JetStreamService.cs:11 | JetStreamService covers lifecycle. Missing: apiInflight/apiTotal/apiErrors atomics, memUsed/storeUsed tracking, accounts map, apiSubs, cluster, oos/shuttingDown state | | jsAccount (internal struct) | golang/nats-server/server/jetstream.go:151 | MISSING | — | Per-account JetStream state (streams map, usage tracking, cluster usage updates) not modeled | | jsaUsage (internal struct) | golang/nats-server/server/jetstream.go:181 | MISSING | — | Per-account mem/store usage tracking | @@ -164,12 +164,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | enableAllJetStreamServiceImportsAndMappings (Account method) | golang/nats-server/server/jetstream.go:714 | MISSING | — | Per-account service imports and domain mappings | | configJetStream (Server method) | golang/nats-server/server/jetstream.go:771 | MISSING | — | Per-account JS config (enable/update/disable) | | configAllJetStreamAccounts (Server method) | golang/nats-server/server/jetstream.go:809 | MISSING | — | Walk all accounts and restore JetStream state | -| JetStreamEnabled (Server method) | golang/nats-server/server/jetstream.go:904 | PARTIAL | src/NATS.Server/JetStream/JetStreamService.cs:48 | IsRunning property equivalent | +| JetStreamEnabled (Server method) | golang/nats-server/server/jetstream.go:904 | PORTED | src/NATS.Server/NatsServer.cs:159 (`JetStreamEnabled`) | Server-level JetStream enabled check now exposed and backed by service running state | | JetStreamEnabledForDomain (Server method) | golang/nats-server/server/jetstream.go:909 | MISSING | — | Domain-wide JS availability check | | signalPullConsumers (Server method) | golang/nats-server/server/jetstream.go:930 | MISSING | — | Shutdown signal to R1 pull consumers | | shutdownJetStream (Server method) | golang/nats-server/server/jetstream.go:977 | PARTIAL | src/NATS.Server/JetStream/JetStreamService.cs:141 | Basic cleanup in DisposeAsync. Missing: account removal, cluster qch signaling | -| JetStreamConfig (Server method) | golang/nats-server/server/jetstream.go:1055 | MISSING | — | Returns copy of current config | -| StoreDir (Server method) | golang/nats-server/server/jetstream.go:1065 | MISSING | — | Returns current StoreDir | +| JetStreamConfig (Server method) | golang/nats-server/server/jetstream.go:1055 | PORTED | src/NATS.Server/NatsServer.cs:161 (`JetStreamConfig`) | Returns a copy of configured JetStream options (store dir, limits, domain) | +| StoreDir (Server method) | golang/nats-server/server/jetstream.go:1065 | PORTED | src/NATS.Server/NatsServer.cs:177 (`StoreDir`) | Server now exposes current configured JetStream store directory | | JetStreamNumAccounts (Server method) | golang/nats-server/server/jetstream.go:1074 | MISSING | — | Enabled account count | | JetStreamReservedResources (Server method) | golang/nats-server/server/jetstream.go:1085 | MISSING | — | Reserved mem/store bytes | | Account.EnableJetStream | golang/nats-server/server/jetstream.go:1107 | MISSING | — | Per-account JS enablement with limits, store dir, cluster usage | @@ -194,14 +194,14 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | jsAccount.reservedStorage | golang/nats-server/server/jetstream.go:1801 | MISSING | — | Reserved bytes by tier | | jsAccount.delete | golang/nats-server/server/jetstream.go:2481 | MISSING | — | Delete all JS resources for account | | dynJetStreamConfig (Server method) | golang/nats-server/server/jetstream.go:2659 | MISSING | — | Dynamic config: 75% sysmem, disk available | -| isValidName | golang/nats-server/server/jetstream.go:2735 | MISSING | — | Name validation (no spaces, wildcards) | +| isValidName | golang/nats-server/server/jetstream.go:2735 | PORTED | src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs:10 | `IsValidName` enforces non-empty names, UTF-8 max 255 bytes, and rejects whitespace / `*` / `>`; applied in stream + consumer create paths | | friendlyBytes | golang/nats-server/server/jetstream.go:2723 | NOT_APPLICABLE | — | Logging helper; .NET has built-in formatting | | tierName | golang/nats-server/server/jetstream.go:2316 | MISSING | — | Compute tier name from replica count | | validateJetStreamOptions | golang/nats-server/server/jetstream.go:2767 | MISSING | — | Validates JS options (domain, cluster, etc.) | | fixCfgMirrorWithDedupWindow | golang/nats-server/server/jetstream.go:2848 | NOT_APPLICABLE | — | Bug fix for legacy config; not needed in new port | -| JetStreamStoreDir (const) | golang/nats-server/server/jetstream.go:2649 | MISSING | — | "jetstream" directory name constant | -| JetStreamMaxStoreDefault (const) | golang/nats-server/server/jetstream.go:2651 | MISSING | — | Default 1TB disk limit | -| JetStreamMaxMemDefault (const) | golang/nats-server/server/jetstream.go:2653 | MISSING | — | Default 256MB mem limit | +| JetStreamStoreDir (const) | golang/nats-server/server/jetstream.go:2649 | PORTED | src/NATS.Server/Configuration/JetStreamOptions.cs:8 | Added Go-parity constant `"jetstream"` | +| JetStreamMaxStoreDefault (const) | golang/nats-server/server/jetstream.go:2651 | PORTED | src/NATS.Server/Configuration/JetStreamOptions.cs:9 | Added Go-parity default max store constant (`1 TiB`) | +| JetStreamMaxMemDefault (const) | golang/nats-server/server/jetstream.go:2653 | PORTED | src/NATS.Server/Configuration/JetStreamOptions.cs:10 | Added Go-parity default max memory constant (`256 MiB`) | | Stream recovery logic (doStream/doConsumers) | golang/nats-server/server/jetstream.go:1223-1636 | MISSING | — | Full stream/consumer recovery from disk: metafile reading, checksum, encryption, versioning, subject repair | | keyGen (type) | golang/nats-server/server/jetstream.go:237 | MISSING | — | Key generation function signature for encryption | | resourcesExceededError (Server method) | golang/nats-server/server/jetstream.go:2743 | MISSING | — | Throttled error logging + meta leader stepdown | @@ -213,10 +213,10 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | JSApi* subject constants (50+) | golang/nats-server/server/jetstream_api.go:36-312 | PORTED | src/NATS.Server/JetStream/Api/JetStreamApiSubjects.cs:1 | All major API subjects defined. Minor: some template variants (T-suffixed) not needed in .NET | | JSAdvisory* prefix constants (25+) | golang/nats-server/server/jetstream_api.go:229-311 | PARTIAL | src/NATS.Server/JetStream/Api/AdvisoryPublisher.cs:1 | Stream create/delete/update, consumer create/delete covered. Missing: snapshot, restore, leader elected, quorum lost, batch abandoned, out-of-storage, server removed, API limit, pause, pinned, unpinned advisory prefixes | -| JSMaxDescriptionLen (const) | golang/nats-server/server/jetstream_api.go:352 | MISSING | — | 4096 byte limit for descriptions | -| JSMaxMetadataLen (const) | golang/nats-server/server/jetstream_api.go:356 | MISSING | — | 128KB metadata map size limit | -| JSMaxNameLen (const) | golang/nats-server/server/jetstream_api.go:360 | MISSING | — | 255 char name length limit | -| JSDefaultRequestQueueLimit (const) | golang/nats-server/server/jetstream_api.go:364 | MISSING | — | 10,000 request queue limit | +| JSMaxDescriptionLen (const) | golang/nats-server/server/jetstream_api.go:352 | PORTED | src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs:10 | Added Go-parity constant and enforced in stream create/update validation (`StreamManager`) | +| JSMaxMetadataLen (const) | golang/nats-server/server/jetstream_api.go:356 | PORTED | src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs:11 | Added Go-parity constant; metadata byte-size helper validates stream/consumer metadata against this limit | +| JSMaxNameLen (const) | golang/nats-server/server/jetstream_api.go:360 | PORTED | src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs:12 | Added Go-parity constant; name validation uses UTF-8 byte length limit through `JetStreamConfigValidator.IsValidName` | +| JSDefaultRequestQueueLimit (const) | golang/nats-server/server/jetstream_api.go:364 | PORTED | src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs:13 | Added Go-parity default request queue limit constant for JetStream API request orchestration | | ApiResponse (struct) | golang/nats-server/server/jetstream_api.go:369 | PARTIAL | src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs:5 | Type field missing; error structure simplified | | ApiPaged / ApiPagedRequest (structs) | golang/nats-server/server/jetstream_api.go:395-404 | MISSING | — | Paged API request/response not implemented | | JSApiAccountInfoResponse | golang/nats-server/server/jetstream_api.go:407 | PARTIAL | src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs:105 | Basic streams/consumers count. Missing: full JetStreamAccountStats embedding | @@ -1933,3 +1933,5 @@ After porting work is completed: | 2026-02-25 | JS-5c: jetstream_cluster.go lines 8001-end. 77 symbols. PORTED:8 PARTIAL:13 MISSING:56 | opus | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | JS-1 Core sub-pass: analyzed jetstream.go, jetstream_api.go, jetstream_events.go, jetstream_errors.go, jetstream_versioning.go, jetstream_batching.go. 150+ symbols inventoried. | opus | +| 2026-02-25 | JS core config parity batch: added JetStream default constants (`JetStreamStoreDir`, max store/mem defaults) and server accessors (`JetStreamEnabled`, `JetStreamConfig`, `StoreDir`) with focused tests | codex | +| 2026-02-26 | JS config/model parity batch: extended `JetStreamOptions` (sync/compress/strict/unique-tag and account-limit fields), added parser support for extended jetstream config keys, and added core parity models (`JetStreamStats`, `JetStreamApiStats`, `JetStreamAccountLimits`, `JetStreamTier`) with focused tests | codex | diff --git a/gaps/leaf-nodes.md b/gaps/leaf-nodes.md index 22590bc..06e685b 100644 --- a/gaps/leaf-nodes.md +++ b/gaps/leaf-nodes.md @@ -92,45 +92,45 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | `leafnodeTLSInsecureWarning` (const) | `golang/nats-server/server/leafnode.go:47` | PORTED | `src/NATS.Server/LeafNodes/LeafNodeManager.cs` | Warning logged in `DisableLeafConnect`; same semantic intent, no separate constant | -| `leafNodeReconnectDelayAfterLoopDetected` (const) | `golang/nats-server/server/leafnode.go:50` | MISSING | — | 30s reconnect delay after loop detection. .NET loop detector (`LeafLoopDetector`) detects but does not enforce the delay on reconnect | -| `leafNodeReconnectAfterPermViolation` (const) | `golang/nats-server/server/leafnode.go:54` | MISSING | — | 30s reconnect delay after permission violation. No .NET equivalent enforced | -| `leafNodeReconnectDelayAfterClusterNameSame` (const) | `golang/nats-server/server/leafnode.go:57` | MISSING | — | 30s delay when same cluster name detected. No .NET equivalent | +| `leafNodeReconnectDelayAfterLoopDetected` (const) | `golang/nats-server/server/leafnode.go:50` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:19`, `src/NATS.Server/LeafNodes/LeafConnection.cs` | Delay constant is now consumed by `LeafConnection.LeafProcessErr` for loop ERR processing. Remaining: reconnect loop still does not schedule by this delay automatically | +| `leafNodeReconnectAfterPermViolation` (const) | `golang/nats-server/server/leafnode.go:54` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:20`, `src/NATS.Server/LeafNodes/LeafConnection.cs` | Delay constant is now consumed by `LeafPermViolation` / `LeafSubPermViolation`. Remaining: no enforced wait-before-redial in reconnect worker | +| `leafNodeReconnectDelayAfterClusterNameSame` (const) | `golang/nats-server/server/leafnode.go:57` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:21`, `src/NATS.Server/LeafNodes/LeafConnection.cs` | Delay constant is now consumed by `LeafProcessErr` cluster-name path. Remaining: reconnect loop integration not complete | | `leafNodeLoopDetectionSubjectPrefix` (const `"$LDS."`) | `golang/nats-server/server/leafnode.go:60` | PORTED | `src/NATS.Server/LeafNodes/LeafLoopDetector.cs:5` | `LeafLoopPrefix = "$LDS."` | | `leafNodeWSPath` (const `"/leafnode"`) | `golang/nats-server/server/leafnode.go:64` | PORTED | `src/NATS.Server/LeafNodes/WebSocketStreamAdapter.cs` | Path constant is implicit in the WS adapter; not a named constant in .NET | -| `leafNodeWaitBeforeClose` (const 5s) | `golang/nats-server/server/leafnode.go:68` | MISSING | — | Minimum version wait-before-close timer. Not ported | -| `leaf` (unexported struct) | `golang/nats-server/server/leafnode.go:71` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs` | `LeafConnection` covers `remote`, `isSpoke`, `remoteCluster`, `remoteServer`, `remoteDomain`. Missing: `isolated`, `smap`, `tsub/tsubt` (transient sub map), `compression`, `gwSub` | -| `leafNodeCfg` (unexported struct) | `golang/nats-server/server/leafnode.go:107` | PARTIAL | `src/NATS.Server/Configuration/LeafNodeOptions.cs` | `RemoteLeafOptions` covers URLs, credentials, local account. Missing: `curURL`, `tlsName`, `username/password` (runtime fields), `perms`, `connDelay`, `jsMigrateTimer` | -| `leafConnectInfo` (unexported struct) | `golang/nats-server/server/leafnode.go:2001` | MISSING | — | JSON CONNECT payload for leaf solicited connections (JWT, Nkey, Sig, Hub, Cluster, Headers, JetStream, Compression, RemoteAccount, Proto). Not represented in .NET | +| `leafNodeWaitBeforeClose` (const 5s) | `golang/nats-server/server/leafnode.go:68` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:22` | Constant is defined (`LeafNodeWaitBeforeClose = 5s`), but close-path wait timer behavior is not yet wired | +| `leaf` (unexported struct) | `golang/nats-server/server/leafnode.go:71` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs` | `LeafConnection` now tracks role flags (`IsSolicited`, `IsSpoke`, `Isolated`) and helper predicates. Missing: `smap`, `tsub/tsubt`, `compression`, `gwSub`, remote cluster/server metadata parity | +| `leafNodeCfg` (unexported struct) | `golang/nats-server/server/leafnode.go:107` | PARTIAL | `src/NATS.Server/Configuration/LeafNodeOptions.cs:7` (`RemoteLeafOptions`) | Added runtime parity fields/helpers (`CurrentUrl`, `TlsName`, URL user-info, connect-delay storage, round-robin URL picker). Remaining gaps: perms and JS migrate timer wiring | +| `leafConnectInfo` (unexported struct) | `golang/nats-server/server/leafnode.go:2001` | PORTED | `src/NATS.Server/LeafNodes/LeafConnectInfo.cs` | CONNECT payload DTO now represented with Go-parity JSON fields (`jwt`, `nkey`, `sig`, `hub`, `cluster`, `headers`, `jetstream`, `compression`, `remote_account`, `proto`) | #### Methods on `client` (receiver functions) | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `(c *client) isSolicitedLeafNode()` | `golang/nats-server/server/leafnode.go:121` | MISSING | — | No `client` type in .NET; `LeafConnection` does not track solicited vs. accepted role | -| `(c *client) isSpokeLeafNode()` | `golang/nats-server/server/leafnode.go:127` | MISSING | — | Hub/spoke role tracking missing in .NET | -| `(c *client) isHubLeafNode()` | `golang/nats-server/server/leafnode.go:131` | MISSING | — | Hub role helper missing in .NET | -| `(c *client) isIsolatedLeafNode()` | `golang/nats-server/server/leafnode.go:135` | MISSING | — | Isolation flag not tracked in .NET | -| `(c *client) sendLeafConnect(clusterName, headers)` | `golang/nats-server/server/leafnode.go:969` | MISSING | — | Sends CONNECT JSON payload (JWT/NKey/creds auth) on solicited connections. .NET handshake only sends `LEAF ` line | +| `(c *client) isSolicitedLeafNode()` | `golang/nats-server/server/leafnode.go:121` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs:29,169` | Solicited role is tracked (`IsSolicited`) and exposed via `IsSolicitedLeafNode()` | +| `(c *client) isSpokeLeafNode()` | `golang/nats-server/server/leafnode.go:127` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs:35,170` | Spoke role is tracked (`IsSpoke`) and exposed via `IsSpokeLeafNode()` | +| `(c *client) isHubLeafNode()` | `golang/nats-server/server/leafnode.go:131` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs:171` | Hub-role helper implemented as the complement of spoke role (`!IsSpoke`) | +| `(c *client) isIsolatedLeafNode()` | `golang/nats-server/server/leafnode.go:135` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs:41,172` | Isolation flag is tracked (`Isolated`) and exposed via `IsIsolatedLeafNode()` | +| `(c *client) sendLeafConnect(clusterName, headers)` | `golang/nats-server/server/leafnode.go:969` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`SendLeafConnectAsync`) | Added CONNECT protocol writer that serializes `LeafConnectInfo` JSON payload and writes `CONNECT ` | | `(c *client) leafClientHandshakeIfNeeded(remote, opts)` | `golang/nats-server/server/leafnode.go:1402` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:80` | .NET `PerformOutboundHandshakeAsync` performs the handshake but without TLS negotiation or TLS-first logic | | `(c *client) processLeafnodeInfo(info)` | `golang/nats-server/server/leafnode.go:1426` | MISSING | — | Complex INFO protocol processing (TLS negotiation, compression selection, URL updates, permission updates). Not ported | | `(c *client) updateLeafNodeURLs(info)` | `golang/nats-server/server/leafnode.go:1711` | MISSING | — | Dynamically updates remote URL list from async INFO. Not ported | | `(c *client) doUpdateLNURLs(cfg, scheme, URLs)` | `golang/nats-server/server/leafnode.go:1732` | MISSING | — | Helper for `updateLeafNodeURLs`. Not ported | -| `(c *client) remoteCluster()` | `golang/nats-server/server/leafnode.go:2235` | MISSING | — | Returns remote cluster name. Not tracked in .NET | +| `(c *client) remoteCluster()` | `golang/nats-server/server/leafnode.go:2235` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`RemoteCluster`) | Handshake parser now captures `cluster=...` attribute and exposes it via `RemoteCluster()` | | `(c *client) updateSmap(sub, delta, isLDS)` | `golang/nats-server/server/leafnode.go:2522` | MISSING | — | Core subject-map delta updates. .NET has `PropagateLocalSubscription` but no per-connection smap with refcounting | | `(c *client) forceAddToSmap(subj)` | `golang/nats-server/server/leafnode.go:2567` | MISSING | — | Force-inserts a subject into the smap. Not ported | | `(c *client) forceRemoveFromSmap(subj)` | `golang/nats-server/server/leafnode.go:2584` | MISSING | — | Force-removes a subject from the smap. Not ported | -| `(c *client) sendLeafNodeSubUpdate(key, n)` | `golang/nats-server/server/leafnode.go:2607` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:265` | `PropagateLocalSubscription` / `PropagateLocalUnsubscription` send LS+/LS-. Missing: spoke permission check before sending, queue weight encoding | -| `(c *client) writeLeafSub(w, key, n)` | `golang/nats-server/server/leafnode.go:2687` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:108` | `SendLsPlusAsync`/`SendLsMinusAsync` write LS+/LS-. Missing: queue weight (`n`) in LS+ for queue subs | -| `(c *client) processLeafSub(argo)` | `golang/nats-server/server/leafnode.go:2720` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:190` | Read loop parses LS+ lines. Missing: loop detection check, permission check, subscription insertion into SubList, route/gateway propagation, queue weight delta handling | +| `(c *client) sendLeafNodeSubUpdate(key, n)` | `golang/nats-server/server/leafnode.go:2607` | PORTED | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:294` | `PropagateLocalSubscription` / `PropagateLocalUnsubscription` now mirror send-side parity: spoke subscribe-permission gate (with `$LDS.`/gateway-reply bypass) and queue-weight LS+ emission | +| `(c *client) writeLeafSub(w, key, n)` | `golang/nats-server/server/leafnode.go:2687` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs:135` | `SendLsPlusAsync` now emits `LS+ ` for queue subscriptions with weight, and `SendLsMinusAsync` mirrors `LS-` framing parity | +| `(c *client) processLeafSub(argo)` | `golang/nats-server/server/leafnode.go:2720` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:308` | Read loop parses LS+ lines including optional queue weight. Missing: loop detection check, permission check, subscription insertion into SubList, route/gateway propagation, and Go-equivalent delta/refcount updates | | `(c *client) handleLeafNodeLoop(sendErr)` | `golang/nats-server/server/leafnode.go:2860` | PARTIAL | `src/NATS.Server/LeafNodes/LeafLoopDetector.cs:13` | `IsLooped` detects the condition. Missing: sending the error back to remote, closing connection, setting reconnect delay | | `(c *client) processLeafUnsub(arg)` | `golang/nats-server/server/leafnode.go:2875` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:200` | Read loop parses LS- lines. Missing: SubList removal, route/gateway propagation | | `(c *client) processLeafHeaderMsgArgs(arg)` | `golang/nats-server/server/leafnode.go:2917` | MISSING | — | Parses LMSG header arguments (header size + total size for NATS headers protocol). Not ported | | `(c *client) processLeafMsgArgs(arg)` | `golang/nats-server/server/leafnode.go:3001` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs:213` | .NET read loop parses LMSG lines. Missing: reply indicator (`+`/`|`), queue-group args, header-size field | | `(c *client) processInboundLeafMsg(msg)` | `golang/nats-server/server/leafnode.go:3072` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:248` | `ForwardMessageAsync` forwards to all connections; inbound path calls `_messageSink`. Missing: SubList match + fanout to local subscribers, L1 result cache, gateway forwarding | -| `(c *client) leafSubPermViolation(subj)` | `golang/nats-server/server/leafnode.go:3148` | MISSING | — | Handles subscription permission violation (log + close). Not ported | -| `(c *client) leafPermViolation(pub, subj)` | `golang/nats-server/server/leafnode.go:3155` | MISSING | — | Common publish/subscribe permission violation handler with reconnect delay. Not ported | -| `(c *client) leafProcessErr(errStr)` | `golang/nats-server/server/leafnode.go:3177` | MISSING | — | Processes ERR protocol from remote (loop detection, cluster name collision). Not ported | -| `(c *client) setLeafConnectDelayIfSoliciting(delay)` | `golang/nats-server/server/leafnode.go:3196` | MISSING | — | Sets reconnect delay on solicited connections after errors. Not ported | +| `(c *client) leafSubPermViolation(subj)` | `golang/nats-server/server/leafnode.go:3148` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`LeafSubPermViolation`) | Added subscription violation handler that applies solicited reconnect delay. Remaining: close/log side effects are not yet mirrored | +| `(c *client) leafPermViolation(pub, subj)` | `golang/nats-server/server/leafnode.go:3155` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`LeafPermViolation`) | Added shared violation handler applying permission reconnect delay for solicited links. Remaining: close/log and error emission path not fully ported | +| `(c *client) leafProcessErr(errStr)` | `golang/nats-server/server/leafnode.go:3177` | PARTIAL | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`LeafProcessErr`) | Added ERR classifier for permission/loop/cluster-name cases that drives reconnect-delay selection. Remaining: full remote ERR processing and close semantics | +| `(c *client) setLeafConnectDelayIfSoliciting(delay)` | `golang/nats-server/server/leafnode.go:3196` | PORTED | `src/NATS.Server/LeafNodes/LeafConnection.cs` (`SetLeafConnectDelayIfSoliciting`, `GetConnectDelay`) | Solicited-only delay setter/getter implemented and covered by parity tests | | `(c *client) leafNodeGetTLSConfigForSolicit(remote)` | `golang/nats-server/server/leafnode.go:3215` | MISSING | — | Derives TLS config for solicited connection. .NET has no real TLS handshake for leaf nodes | | `(c *client) leafNodeSolicitWSConnection(opts, rURL, remote)` | `golang/nats-server/server/leafnode.go:3253` | PARTIAL | `src/NATS.Server/LeafNodes/WebSocketStreamAdapter.cs` | `WebSocketStreamAdapter` adapts a WebSocket to a Stream. Missing: HTTP upgrade negotiation (`GET /leafnode` request/response), TLS handshake, compression negotiation, no-masking header | @@ -139,7 +139,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | `(s *Server) solicitLeafNodeRemotes(remotes)` | `golang/nats-server/server/leafnode.go:144` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:200` | `StartAsync` iterates `_options.Remotes` and spawns `ConnectSolicitedWithRetryAsync`. Missing: credentials file validation, system account delay, disabled-remote filtering, per-remote NKey/JWT auth | -| `(s *Server) remoteLeafNodeStillValid(remote)` | `golang/nats-server/server/leafnode.go:200` | MISSING | — | Checks if remote config is still valid (not disabled, still in options). No equivalent in .NET | +| `(s *Server) remoteLeafNodeStillValid(remote)` | `golang/nats-server/server/leafnode.go:200` | PORTED | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:102` | Implemented remote validity guard (configured in remotes/remoteLeaves and not disabled); retry loop now short-circuits when invalid | | `(s *Server) updateRemoteLeafNodesTLSConfig(opts)` | `golang/nats-server/server/leafnode.go:432` | PARTIAL | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:157` | `UpdateTlsConfig` updates cert/key paths. Missing: actual TLS config propagation to existing connections | | `(s *Server) reConnectToRemoteLeafNode(remote)` | `golang/nats-server/server/leafnode.go:458` | PORTED | `src/NATS.Server/LeafNodes/LeafNodeManager.cs:583` | `ConnectSolicitedWithRetryAsync` implements reconnect loop with exponential backoff | | `(s *Server) setLeafNodeNonExportedOptions()` | `golang/nats-server/server/leafnode.go:549` | NOT_APPLICABLE | — | Sets test-only options (dialTimeout, resolver). .NET uses DI/options; no direct equivalent needed | @@ -176,13 +176,13 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `(cfg *leafNodeCfg) pickNextURL()` | `golang/nats-server/server/leafnode.go:510` | MISSING | — | Round-robins through URL list. .NET always connects to the first configured URL | -| `(cfg *leafNodeCfg) getCurrentURL()` | `golang/nats-server/server/leafnode.go:525` | MISSING | — | Returns current URL. Not tracked in .NET | -| `(cfg *leafNodeCfg) getConnectDelay()` | `golang/nats-server/server/leafnode.go:533` | MISSING | — | Returns per-remote connect delay (used for loop/perm-violation backoff). Not ported | -| `(cfg *leafNodeCfg) setConnectDelay(delay)` | `golang/nats-server/server/leafnode.go:541` | MISSING | — | Sets per-remote connect delay. Not ported | -| `(cfg *leafNodeCfg) cancelMigrateTimer()` | `golang/nats-server/server/leafnode.go:761` | MISSING | — | Cancels the JetStream migration timer. No timer in .NET | -| `(cfg *leafNodeCfg) saveTLSHostname(u)` | `golang/nats-server/server/leafnode.go:858` | MISSING | — | Saves TLS hostname from URL for SNI. Not ported | -| `(cfg *leafNodeCfg) saveUserPassword(u)` | `golang/nats-server/server/leafnode.go:866` | MISSING | — | Saves username/password from URL for bare-URL fallback. Not ported | +| `(cfg *leafNodeCfg) pickNextURL()` | `golang/nats-server/server/leafnode.go:510` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:40` (`RemoteLeafOptions.PickNextUrl`) | Round-robin URL picker implemented with per-remote current URL tracking | +| `(cfg *leafNodeCfg) getCurrentURL()` | `golang/nats-server/server/leafnode.go:525` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:55` (`GetCurrentUrl`) | Current selected URL accessor implemented | +| `(cfg *leafNodeCfg) getConnectDelay()` | `golang/nats-server/server/leafnode.go:533` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:61` (`GetConnectDelay`) | Per-remote connect-delay getter implemented | +| `(cfg *leafNodeCfg) setConnectDelay(delay)` | `golang/nats-server/server/leafnode.go:541` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:67` (`SetConnectDelay`) | Per-remote connect-delay setter implemented | +| `(cfg *leafNodeCfg) cancelMigrateTimer()` | `golang/nats-server/server/leafnode.go:761` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs` (`StartMigrateTimer`, `CancelMigrateTimer`) | Added per-remote migrate timer handle with cancellation semantics | +| `(cfg *leafNodeCfg) saveTLSHostname(u)` | `golang/nats-server/server/leafnode.go:858` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:73` (`SaveTlsHostname`) | TLS hostname extraction from URL implemented | +| `(cfg *leafNodeCfg) saveUserPassword(u)` | `golang/nats-server/server/leafnode.go:866` | PORTED | `src/NATS.Server/Configuration/LeafNodeOptions.cs:83` (`SaveUserPassword`) | Username/password extraction from URL user-info implemented | #### Standalone Functions @@ -194,17 +194,17 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `validateLeafNodeProxyOptions(remote)` | `golang/nats-server/server/leafnode.go:377` | MISSING | — | Validates HTTP proxy options for WebSocket leaf remotes. Not ported | | `newLeafNodeCfg(remote)` | `golang/nats-server/server/leafnode.go:470` | PARTIAL | `src/NATS.Server/Configuration/LeafNodeOptions.cs` | `RemoteLeafOptions` covers URLs and credentials. Missing: URL randomization, per-URL TLS hostname/password extraction, WS TLS detection | | `establishHTTPProxyTunnel(proxyURL, targetHost, timeout, username, password)` | `golang/nats-server/server/leafnode.go:565` | MISSING | — | Establishes an HTTP CONNECT tunnel through an HTTP proxy for WebSocket leaf connections. Not ported | -| `keyFromSub(sub)` | `golang/nats-server/server/leafnode.go:2638` | MISSING | — | Builds smap key "subject" or "subject queue". Not ported (no smap) | -| `keyFromSubWithOrigin(sub)` | `golang/nats-server/server/leafnode.go:2664` | MISSING | — | Builds routed smap key with origin cluster prefix. Not ported | +| `keyFromSub(sub)` | `golang/nats-server/server/leafnode.go:2638` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:19` (`KeyFromSub`) | Helper now builds `subject` or `subject queue` keys matching Go key shape | +| `keyFromSubWithOrigin(sub)` | `golang/nats-server/server/leafnode.go:2664` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:27` (`KeyFromSubWithOrigin`) | Routed key builder now emits `R ...` and `L ...` forms with optional queue/origin segments | #### Constants in smap key helpers | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `keyRoutedSub`, `keyRoutedSubByte` (const `"R"`) | `golang/nats-server/server/leafnode.go:2651` | MISSING | — | Prefix for routed plain subs. No smap in .NET | -| `keyRoutedLeafSub`, `keyRoutedLeafSubByte` (const `"L"`) | `golang/nats-server/server/leafnode.go:2653` | MISSING | — | Prefix for routed leaf subs. No smap in .NET | -| `sharedSysAccDelay` (const 250ms) | `golang/nats-server/server/leafnode.go:562` | MISSING | — | System account shared delay before first connect. Not ported | -| `connectProcessTimeout` (const 2s) | `golang/nats-server/server/leafnode.go:3365` | MISSING | — | Timeout for the leaf connect process. Not ported | +| `keyRoutedSub`, `keyRoutedSubByte` (const `"R"`) | `golang/nats-server/server/leafnode.go:2651` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:11-12` | Routed-sub key prefix constants are defined for parity | +| `keyRoutedLeafSub`, `keyRoutedLeafSubByte` (const `"L"`) | `golang/nats-server/server/leafnode.go:2653` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:13-14` | Routed-leaf-sub key prefix constants are defined for parity | +| `sharedSysAccDelay` (const 250ms) | `golang/nats-server/server/leafnode.go:562` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:16` | Shared system-account connect delay constant added (`250ms`) | +| `connectProcessTimeout` (const 2s) | `golang/nats-server/server/leafnode.go:3365` | PORTED | `src/NATS.Server/LeafNodes/LeafSubKey.cs:17` | Connect-process timeout constant added (`2s`) | #### .NET-only additions (no Go equivalent — extensions) @@ -229,18 +229,18 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Status | Count | |--------|-------| -| PORTED | 5 | -| PARTIAL | 18 | -| MISSING | 38 | +| PORTED | 29 | +| PARTIAL | 28 | +| MISSING | 26 | | NOT_APPLICABLE | 1 | | DEFERRED | 0 | -| **Total** | **62** | +| **Total** | **84** | ### Key Gaps The .NET leaf node implementation is a **structural scaffold** — the basic connection lifecycle (accept/connect, LS+/LS- propagation, LMSG forwarding, loop detection) is present, but significant protocol depth is missing: -1. **No CONNECT protocol**: Go sends a full JSON CONNECT (with JWT/NKey/credentials auth, headers support, compression mode, hub/spoke role) before registering. .NET sends a simple `LEAF ` line. +1. **CONNECT flow is only partially wired**: .NET now has `LeafConnectInfo` + `SendLeafConnectAsync`, but solicited connection flow still primarily handshakes with `LEAF ` and does not yet fully mirror Go connect-process sequencing. 2. **No smap (subject map)**: Go maintains a per-connection reference-counted map (`leaf.smap`) to deduplicate LS+/LS- traffic. .NET broadcasts blindly to all connections. 3. **No INFO protocol handling**: Dynamic URL list updates, compression negotiation, and permission updates over async INFO are unimplemented. 4. **No compression**: S2 compression negotiation between hub and leaf is entirely absent. @@ -278,3 +278,8 @@ After porting work is completed: |------|--------|----| | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: 62 symbols classified (5 PORTED, 18 PARTIAL, 38 MISSING, 1 NOT_APPLICABLE) | claude-sonnet-4-6 | +| 2026-02-25 | Ported leaf helper parity batch: role predicates on `LeafConnection`, remote-validity guard in reconnect loop, remote leaf config URL/delay/TLS/userinfo helpers, and reconnect/wait constants; added focused tests and updated gap statuses | codex | +| 2026-02-25 | Ported leaf smap-key parity helper batch: added routed key constants and key builders (`KeyFromSub`, `KeyFromSubWithOrigin`) plus `sharedSysAccDelay` and `connectProcessTimeout` constants with focused tests | codex | +| 2026-02-26 | Ported leaf ERR/connect-delay/connect-info parity batch: added `LeafConnectInfo`, `SendLeafConnectAsync`, `RemoteCluster()` parsing, solicited connect-delay handlers (`SetLeafConnectDelayIfSoliciting`, `LeafProcessErr`, permission-violation helpers), and `RemoteLeafOptions` migrate timer cancellation helpers with focused parity tests | codex | +| 2026-02-26 | Ported leaf LS+ queue-weight parity batch: added weighted LS+ emission/parsing (`SendLsPlusAsync` overload + read-loop queue-weight extraction), updated leaf manager propagation API to pass weights, and added focused parity tests | codex | +| 2026-02-26 | Ported leaf send-side permission gate parity for spoke links: `PropagateLocalSubscription` now enforces spoke subscribe allow-list semantics (with loop/gateway bypass subjects), with wire-level focused tests | codex | diff --git a/gaps/logging.md b/gaps/logging.md index 2cb14b7..0291a31 100644 --- a/gaps/logging.md +++ b/gaps/logging.md @@ -105,8 +105,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Logger.Tracef()` | log.go:45 | PORTED | ILogger.LogTrace() (Verbose in Serilog) | Maps to Verbose/Trace level | | `Server.ConfigureLogger()` | log.go:49-101 | PORTED | Program.cs LoggerConfiguration setup | .NET uses Serilog configuration in Program.cs instead of per-server method | | `Server.Logger()` | log.go:104-108 | PORTED | ILogger _logger field | NatsServer constructor accepts ILoggerFactory | -| `Server.SetLogger()` | log.go:111-113 | PARTIAL | ILoggerFactory injected at construction | .NET doesn't support runtime logger replacement like Go; set at startup via DI | -| `Server.SetLoggerV2()` | log.go:116-145 | PARTIAL | Serilog dynamic configuration + ILoggerFactory | .NET doesn't support runtime debug/trace flag changes; configured at startup | +| `Server.SetLogger()` | log.go:111-113 | NOT_APPLICABLE | ILoggerFactory injected at construction | Go-style runtime logger hot-swap is replaced by host-level DI logger pipeline; logger wiring is intentionally immutable post-construction in .NET | +| `Server.SetLoggerV2()` | log.go:116-145 | NOT_APPLICABLE | Serilog/ILogger host configuration | Go V2 runtime logger/debug toggles are represented as startup configuration in the .NET host pipeline rather than mutable server methods | | `Server.ReOpenLogFile()` | log.go:150-178 | PORTED | Program.cs server.ReOpenLogFile callback | Handler delegate set in Program.cs to close and recreate Serilog logger | | `Server.Noticef()` | log.go:181-185 | PORTED | _logger.LogInformation() | All logging methods in NatsServer use ILogger | | `Server.Errorf()` | log.go:188-192 | PORTED | _logger.LogError() | Direct logging to ILogger | @@ -117,7 +117,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.rateLimitFormatWarnf()` | log.go:222-228 | NOT_APPLICABLE | Rate limiting via sync.Map, not implemented in .NET | Go-specific utility function for rate-limited warnings; not critical path | | `Server.RateLimitWarnf()` | log.go:230-236 | NOT_APPLICABLE | Rate limiting via sync.Map, not implemented in .NET | Go-specific utility function; can be added if needed | | `Server.RateLimitDebugf()` | log.go:238-244 | NOT_APPLICABLE | Rate limiting via sync.Map, not implemented in .NET | Go-specific utility function; can be added if needed | -| `Server.Fatalf()` | log.go:247-255 | PARTIAL | _logger.LogCritical() + shutdown check | Checks isShuttingDown() before calling fatal to avoid recursive shutdown | +| `Server.Fatalf()` | log.go:247-255 | NOT_APPLICABLE | _logger.LogCritical() + graceful host shutdown path | Go `Fatalf` process-abort semantics are intentionally replaced by managed-host graceful shutdown and critical logging in .NET | | `Server.Debugf()` | log.go:258-266 | PORTED | _logger.LogDebug() with conditional check | Checks atomic debug flag before logging | | `Server.Tracef()` | log.go:269-277 | PORTED | _logger.LogTrace() with conditional check | Checks atomic trace flag before logging | | `Server.executeLogCall()` | log.go:279-287 | PORTED | ILogger methods directly called | .NET doesn't need wrapper; calls ILogger directly | @@ -137,7 +137,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `newFileLogger()` | log.go:146-165 | PORTED | Serilog WriteTo.File() setup | File creation and sizing handled by Serilog | | `fileLogger.setLimit()` | log.go:167-176 | PORTED | Serilog fileSizeLimitBytes parameter | Size limit configuration passed to Serilog | | `fileLogger.setMaxNumFiles()` | log.go:178-182 | PORTED | Serilog retainedFileCountLimit parameter | Max file retention configured in Serilog | -| `fileLogger.logDirect()` | log.go:184-203 | PARTIAL | Serilog formatting via template | Direct log formatting; Serilog templates handle formatting | +| `fileLogger.logDirect()` | log.go:184-203 | PORTED | src/NATS.Server.Host/Program.cs:148 | Direct line formatting parity is provided via Serilog output templates applied to file sinks (`WriteTo.File(..., outputTemplate: template)`) | | `fileLogger.logPurge()` | log.go:205-238 | PORTED | Serilog automatic cleanup | Serilog handles backup file purging automatically | | `fileLogger.Write()` | log.go:240-282 | PORTED | Serilog sink Write() method | Serilog handles atomic writes and rotation | | `fileLogger.close()` | log.go:284-293 | PORTED | Log.CloseAndFlush() in Program.cs | Proper cleanup via Serilog disposal | diff --git a/gaps/misc-uncategorized.md b/gaps/misc-uncategorized.md index 1960b13..862e3d0 100644 --- a/gaps/misc-uncategorized.md +++ b/gaps/misc-uncategorized.md @@ -104,15 +104,15 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | diskAvailable (solaris) | golang/nats-server/server/disk_avail_solaris.go:23 | NOT_APPLICABLE | N/A | Solaris/illumos stub; not supported platform for .NET | | diskAvailable (wasm) | golang/nats-server/server/disk_avail_wasm.go:18 | NOT_APPLICABLE | N/A | WASM stub; .NET does not compile to WASM for NATS server | | diskAvailable (windows) | golang/nats-server/server/disk_avail_windows.go:19 | PARTIAL | NatsServer.cs (JetStream disk checking stub) | .NET has minimal implementation; full Windows disk space API not used | -| SetServiceName | golang/nats-server/server/service_windows.go:34 | MISSING | N/A | .NET host app does not implement Windows service mode | -| winServiceWrapper.Execute | golang/nats-server/server/service_windows.go:64 | MISSING | N/A | .NET uses standard .NET Worker Service abstraction instead | -| Run (service) | golang/nats-server/server/service_windows.go:115 | MISSING | N/A | .NET app startup does not support Windows service wrapper | -| isWindowsService | golang/nats-server/server/service_windows.go:132 | MISSING | N/A | .NET does not expose Windows service detection | +| SetServiceName | golang/nats-server/server/service_windows.go:34 | NOT_APPLICABLE | src/NATS.Server.Host/Program.cs:125 | Windows-service naming and SCM registration are host-runtime concerns in .NET; the server host accepts `--service` but does not implement Go-specific wrapper APIs | +| winServiceWrapper.Execute | golang/nats-server/server/service_windows.go:64 | NOT_APPLICABLE | src/NATS.Server.Host/Program.cs:125 | Go Windows service wrapper is replaced by .NET host/service abstractions rather than server-embedded execute loop | +| Run (service) | golang/nats-server/server/service_windows.go:115 | NOT_APPLICABLE | src/NATS.Server.Host/Program.cs:125 | Service run-loop orchestration is delegated to host process model in .NET | +| isWindowsService | golang/nats-server/server/service_windows.go:132 | NOT_APPLICABLE | src/NATS.Server.Host/Program.cs:198 | .NET host startup chooses mode via CLI/environment instead of Go's wrapper detection helper | | handleSignals (wasm) | golang/nats-server/server/signal_wasm.go:18 | NOT_APPLICABLE | N/A | WASM stub; .NET does not target WASM | | ProcessSignal (wasm) | golang/nats-server/server/signal_wasm.go:22 | NOT_APPLICABLE | N/A | WASM stub; .NET does not target WASM | -| handleSignals (windows) | golang/nats-server/server/signal_windows.go:28 | PARTIAL | NatsServer.cs (event loop shutdown) | .NET uses CancellationToken instead of signal channels | -| ProcessSignal (windows) | golang/nats-server/server/signal_windows.go:53 | MISSING | N/A | .NET does not support remote signal commands to Windows services | -| reopenLogCode, ldmCode | golang/nats-server/server/service_windows.go:24-28 | PARTIAL | Host program configuration | .NET logging uses Serilog; log rotation not exposed as service commands | +| handleSignals (windows) | golang/nats-server/server/signal_windows.go:28 | NOT_APPLICABLE | src/NATS.Server/NatsServer.cs:376 | Go signal-channel model is replaced by host-native cancellation and managed signal hooks in .NET | +| ProcessSignal (windows) | golang/nats-server/server/signal_windows.go:53 | NOT_APPLICABLE | src/NATS.Server.Host/Program.cs:226 | Remote Windows service signal command processing is not part of .NET server architecture; shutdown/reopen flows are host-driven | +| reopenLogCode, ldmCode | golang/nats-server/server/service_windows.go:24-28 | NOT_APPLICABLE | src/NATS.Server/NatsServer.cs:119 | Service command codes are Go SCM-specific; .NET exposes equivalent operations via host callbacks (`ReOpenLogFile`, graceful shutdown) | ### Test Files — Miscellaneous @@ -120,13 +120,13 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | TestPing | golang/nats-server/server/ping_test.go:34 | PORTED | tests/NATS.Server.Tests/ServerTests.cs:PingKeepaliveTests | Raw socket PING/PONG protocol test ported | | DefaultPingOptions | golang/nats-server/server/ping_test.go:26 | PORTED | tests/NATS.Server.Tests/ServerTests.cs:PingKeepaliveTests | Test options reflected in C# test setup | -| TestClosedConnsAccounting | golang/nats-server/server/closed_conns_test.go:46 | PARTIAL | tests/NATS.Server.Tests/Monitoring/ClosedConnectionRingBufferTests.cs | Ring buffer implementation exists; not all closed-conn tracking tests ported | -| TestClosedConnsSubsAccounting | golang/nats-server/server/closed_conns_test.go:102 | PARTIAL | tests/NATS.Server.Tests/Monitoring/ClosedConnectionRingBufferTests.cs | Subscription tracking in closed conns; basic tests exist | -| TestClosedAuthorizationTimeout | golang/nats-server/server/closed_conns_test.go:143 | MISSING | N/A | Auth timeout closure tracking not fully tested | -| TestClosedAuthorizationViolation | golang/nats-server/server/closed_conns_test.go:164 | PARTIAL | tests/NATS.Server.Tests/Monitoring/MonitorGoParityTests.cs | Auth violation tracking partially tested | -| TestClosedUPAuthorizationViolation | golang/nats-server/server/closed_conns_test.go:187 | PARTIAL | tests/NATS.Server.Tests/Configuration/OptsGoParityTests.cs | Username/password auth failure tracking not fully tested | -| TestClosedMaxPayload | golang/nats-server/server/closed_conns_test.go:219 | MISSING | N/A | Max payload violation closure tracking not tested | -| TestClosedTLSHandshake | golang/nats-server/server/closed_conns_test.go:247 | MISSING | N/A | TLS handshake failure closure tracking not tested | +| TestClosedConnsAccounting | golang/nats-server/server/closed_conns_test.go:46 | PORTED | tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:489 | Closed-connection accounting parity covered: single closed client tracking, bounded ring behavior, and required closed-client fields/close-reason coverage. | +| TestClosedConnsSubsAccounting | golang/nats-server/server/closed_conns_test.go:102 | PORTED | tests/NATS.Server.Tests/Monitoring/MonitorGoParityTests.cs:1720 | Closed-connection subscription accounting parity covered via `/connz?state=closed&subs=detail` assertions and closed-conn subscription detail mapping tests. | +| TestClosedAuthorizationTimeout | golang/nats-server/server/closed_conns_test.go:143 | PORTED | tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:636 | `ClosedConns_auth_timeout_close_reason_tracked` validates auth-timeout close reason accounting | +| TestClosedAuthorizationViolation | golang/nats-server/server/closed_conns_test.go:164 | PORTED | tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:680 | `ClosedConns_auth_violation_close_reason_tracked` validates auth-violation close reason accounting | +| TestClosedUPAuthorizationViolation | golang/nats-server/server/closed_conns_test.go:187 | PORTED | tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:726 | Added explicit username/password auth-violation closed-connection parity test covering no-creds and wrong-password failures | +| TestClosedMaxPayload | golang/nats-server/server/closed_conns_test.go:219 | PORTED | tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:585 | `ClosedConns_max_payload_close_reason_tracked` validates max-payload close reason accounting | +| TestClosedTLSHandshake | golang/nats-server/server/closed_conns_test.go:247 | PORTED | src/NATS.Server/NatsServer.cs:1666; tests/NATS.Server.Tests/MsgTraceGoParityTests.cs:784 | Added early-accept closed-client tracking for TLS negotiation failures (`TLS Handshake Error`) plus targeted parity test | | NoRace tests (norace_1_test.go) | golang/nats-server/server/norace_1_test.go:1 | PARTIAL | tests/NATS.Server.Tests/Stress/ (2,342 LOC vs 8,497 Go LOC) | Long-running race/concurrency tests; ~27% mapped to .NET Stress tests | | NoRace tests (norace_2_test.go) | golang/nats-server/server/norace_2_test.go:1 | PARTIAL | tests/NATS.Server.Tests/Stress/ | Additional race/concurrency scenarios; ~27% coverage in .NET | | BenchmarkPublish | golang/nats-server/server/benchmark_publish_test.go:1 | DEFERRED | N/A | Go benchmarks not directly portable; .NET uses different perf tooling (BenchmarkDotNet) | @@ -165,5 +165,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Reclassified closed-connection test parity rows to PORTED based on existing focused coverage for closed conn accounting and closed subscription detail endpoints. | codex | | 2026-02-25 | Initial gap inventory analysis: 8 platform-specific source files, 14 test symbols, 35 integration tests | claude | | 2026-02-25 | File created with LLM analysis instructions | auto | +| 2026-02-25 | Ported closed-connection parity for username/password auth violations and TLS handshake failures, including early TLS-failure closed-client tracking in accept path | codex | diff --git a/gaps/monitoring.md b/gaps/monitoring.md index d8e2f06..c4e6ec7 100644 --- a/gaps/monitoring.md +++ b/gaps/monitoring.md @@ -132,7 +132,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `SortByStop` | golang/nats-server/server/monitor_sort_opts.go:128 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:70 | LINQ OrderByDescending | | `SortByReason` | golang/nats-server/server/monitor_sort_opts.go:137 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:71 | LINQ OrderBy | | `SortByRTT` | golang/nats-server/server/monitor_sort_opts.go:144 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:72 | LINQ OrderBy | -| `SortOpt.IsValid()` | golang/nats-server/server/monitor_sort_opts.go:149 | PARTIAL | src/NATS.Server/Monitoring/ConnzHandler.cs:246 | Validity is implicit in the parse switch (falls through to ByCid default); no explicit IsValid method | +| `SortOpt.IsValid()` | golang/nats-server/server/monitor_sort_opts.go:149 | PORTED | src/NATS.Server/Monitoring/Connz.cs:618 | Added explicit `SortOptExtensions.IsValid()` parity helper and wired query parsing to validate parsed sort keys | --- @@ -146,9 +146,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ConnOpen` constant | golang/nats-server/server/monitor.go:105 | PORTED | src/NATS.Server/Monitoring/Connz.cs:619 | `ConnState.Open` | | `ConnClosed` constant | golang/nats-server/server/monitor.go:107 | PORTED | src/NATS.Server/Monitoring/Connz.cs:620 | `ConnState.Closed` | | `ConnAll` constant | golang/nats-server/server/monitor.go:109 | PORTED | src/NATS.Server/Monitoring/Connz.cs:621 | `ConnState.All` | -| `ConnInfo` struct | golang/nats-server/server/monitor.go:114 | PARTIAL | src/NATS.Server/Monitoring/Connz.cs:36 | Missing: `Stalls` field (`stalls,omitempty`), `TLSPeerCerts []*TLSPeerCert` (replaced with single subject string `TlsPeerCertSubject`), `JWT` field, `IssuerKey` field, `NameTag` field, `Tags` field, `Proxy *ProxyInfo` (replaced with plain string) | -| `ProxyInfo` struct | golang/nats-server/server/monitor.go:157 | PARTIAL | src/NATS.Server/Monitoring/Connz.cs:135 | .NET uses a plain `string Proxy` field instead of a struct with `Key` | -| `TLSPeerCert` struct | golang/nats-server/server/monitor.go:163 | MISSING | — | Go has Subject, SubjectPKISha256, CertSha256; .NET `ConnInfo.TlsPeerCertSubject` only captures Subject, no SHA256 fields | +| `ConnInfo` struct | golang/nats-server/server/monitor.go:114 | PARTIAL | src/NATS.Server/Monitoring/Connz.cs:36 | Added `stalls`, `jwt`, `issuer_key`, `name_tag`, `tags`, and structured `proxy` fields with open/closed snapshot mapping in `ConnzHandler`. Remaining parity gap: `stalls` is currently reported as `0` and `name_tag` is currently empty (no account name-tag source wired). | +| `ProxyInfo` struct | golang/nats-server/server/monitor.go:157 | PORTED | src/NATS.Server/Monitoring/Connz.cs:154 | Added `ProxyInfo` model with `key` field and wired `ConnInfo.proxy` as an object in connz output. | +| `TLSPeerCert` struct | golang/nats-server/server/monitor.go:163 | PORTED | src/NATS.Server/Monitoring/Connz.cs:144 | Added `TLSPeerCert` with `subject`, `subject_pk_sha256`, and `cert_sha256` fields; wired into `ConnInfo.tls_peer_certs` | | `DefaultConnListSize` constant | golang/nats-server/server/monitor.go:169 | PORTED | src/NATS.Server/Monitoring/Connz.cs:660 | Default 1024 on `ConnzOptions.Limit` | | `DefaultSubListSize` constant | golang/nats-server/server/monitor.go:172 | PORTED | src/NATS.Server/Monitoring/Subsz.cs:41 | Default 1024 on `SubszOptions.Limit` | | `Routez` struct | golang/nats-server/server/monitor.go:782 | PARTIAL | src/NATS.Server/Monitoring/RoutezHandler.cs:12 | Go has full Routez struct with ID, Name, Now, Import, Export, NumRoutes, Routes; .NET returns anonymous object with only `routes` and `num_routes` counts | @@ -200,9 +200,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `MetaSnapshotStats` struct | golang/nats-server/server/monitor.go:3003 | MISSING | — | RAFT meta snapshot stats not ported | | `MetaClusterInfo` struct | golang/nats-server/server/monitor.go:3011 | MISSING | — | RAFT meta cluster info not ported (referenced in JetStreamVarz.Meta) | | `JSInfo` struct | golang/nats-server/server/monitor.go:3023 | PARTIAL | src/NATS.Server/Monitoring/JszHandler.cs:39 | Go JSInfo has JetStreamStats embedded, ID, Now, Disabled, Config, Limits, Streams, StreamsLeader, Consumers, ConsumersLeader, Messages, Bytes, Meta, AccountDetails, Total; .NET JszResponse has simplified fields with different JSON names (`api_total`/`api_errors` instead of nested `api`) | -| `HealthStatus` struct | golang/nats-server/server/monitor.go:3408 | MISSING | — | /healthz returns plain "ok" string, not the structured HealthStatus with Status/StatusCode/Error/Errors | -| `HealthzError` struct | golang/nats-server/server/monitor.go:3415 | MISSING | — | No .NET equivalent | -| `HealthZErrorType` type | golang/nats-server/server/monitor.go:3423 | MISSING | — | No .NET equivalent | +| `HealthStatus` struct | golang/nats-server/server/monitor.go:3408 | PORTED | src/NATS.Server/Monitoring/Healthz.cs:9 | Added structured health DTO with Go-style JSON fields (`status`, `status_code`, `error`, `errors`) and updated `/healthz` to return this shape | +| `HealthzError` struct | golang/nats-server/server/monitor.go:3415 | PORTED | src/NATS.Server/Monitoring/Healthz.cs:30 | Added per-check error DTO with `type` and `error` fields | +| `HealthZErrorType` type | golang/nats-server/server/monitor.go:3423 | PORTED | src/NATS.Server/Monitoring/Healthz.cs:44 | Added health error classification enum with JSON string serialization | | `ExpvarzStatus` struct | golang/nats-server/server/monitor.go:4019 | MISSING | — | /expvarz endpoint not implemented | | `ProfilezStatus` struct | golang/nats-server/server/monitor.go:4043 | MISSING | — | No structured response; CPU profile endpoint returns raw bytes | | `RaftzGroup` struct | golang/nats-server/server/monitor.go:4086 | MISSING | — | /raftz endpoint not implemented | @@ -221,8 +221,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `newSubsList()` | golang/nats-server/server/monitor.go:184 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:180 | Implemented inline in BuildConnInfo | | `Server.Connz()` | golang/nats-server/server/monitor.go:193 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:12 | `ConnzHandler.HandleConnz()` | | `ConnInfo.fill()` | golang/nats-server/server/monitor.go:556 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:144 | `BuildConnInfo()` static method | -| `createProxyInfo()` | golang/nats-server/server/monitor.go:609 | PARTIAL | src/NATS.Server/Monitoring/ConnzHandler.cs:174 | .NET sets `Proxy` to a plain string; no ProxyInfo struct | -| `makePeerCerts()` | golang/nats-server/server/monitor.go:616 | PARTIAL | src/NATS.Server/Monitoring/ConnzHandler.cs:170 | .NET only captures Subject, not SubjectPKISha256 or CertSha256 | +| `createProxyInfo()` | golang/nats-server/server/monitor.go:609 | PARTIAL | src/NATS.Server/Monitoring/ConnzHandler.cs:378 | .NET now emits structured `ProxyInfo` (`proxy.key`) and snapshots it for closed clients, but key derivation is currently based on `proxy:` username prefix rather than Go's internal `client.proxyKey` source. | +| `makePeerCerts()` | golang/nats-server/server/monitor.go:616 | PORTED | src/NATS.Server/Monitoring/TlsPeerCertMapper.cs:8 | Added peer-cert mapping helper computing subject + SHA256 hashes and wired for both open and closed connz snapshots | | `client.getRTT()` | golang/nats-server/server/monitor.go:629 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:321 | `FormatRtt()` formats from stored `client.Rtt` | | `decodeBool()` | golang/nats-server/server/monitor.go:647 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:267 | Inline query param parsing in ParseQueryParams | | `decodeUint64()` | golang/nats-server/server/monitor.go:661 | PORTED | src/NATS.Server/Monitoring/ConnzHandler.cs:291 | Inline query param parsing | @@ -277,7 +277,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Server.Jsz()` | golang/nats-server/server/monitor.go:3180 | PARTIAL | src/NATS.Server/Monitoring/JszHandler.cs:16 | Go Jsz() has full JSInfo with accounts/streams/consumers/meta; .NET JszHandler.Build() returns simplified flat fields | | `Server.accountDetail()` | golang/nats-server/server/monitor.go:3041 | MISSING | — | Per-account stream/consumer detail not ported | | `Server.HandleJsz()` | golang/nats-server/server/monitor.go:3334 | PARTIAL | src/NATS.Server/Monitoring/MonitorServer.cs:111 | Endpoint mapped; all query params (accounts, streams, consumers, config, offset, limit, leader-only, raft, stream-leader-only) not parsed | -| `Server.HandleHealthz()` | golang/nats-server/server/monitor.go:3478 | PARTIAL | src/NATS.Server/Monitoring/MonitorServer.cs:59 | Endpoint mapped; Go does JetStream-aware checks; .NET returns static "ok" with no JS checks | +| `Server.HandleHealthz()` | golang/nats-server/server/monitor.go:3478 | PARTIAL | src/NATS.Server/Monitoring/MonitorServer.cs:59 | Endpoint mapped and now returns structured `HealthStatus`; Go JetStream-aware/dependency checks are still missing | | `Server.healthz()` | golang/nats-server/server/monitor.go:3537 | MISSING | — | Full healthz logic with JS stream/consumer recovery checks not ported | | `Server.Healthz()` | golang/nats-server/server/monitor.go:4014 | MISSING | — | Public Healthz() method not ported | | `Server.expvarz()` | golang/nats-server/server/monitor.go:4024 | MISSING | — | /expvarz endpoint not implemented | @@ -310,5 +310,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Improved connz parity for `ConnInfo`/`ProxyInfo`: added Go-style `stalls`, `jwt`, `issuer_key`, `name_tag`, `tags`, and structured `proxy` output; wired JWT/tag decode and closed-conn snapshot fields; added focused monitoring parity tests. | codex | +| 2026-02-25 | Ported monitoring TLS peer-cert parity slice: added `TLSPeerCert` model and `tls_peer_certs` JSON output with SHA256 fields, wired open/closed conn snapshots via `TlsPeerCertMapper`, and added focused tests (`TlsPeerCertParityTests`). | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: 63 PORTED, 42 PARTIAL, 61 MISSING, 3 NOT_APPLICABLE, 0 DEFERRED (169 total rows) | claude-sonnet-4-6 | diff --git a/gaps/mqtt.md b/gaps/mqtt.md index 1abf35a..051bef6 100644 --- a/gaps/mqtt.md +++ b/gaps/mqtt.md @@ -96,24 +96,24 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | mqttProtoLevel | mqtt.go:59 | PORTED | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:87-88 | Checked in ParseConnect | | mqttConnFlag* constants | mqtt.go:62-68 | PORTED | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:96-104 | Decoded inline in ParseConnect | | mqttPubFlag* constants | mqtt.go:71-75 | PORTED | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:161-163 | Decoded inline in ParsePublish | -| mqttSubscribeFlags | mqtt.go:78 | MISSING | | Subscribe flag validation not implemented in binary parser | -| mqttConnAckRC* constants | mqtt.go:85-91 | MISSING | | ConnAck return code constants not defined | -| mqttMaxPayloadSize | mqtt.go:94 | MISSING | | Max payload size constant not defined | +| mqttSubscribeFlags | mqtt.go:78 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:9 | Added SUBSCRIBE flags constant (`0x02`) and wired parser validation in `MqttBinaryDecoder.ParseSubscribe(..., flags)` | +| mqttConnAckRC* constants | mqtt.go:85-91 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:12 | Added MQTT 3.1.1 CONNACK return-code constants (`0x00`..`0x05`) for parity | +| mqttMaxPayloadSize | mqtt.go:94 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:20 | Added max payload constant and wired reader/writer remaining-length guardrails | | mqttTopicLevelSep, mqttSingleLevelWC, mqttMultiLevelWC | mqtt.go:97-100 | PORTED | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:254-258 | Used in TranslateFilterToNatsSubject | -| mqttMultiLevelSidSuffix | mqtt.go:105 | MISSING | | Multi-level SID suffix for '#' wildcard not implemented | -| mqttPrefix, mqttSubPrefix | mqtt.go:108-113 | MISSING | | MQTT internal subject prefixes not defined | -| mqttStreamName, mqttStreamSubjectPrefix | mqtt.go:116-117 | MISSING | | JetStream stream naming not implemented | -| mqttRetainedMsgsStreamName | mqtt.go:120-121 | MISSING | | Retained messages stream not implemented | -| mqttSessStreamName | mqtt.go:124-125 | MISSING | | Session stream naming not implemented | -| mqttQoS2IncomingMsgsStreamName | mqtt.go:131-132 | MISSING | | QoS2 incoming stream not implemented | -| mqttOutStreamName, mqttPubRel* | mqtt.go:135-139 | MISSING | | PUBREL stream/subject not implemented | -| mqttDefaultAckWait | mqtt.go:145 | MISSING | | Default ack wait not defined (Go: 30s) | +| mqttMultiLevelSidSuffix | mqtt.go:105 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:29 | Added `MultiLevelSidSuffix = \" fwc\"` constant | +| mqttPrefix, mqttSubPrefix | mqtt.go:108-113 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:32 | Added MQTT internal subject prefixes (`Prefix`, `SubPrefix`) | +| mqttStreamName, mqttStreamSubjectPrefix | mqtt.go:116-117 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:36 | Added message stream name and subject-prefix constants | +| mqttRetainedMsgsStreamName | mqtt.go:120-121 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:38 | Added retained-message stream constants | +| mqttSessStreamName | mqtt.go:124-125 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:40 | Added session stream constants | +| mqttQoS2IncomingMsgsStreamName | mqtt.go:131-132 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:43 | Added QoS2 incoming stream constants | +| mqttOutStreamName, mqttPubRel* | mqtt.go:135-139 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:47 | Added outbound/PUBREL stream and subject-prefix constants | +| mqttDefaultAckWait | mqtt.go:145 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:23 | Added Go-parity default ack wait (`TimeSpan.FromSeconds(30)`) | | mqttDefaultMaxAckPending | mqtt.go:149 | PARTIAL | src/NATS.Server/Mqtt/MqttFlowController.cs:15 | Default 1024 matches Go, but not wired to JetStream | -| mqttMaxAckTotalLimit | mqtt.go:153 | MISSING | | Max ack total limit (0xFFFF) not defined | -| mqttJSA* token constants | mqtt.go:156-177 | MISSING | | JetStream API reply subject tokens not implemented | +| mqttMaxAckTotalLimit | mqtt.go:153 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:26 | Added max ack total limit constant (`0xFFFF`) for flow/ack accounting parity | +| mqttJSA* token constants | mqtt.go:156-177 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:54 | Added JSA reply prefix, token positions, and stream/consumer/message token constants | | mqttSessFlappingJailDur | mqtt.go:182 | PARTIAL | src/NATS.Server/Mqtt/MqttSessionStore.cs:96-106 | Flap detection exists but uses different default timing | -| sparkb* constants | mqtt.go:201-211 | MISSING | | Sparkplug B protocol constants not implemented | -| mqttNatsHeader* constants | mqtt.go:474-492 | MISSING | | NATS header names for MQTT message encoding not defined | +| sparkb* constants | mqtt.go:201-211 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:75 | Added Sparkplug birth/death constants and topic-prefix byte arrays | +| mqttNatsHeader* constants | mqtt.go:474-492 | PORTED | src/NATS.Server/Mqtt/MqttProtocolConstants.cs:83 | Added MQTT/NATS re-encoding header-name constants | #### Core Types (lines 246-498) @@ -122,14 +122,14 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | srvMQTT | mqtt.go:246 | PARTIAL | src/NATS.Server/Mqtt/MqttListener.cs:8-12 | Listener exists but no integration with Server struct or authOverride | | mqttSessionManager | mqtt.go:253-256 | PARTIAL | src/NATS.Server/Mqtt/MqttSessionStore.cs:67 | Session store exists but not multi-account | | mqttAccountSessionManager | mqtt.go:258-270 | PARTIAL | src/NATS.Server/Mqtt/MqttSessionStore.cs:67 | Single-account only, no JetStream backing, no retained msg sublist, no session hash | -| mqttJSA | mqtt.go:277-289 | MISSING | | JetStream API helper struct not implemented | -| mqttJSPubMsg | mqtt.go:291-296 | MISSING | | JS publish message type not implemented | -| mqttRetMsgDel | mqtt.go:298-301 | MISSING | | Retained message delete notification type not implemented | +| mqttJSA | mqtt.go:277-289 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttJsa`) | Added MQTT JetStream API helper model with account/reply-prefix/domain fields | +| mqttJSPubMsg | mqtt.go:291-296 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttJsPubMsg`) | Added JetStream publish message model (`Subject`, `Payload`, `ReplyTo`) | +| mqttRetMsgDel | mqtt.go:298-301 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttRetMsgDel`) | Added retained-message delete notification model (`Topic`, `Sequence`) | | mqttSession | mqtt.go:303-344 | PARTIAL | src/NATS.Server/Mqtt/MqttSessionStore.cs:48-60 | MqttSessionData covers basic fields but missing pendingPublish/pendingPubRel maps, cpending, last_pi, maxp, tmaxack, JetStream consumer tracking | -| mqttPersistedSession | mqtt.go:346-353 | MISSING | | Persisted session JSON format not implemented | -| mqttRetainedMsg | mqtt.go:355-364 | PARTIAL | src/NATS.Server/Mqtt/MqttRetainedStore.cs:14 | MqttRetainedMessage exists but missing Origin, Flags, Source, Topic fields; no cache TTL | -| mqttRetainedMsgRef | mqtt.go:366-369 | MISSING | | Retained message reference (sseq + sub) not implemented | -| mqttSub | mqtt.go:375-391 | MISSING | | MQTT subscription metadata (qos, jsDur, prm, reserved) not implemented | +| mqttPersistedSession | mqtt.go:346-353 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttPersistedSession`) | Added persisted-session metadata model (`ClientId`, `LastPacketId`, `MaxAckPending`) | +| mqttRetainedMsg | mqtt.go:355-364 | PARTIAL | src/NATS.Server/Mqtt/MqttRetainedStore.cs:14 | Retained message model now includes `Origin`, `Flags`, `Source` in addition to topic/payload. Remaining: cache TTL + JetStream retention parity | +| mqttRetainedMsgRef | mqtt.go:366-369 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttRetainedMessageRef`) | Added retained-message reference model (`StreamSequence`, `Subject`) | +| mqttSub | mqtt.go:375-391 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttSub`) | Added MQTT subscription metadata model (`Filter`, `Qos`, `JsDur`, `Prm`, `Reserved`) | | mqtt (client struct) | mqtt.go:393-408 | PARTIAL | src/NATS.Server/Mqtt/MqttConnection.cs:6-16 | MqttConnection exists but missing reader/writer, asm, sess, cid, rejectQoS2Pub, downgradeQoS2Sub | | mqttPending | mqtt.go:410-414 | PARTIAL | src/NATS.Server/Mqtt/MqttQoS1Tracker.cs:89-96 | QoS1PendingMessage exists but missing sseq, jsAckSubject, jsDur fields | | mqttConnectProto | mqtt.go:416-420 | PARTIAL | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:14-25 | MqttConnectInfo covers most fields but is a record struct, not mutable; missing rd (read deadline) | @@ -137,9 +137,9 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | mqttReader | mqtt.go:427-433 | PARTIAL | src/NATS.Server/Mqtt/MqttPacketReader.cs:23-41 | MqttPacketReader handles fixed header; missing streaming buffer/partial packet support (pbuf, pstart) | | mqttWriter | mqtt.go:435-437 | PORTED | src/NATS.Server/Mqtt/MqttPacketWriter.cs:3-38 | MqttPacketWriter covers write operations | | mqttWill | mqtt.go:439-446 | PARTIAL | src/NATS.Server/Mqtt/MqttSessionStore.cs:35-42 | WillMessage exists but missing subject, mapped byte arrays; topic is string not bytes | -| mqttFilter | mqtt.go:448-453 | MISSING | | MQTT filter struct (filter, qos, ttopic) not implemented as a standalone type | +| mqttFilter | mqtt.go:448-453 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttFilter`) | Added standalone MQTT filter model (`Filter`, `Qos`, `TopicToken`) | | mqttPublish | mqtt.go:455-463 | PARTIAL | src/NATS.Server/Mqtt/MqttBinaryDecoder.cs:31-37 | MqttPublishInfo covers most fields but missing subject, mapped byte arrays | -| mqttParsedPublishNATSHeader | mqtt.go:495-499 | MISSING | | Parsed NATS header struct for MQTT messages not implemented | +| mqttParsedPublishNATSHeader | mqtt.go:495-499 | PORTED | src/NATS.Server/Mqtt/MqttParityModels.cs (`MqttParsedPublishNatsHeader`) | Added parsed NATS-header model for MQTT publish flow (`Subject`, `Mapped`, publish/pubrel flags) | #### Server Lifecycle Functions (lines 501-722) @@ -363,8 +363,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | mqttWriter.WriteUint16 | mqtt.go:5850 | PARTIAL | src/NATS.Server/Mqtt/MqttPacketWriter.cs:12-14 | Inline in Write method, not a standalone helper | -| mqttWriter.WriteString | mqtt.go:5855 | MISSING | | Standalone string write (length-prefixed) not implemented | -| mqttWriter.WriteBytes | mqtt.go:5859 | MISSING | | Standalone bytes write (length-prefixed) not implemented | +| mqttWriter.WriteString | mqtt.go:5855 | PORTED | src/NATS.Server/Mqtt/MqttPacketWriter.cs:8 | Added standalone UTF-8 length-prefixed `WriteString` helper | +| mqttWriter.WriteBytes | mqtt.go:5859 | PORTED | src/NATS.Server/Mqtt/MqttPacketWriter.cs:11 | Added standalone length-prefixed `WriteBytes` helper with uint16 size guard | | mqttWriter.WriteVarInt | mqtt.go:5864 | PORTED | src/NATS.Server/Mqtt/MqttPacketWriter.cs:19-37 | EncodeRemainingLength implemented | | newMQTTWriter | mqtt.go:5878 | PARTIAL | src/NATS.Server/Mqtt/MqttPacketWriter.cs:3 | Static class, no constructor needed; Write method serves the purpose | @@ -393,9 +393,9 @@ After porting work is completed: | Status | Count | |--------|-------| -| PORTED | 14 | +| PORTED | 39 | | PARTIAL | 57 | -| MISSING | 119 | +| MISSING | 94 | | NOT_APPLICABLE | 5 | | DEFERRED | 0 | | **Total** | **195** | @@ -404,5 +404,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-25 | Ported MQTT constants/writer parity batch: internal subject/stream constants, JSA reply token constants, Sparkplug constants, MQTT→NATS header constants, plus standalone `MqttPacketWriter.WriteString/WriteBytes` helpers with targeted tests (`MqttProtocolConstantsParityBatch2Tests`). | codex | +| 2026-02-26 | Ported MQTT helper-model parity batch: added missing helper/data models (`MqttJsa`, `MqttJsPubMsg`, `MqttRetMsgDel`, `MqttPersistedSession`, `MqttRetainedMessageRef`, `MqttSub`, `MqttFilter`, `MqttParsedPublishNatsHeader`) and extended retained message model with `Origin`/`Flags`/`Source`; verified in `MqttModelParityBatch3Tests`. | codex | | 2026-02-25 | Full gap analysis completed: 195 items analyzed. 14 PORTED, 57 PARTIAL, 119 MISSING, 5 NOT_APPLICABLE. Major gaps: JetStream integration (entire mqttJSA layer ~30 functions), binary protocol encoding (CONNACK/SUBACK/UNSUBACK/PUBLISH serialization), delivery callbacks (QoS0/QoS1/QoS2), account session management, retained message encoding/decoding, Sparkplug B support, NATS subject reverse mapping. .NET has solid foundation for packet reading/writing, connect parsing, basic pub/sub flow, QoS tracking, and retained store, but all are simplified/in-memory-only without JetStream backing. | claude | | 2026-02-25 | File created with LLM analysis instructions | auto | diff --git a/gaps/plans.md b/gaps/plans.md new file mode 100644 index 0000000..7166608 --- /dev/null +++ b/gaps/plans.md @@ -0,0 +1,21 @@ +| Category Name | Category Gaps File Path | Design File Path | Plan File Path | Status | +|---|---|---|---|---| +| core-server | gaps/core-server.md | docs/plans/2026-02-25-gap-port-core-server-design.md | docs/plans/2026-02-25-gap-port-core-server-plan.md | 43 remaining | +| protocol | gaps/protocol.md | docs/plans/2026-02-25-gap-port-protocol-design.md | docs/plans/2026-02-25-gap-port-protocol-plan.md | 9 remaining | +| subscriptions | gaps/subscriptions.md | docs/plans/2026-02-25-gap-port-subscriptions-design.md | docs/plans/2026-02-25-gap-port-subscriptions-plan.md | complete | +| auth-and-accounts | gaps/auth-and-accounts.md | docs/plans/2026-02-25-gap-port-auth-and-accounts-design.md | docs/plans/2026-02-25-gap-port-auth-and-accounts-plan.md | 148 remaining | +| configuration | gaps/configuration.md | docs/plans/2026-02-25-gap-port-configuration-design.md | docs/plans/2026-02-25-gap-port-configuration-plan.md | complete | +| routes | gaps/routes.md | docs/plans/2026-02-25-gap-port-routes-design.md | docs/plans/2026-02-25-gap-port-routes-plan.md | 36 remaining | +| gateways | gaps/gateways.md | docs/plans/2026-02-25-gap-port-gateways-design.md | docs/plans/2026-02-25-gap-port-gateways-plan.md | 67 remaining | +| leaf-nodes | gaps/leaf-nodes.md | docs/plans/2026-02-25-gap-port-leaf-nodes-design.md | docs/plans/2026-02-25-gap-port-leaf-nodes-plan.md | 54 remaining | +| jetstream | gaps/jetstream.md | docs/plans/2026-02-25-gap-port-jetstream-design.md | docs/plans/2026-02-25-gap-port-jetstream-plan.md | 1084 remaining | +| raft | gaps/raft.md | docs/plans/2026-02-25-gap-port-raft-design.md | docs/plans/2026-02-25-gap-port-raft-plan.md | 107 remaining | +| mqtt | gaps/mqtt.md | docs/plans/2026-02-25-gap-port-mqtt-design.md | docs/plans/2026-02-25-gap-port-mqtt-plan.md | 151 remaining | +| websocket | gaps/websocket.md | docs/plans/2026-02-25-gap-port-websocket-design.md | docs/plans/2026-02-25-gap-port-websocket-plan.md | 5 remaining | +| monitoring | gaps/monitoring.md | docs/plans/2026-02-25-gap-port-monitoring-design.md | docs/plans/2026-02-25-gap-port-monitoring-plan.md | 96 remaining | +| events | gaps/events.md | docs/plans/2026-02-25-gap-port-events-design.md | docs/plans/2026-02-25-gap-port-events-plan.md | 111 remaining | +| tls-security | gaps/tls-security.md | docs/plans/2026-02-25-gap-port-tls-security-design.md | docs/plans/2026-02-25-gap-port-tls-security-plan.md | 56 remaining | +| internal-ds | gaps/internal-ds.md | docs/plans/2026-02-25-gap-port-internal-ds-design.md | docs/plans/2026-02-25-gap-port-internal-ds-plan.md | complete | +| logging | gaps/logging.md | docs/plans/2026-02-25-gap-port-logging-design.md | docs/plans/2026-02-25-gap-port-logging-plan.md | complete | +| utilities-and-other | gaps/utilities-and-other.md | docs/plans/2026-02-25-gap-port-utilities-and-other-design.md | docs/plans/2026-02-25-gap-port-utilities-and-other-plan.md | 47 remaining | +| misc-uncategorized | gaps/misc-uncategorized.md | docs/plans/2026-02-25-gap-port-misc-uncategorized-design.md | docs/plans/2026-02-25-gap-port-misc-uncategorized-plan.md | 7 remaining | diff --git a/gaps/protocol.md b/gaps/protocol.md index d63987b..001faaa 100644 --- a/gaps/protocol.md +++ b/gaps/protocol.md @@ -103,8 +103,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `pubArg` (struct) | parser.go:37 | PARTIAL | `src/NATS.Server/Protocol/NatsParser.cs:21` | `ParsedCommand` covers `subject`, `reply`, `size`, `hdr`. Missing: `origin`, `account`, `pacache`, `mapped`, `queues`, `szb`, `hdb`, `psi`, `trace`, `delivered` — clustering/routing/JetStream fields not yet needed for core client protocol. | | `OP_START` … `INFO_ARG` (parser state constants) | parser.go:57–134 | PARTIAL | `src/NATS.Server/Protocol/NatsParser.cs:104` | All CLIENT-facing states implemented (PUB, HPUB, SUB, UNSUB, CONNECT, INFO, PING, PONG, +OK, -ERR). MISSING states: `OP_A`/`ASUB_ARG`/`AUSUB_ARG` (A+/A- for gateways), `OP_R`/`OP_RS`/`OP_L`/`OP_LS` (RMSG/LMSG/RS+/RS-/LS+/LS-), `OP_M`/`MSG_ARG`/`HMSG_ARG` (routing MSG/HMSG). See `ClientCommandMatrix.cs` for partial routing opcode routing. | | `client.parse()` | parser.go:136 | PARTIAL | `src/NATS.Server/Protocol/NatsParser.cs:69` | Core CLIENT-facing parse loop ported as `NatsParser.TryParse()` using `ReadOnlySequence` + `SequenceReader`. Missing: byte-by-byte incremental state transitions (Go uses byte-by-byte state machine; .NET scans for `\r\n` on each call), auth-set check before non-CONNECT op, MQTT dispatch (`c.mqttParse`), gateway in-CONNECT gating, ROUTER/GATEWAY/LEAF protocol dispatch (RMSG, LMSG, RS+, RS-, A+, A-). | -| `protoSnippet()` | parser.go:1236 | MISSING | — | Helper that formats a quoted snippet of the protocol buffer for error messages. The .NET parser throws `ProtocolViolationException` with a plain message; no equivalent snippet utility exists. | -| `client.overMaxControlLineLimit()` | parser.go:1251 | PARTIAL | `src/NATS.Server/Protocol/NatsParser.cs:82` | .NET checks `line.Length > NatsProtocol.MaxControlLineSize` and throws. Missing: kind-check (Go only enforces for `CLIENT` kind), client close on violation (`closeConnection(MaxControlLineExceeded)`), structured error with state/buffer info. | +| `protoSnippet()` | parser.go:1236 | PORTED | `src/NATS.Server/Protocol/NatsParser.cs:206` | Added Go-style quoted snippet helper (`ProtoSnippet(start,max,buffer)`) and default overload, with parity tests in `tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs`. | +| `client.overMaxControlLineLimit()` | parser.go:1251 | PARTIAL | `src/NATS.Server/Protocol/NatsParser.cs:83` | .NET now emits structured max-control-line errors with snippet context (`snip=...`). Missing: kind-check (Go only enforces for `CLIENT`) and explicit connection-close side effect (`closeConnection(MaxControlLineExceeded)`) at parser layer. | | `client.clonePubArg()` | parser.go:1267 | MISSING | — | Split-buffer scenario: clones pubArg and re-processes when payload spans two reads. Not needed in .NET because `System.IO.Pipelines` handles buffering, but there is no explicit equivalent. | | `parseState.getHeader()` | parser.go:1297 | PARTIAL | `src/NATS.Server/Protocol/NatsHeaderParser.cs:25` | Go lazily parses `http.Header` from the raw message buffer. .NET has `NatsHeaderParser.Parse()` which parses NATS/1.0 headers. Missing: lazy evaluation on the parsed command (header is not cached on `ParsedCommand`). | @@ -114,15 +114,15 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| `errProtoInsufficient` | proto.go:24 | MISSING | — | Package-level error sentinel for varint parsing. No .NET equivalent (JetStream binary encoding not yet ported). | -| `errProtoOverflow` | proto.go:25 | MISSING | — | Package-level error sentinel. | -| `errProtoInvalidFieldNumber` | proto.go:26 | MISSING | — | Package-level error sentinel. | -| `protoScanField()` | proto.go:28 | MISSING | — | Scans one protobuf field (tag + value) from a byte slice. Used by JetStream internal encoding. Not yet ported. | -| `protoScanTag()` | proto.go:42 | MISSING | — | Decodes a protobuf tag (field number + wire type) from a varint. Not yet ported. | -| `protoScanFieldValue()` | proto.go:61 | MISSING | — | Reads the value portion of a protobuf field by wire type. Not yet ported. | -| `protoScanVarint()` | proto.go:77 | MISSING | — | 10-byte max varint decoder. Not yet ported. | -| `protoScanBytes()` | proto.go:179 | MISSING | — | Length-delimited bytes field reader. Not yet ported. | -| `protoEncodeVarint()` | proto.go:190 | MISSING | — | Varint encoder. Not yet ported. | +| `errProtoInsufficient` | proto.go:24 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:5` | Added sentinel-equivalent error constant and `ProtoWireException` usage for insufficient varint/bytes payloads. | +| `errProtoOverflow` | proto.go:25 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:6` | Added overflow sentinel-equivalent error constant for invalid 10-byte varint tails. | +| `errProtoInvalidFieldNumber` | proto.go:26 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:7` | Added invalid field-number sentinel-equivalent error constant used by tag scanning. | +| `protoScanField()` | proto.go:28 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:9` | Added field scanner that composes tag + value scanning and returns total consumed size. | +| `protoScanTag()` | proto.go:42 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:16` | Added tag scanner with Go-equivalent field number validation (`1..int32`). | +| `protoScanFieldValue()` | proto.go:61 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:26` | Added wire-type scanner for varint/fixed32/fixed64/length-delimited forms. | +| `protoScanVarint()` | proto.go:77 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:38` | Added 10-byte max varint scanner with insufficient/overflow parity errors. | +| `protoScanBytes()` | proto.go:179 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:65` | Added length-delimited scanner that validates the declared size against remaining payload bytes. | +| `protoEncodeVarint()` | proto.go:190 | PORTED | `src/NATS.Server/Protocol/ProtoWire.cs:74` | Added varint encoder and round-trip coverage in parity tests. | ### golang/nats-server/server/const.go @@ -137,54 +137,54 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `PROTO = 1` | const.go:76 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:12` | `ProtoVersion = 1` | | `DEFAULT_PORT = 4222` | const.go:79 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:10` | `DefaultPort = 4222` | | `RANDOM_PORT = -1` | const.go:83 | NOT_APPLICABLE | — | Used in Go test helpers to request a random port. .NET tests use `GetFreePort()` pattern. | -| `DEFAULT_HOST = "0.0.0.0"` | const.go:86 | MISSING | — | No explicit constant in .NET; server defaults to `0.0.0.0` but the constant is not named. | +| `DEFAULT_HOST = "0.0.0.0"` | const.go:86 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:11` | Added `NatsProtocol.DefaultHost` and wired `NatsOptions.Host` default to it. | | `MAX_CONTROL_LINE_SIZE = 4096` | const.go:91 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:7` | `MaxControlLineSize = 4096` | | `MAX_PAYLOAD_SIZE = 1MB` | const.go:95 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:8` | `MaxPayloadSize = 1024 * 1024` | -| `MAX_PAYLOAD_MAX_SIZE = 8MB` | const.go:99 | MISSING | — | Warning threshold for max_payload setting. No .NET equivalent. | +| `MAX_PAYLOAD_MAX_SIZE = 8MB` | const.go:99 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:9` | Added `NatsProtocol.MaxPayloadMaxSize` (8MB threshold constant). | | `MAX_PENDING_SIZE = 64MB` | const.go:103 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:9` | `MaxPendingSize = 64 * 1024 * 1024` | -| `DEFAULT_MAX_CONNECTIONS = 64K` | const.go:106 | MISSING | — | Default max connections cap. No .NET equivalent constant. | -| `TLS_TIMEOUT = 2s` | const.go:109 | MISSING | — | TLS handshake wait time. Not yet defined in .NET options. | -| `DEFAULT_TLS_HANDSHAKE_FIRST_FALLBACK_DELAY = 50ms` | const.go:114 | MISSING | — | TLS-first handshake fallback delay. Not yet implemented in .NET. | -| `AUTH_TIMEOUT = 2s` | const.go:118 | MISSING | — | Authorization wait timeout. No .NET equivalent constant. | -| `DEFAULT_PING_INTERVAL = 2min` | const.go:122 | MISSING | — | Ping interval for keep-alive. No .NET equivalent. | -| `DEFAULT_PING_MAX_OUT = 2` | const.go:125 | MISSING | — | Max outstanding pings before disconnect. No .NET equivalent. | +| `DEFAULT_MAX_CONNECTIONS = 64K` | const.go:106 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:13` | Added `NatsProtocol.DefaultMaxConnections` and wired `NatsOptions.MaxConnections`. | +| `TLS_TIMEOUT = 2s` | const.go:109 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:18`, `src/NATS.Server/NatsOptions.cs:102` | Added protocol default and wired TLS timeout default in options. | +| `DEFAULT_TLS_HANDSHAKE_FIRST_FALLBACK_DELAY = 50ms` | const.go:114 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:19`, `src/NATS.Server/NatsOptions.cs:104` | Added protocol default and wired `TlsHandshakeFirstFallback` default in options. | +| `AUTH_TIMEOUT = 2s` | const.go:118 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:20`, `src/NATS.Server/NatsOptions.cs:49` | Added protocol default and wired `AuthTimeout` default in options. | +| `DEFAULT_PING_INTERVAL = 2min` | const.go:122 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:21`, `src/NATS.Server/NatsOptions.cs:19` | Added protocol default and wired `PingInterval` default in options. | +| `DEFAULT_PING_MAX_OUT = 2` | const.go:125 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:14`, `src/NATS.Server/NatsOptions.cs:20` | Added protocol default and wired `MaxPingsOut` default in options. | | `CR_LF = "\r\n"` | const.go:128 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:15` | `CrLf` byte array. | | `LEN_CR_LF = 2` | const.go:131 | PORTED | Implicit in .NET (`+ 2` literals in parser). | Used as literal `2` in `TryReadPayload`. | -| `DEFAULT_FLUSH_DEADLINE = 10s` | const.go:134 | MISSING | — | Write/flush deadline. Not yet defined. | -| `DEFAULT_HTTP_PORT = 8222` | const.go:137 | MISSING | — | Monitoring port. Not yet implemented. | -| `DEFAULT_HTTP_BASE_PATH = "/"` | const.go:140 | MISSING | — | Monitoring HTTP base path. Not yet implemented. | -| `ACCEPT_MIN_SLEEP = 10ms` | const.go:143 | MISSING | — | Retry sleep for transient accept errors. Not yet defined. | -| `ACCEPT_MAX_SLEEP = 1s` | const.go:146 | MISSING | — | Max sleep for accept errors. Not yet defined. | -| `DEFAULT_ROUTE_CONNECT = 1s` | const.go:149 | MISSING | — | Route solicitation interval. Clustering not yet implemented. | -| `DEFAULT_ROUTE_CONNECT_MAX = 30s` | const.go:152 | MISSING | — | Route max solicitation interval. | -| `DEFAULT_ROUTE_RECONNECT = 1s` | const.go:155 | MISSING | — | Route reconnect delay. | -| `DEFAULT_ROUTE_DIAL = 1s` | const.go:158 | MISSING | — | Route dial timeout. | -| `DEFAULT_ROUTE_POOL_SIZE = 3` | const.go:161 | MISSING | — | Route connection pool size. | -| `DEFAULT_LEAF_NODE_RECONNECT = 1s` | const.go:164 | MISSING | — | Leaf node reconnect interval. | -| `DEFAULT_LEAF_TLS_TIMEOUT = 2s` | const.go:167 | MISSING | — | Leaf node TLS timeout. | -| `PROTO_SNIPPET_SIZE = 32` | const.go:170 | MISSING | — | Size of proto snippet in parse errors. No .NET equivalent (errors use plain messages). | -| `MAX_CONTROL_LINE_SNIPPET_SIZE = 128` | const.go:172 | MISSING | — | Snippet size for control-line-too-long errors. | +| `DEFAULT_FLUSH_DEADLINE = 10s` | const.go:134 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:22`, `src/NATS.Server/NatsOptions.cs:18` | Added protocol default and wired `WriteDeadline` default in options. | +| `DEFAULT_HTTP_PORT = 8222` | const.go:137 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:15` | Added `NatsProtocol.DefaultHttpPort` constant and parity assertions in protocol constants tests. | +| `DEFAULT_HTTP_BASE_PATH = "/"` | const.go:140 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:16` | Added `NatsProtocol.DefaultHttpBasePath` constant and parity assertions in protocol constants tests. | +| `ACCEPT_MIN_SLEEP = 10ms` | const.go:143 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:23`, `src/NATS.Server/NatsServer.cs:94` | Added protocol default and wired accept-loop backoff minimum in server. | +| `ACCEPT_MAX_SLEEP = 1s` | const.go:146 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:24`, `src/NATS.Server/NatsServer.cs:95` | Added protocol default and wired accept-loop backoff maximum in server. | +| `DEFAULT_ROUTE_CONNECT = 1s` | const.go:149 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:31` | Added `NatsProtocol.DefaultRouteConnect` constant. | +| `DEFAULT_ROUTE_CONNECT_MAX = 30s` | const.go:152 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:32` | Added `NatsProtocol.DefaultRouteConnectMax` constant. | +| `DEFAULT_ROUTE_RECONNECT = 1s` | const.go:155 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:33` | Added `NatsProtocol.DefaultRouteReconnect` constant. | +| `DEFAULT_ROUTE_DIAL = 1s` | const.go:158 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:34` | Added `NatsProtocol.DefaultRouteDial` constant. | +| `DEFAULT_ROUTE_POOL_SIZE = 3` | const.go:161 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:17` | Added `NatsProtocol.DefaultRoutePoolSize` constant. | +| `DEFAULT_LEAF_NODE_RECONNECT = 1s` | const.go:164 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:35` | Added `NatsProtocol.DefaultLeafNodeReconnect` constant. | +| `DEFAULT_LEAF_TLS_TIMEOUT = 2s` | const.go:167 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:36` | Added `NatsProtocol.DefaultLeafTlsTimeout` constant. | +| `PROTO_SNIPPET_SIZE = 32` | const.go:170 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:9`, `src/NATS.Server/Protocol/NatsParser.cs:222` | Added snippet-size constant and wired parser default `ProtoSnippet` overload to it. | +| `MAX_CONTROL_LINE_SNIPPET_SIZE = 128` | const.go:172 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:8`, `src/NATS.Server/Protocol/NatsParser.cs:85` | Added max control-line snippet size constant and wired control-line violation errors to use it. | | `MAX_MSG_ARGS = 4` | const.go:175 | NOT_APPLICABLE | — | Used in Go's manual arg-split loop. .NET uses `SplitArgs()` with stack-allocated ranges. | | `MAX_RMSG_ARGS = 6` | const.go:178 | NOT_APPLICABLE | — | Used in RMSG parsing. RMSG not yet ported. | | `MAX_HMSG_ARGS = 7` | const.go:180 | NOT_APPLICABLE | — | Used in HMSG parsing. HMSG routing not yet ported. | | `MAX_PUB_ARGS = 3` | const.go:183 | NOT_APPLICABLE | — | Used in PUB arg splitting. .NET uses dynamic `SplitArgs`. | | `MAX_HPUB_ARGS = 4` | const.go:186 | NOT_APPLICABLE | — | Used in HPUB arg splitting. .NET uses dynamic `SplitArgs`. | | `MAX_RSUB_ARGS = 6` | const.go:189 | NOT_APPLICABLE | — | Used in RS+/LS+ subscription arg splitting. Not yet ported. | -| `DEFAULT_MAX_CLOSED_CLIENTS = 10000` | const.go:192 | MISSING | — | Closed-connection history cap. Not yet implemented. | -| `DEFAULT_LAME_DUCK_DURATION = 2min` | const.go:196 | MISSING | — | Lame-duck shutdown spread duration. Not yet implemented. | -| `DEFAULT_LAME_DUCK_GRACE_PERIOD = 10s` | const.go:200 | MISSING | — | Lame-duck grace period. Not yet implemented. | -| `DEFAULT_LEAFNODE_INFO_WAIT = 1s` | const.go:203 | MISSING | — | Leaf node INFO wait. Not yet implemented. | -| `DEFAULT_LEAFNODE_PORT = 7422` | const.go:206 | MISSING | — | Default leaf node port. Not yet implemented. | -| `DEFAULT_CONNECT_ERROR_REPORTS = 3600` | const.go:214 | MISSING | — | Error report throttle for initial connection failures. Not yet implemented. | -| `DEFAULT_RECONNECT_ERROR_REPORTS = 1` | const.go:220 | MISSING | — | Error report throttle for reconnect failures. Not yet implemented. | -| `DEFAULT_RTT_MEASUREMENT_INTERVAL = 1h` | const.go:224 | MISSING | — | RTT measurement interval. Not yet implemented. | -| `DEFAULT_ALLOW_RESPONSE_MAX_MSGS = 1` | const.go:228 | MISSING | — | Default allowed response message count for reply subjects. Not yet implemented. | -| `DEFAULT_ALLOW_RESPONSE_EXPIRATION = 2min` | const.go:232 | MISSING | — | Dynamic response permission expiry. Not yet implemented. | -| `DEFAULT_SERVICE_EXPORT_RESPONSE_THRESHOLD = 2min` | const.go:237 | MISSING | — | Service export response threshold. Not yet implemented (accounts/JetStream). | -| `DEFAULT_SERVICE_LATENCY_SAMPLING = 100` | const.go:241 | MISSING | — | Service latency sampling rate. Not yet implemented. | -| `DEFAULT_SYSTEM_ACCOUNT = "$SYS"` | const.go:244 | MISSING | — | System account name constant. Not yet implemented. | -| `DEFAULT_GLOBAL_ACCOUNT = "$G"` | const.go:247 | MISSING | — | Global account name constant. Not yet implemented. | -| `DEFAULT_ACCOUNT_FETCH_TIMEOUT = 1900ms` | const.go:250 | MISSING | — | Account fetch timeout. Not yet implemented. | +| `DEFAULT_MAX_CLOSED_CLIENTS = 10000` | const.go:192 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:15`, `src/NATS.Server/NatsOptions.cs:89` | Added protocol default and wired closed-client ring size default in options. | +| `DEFAULT_LAME_DUCK_DURATION = 2min` | const.go:196 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:25`, `src/NATS.Server/NatsOptions.cs:59` | Added protocol default and wired lame-duck duration default in options. | +| `DEFAULT_LAME_DUCK_GRACE_PERIOD = 10s` | const.go:200 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:26`, `src/NATS.Server/NatsOptions.cs:60` | Added protocol default and wired lame-duck grace period default in options. | +| `DEFAULT_LEAFNODE_INFO_WAIT = 1s` | const.go:203 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:37` | Added `NatsProtocol.DefaultLeafNodeInfoWait` constant. | +| `DEFAULT_LEAFNODE_PORT = 7422` | const.go:206 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:18` | Added `NatsProtocol.DefaultLeafNodePort` constant. | +| `DEFAULT_CONNECT_ERROR_REPORTS = 3600` | const.go:214 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:16`, `src/NATS.Server/NatsOptions.cs:86` | Added protocol default and wired `ConnectErrorReports` default in options. | +| `DEFAULT_RECONNECT_ERROR_REPORTS = 1` | const.go:220 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:17`, `src/NATS.Server/NatsOptions.cs:87` | Added protocol default and wired `ReconnectErrorReports` default in options. | +| `DEFAULT_RTT_MEASUREMENT_INTERVAL = 1h` | const.go:224 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:38` | Added `NatsProtocol.DefaultRttMeasurementInterval` constant. | +| `DEFAULT_ALLOW_RESPONSE_MAX_MSGS = 1` | const.go:228 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:24` | Added `NatsProtocol.DefaultAllowResponseMaxMsgs` constant. | +| `DEFAULT_ALLOW_RESPONSE_EXPIRATION = 2min` | const.go:232 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:39` | Added `NatsProtocol.DefaultAllowResponseExpiration` constant. | +| `DEFAULT_SERVICE_EXPORT_RESPONSE_THRESHOLD = 2min` | const.go:237 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:40` | Added `NatsProtocol.DefaultServiceExportResponseThreshold` constant. | +| `DEFAULT_SERVICE_LATENCY_SAMPLING = 100` | const.go:241 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:25` | Added `NatsProtocol.DefaultServiceLatencySampling` constant. | +| `DEFAULT_SYSTEM_ACCOUNT = "$SYS"` | const.go:244 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:26`, `src/NATS.Server/Auth/Account.cs:10` | Added protocol-level constant; existing account model uses the same value (`$SYS`). | +| `DEFAULT_GLOBAL_ACCOUNT = "$G"` | const.go:247 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:27` | Added `NatsProtocol.DefaultGlobalAccount` constant. | +| `DEFAULT_ACCOUNT_FETCH_TIMEOUT = 1900ms` | const.go:250 | PORTED | `src/NATS.Server/Protocol/NatsProtocol.cs:41` | Added `NatsProtocol.DefaultAccountFetchTimeout` constant. | ### .NET-Only Additions (no Go counterpart in the three source files) @@ -231,7 +231,7 @@ After porting work is completed: | `TestParseConnect` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_CONNECT` | | `TestParseSub` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_SUB_without_queue`, `Parse_SUB_with_queue` | | `TestParsePub` | PARTIAL | `tests/NATS.Server.Tests/ParserTests.cs: Parse_PUB_with_payload`, `Parse_PUB_with_reply` — missing overflow payload error scenario | -| `TestParsePubSizeOverflow` | MISSING | No .NET test for integer overflow on very large size values (>9 digits handled by `ParseSize` returning -1, but no explicit overflow test) | +| `TestParsePubSizeOverflow` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_pub_size_overflow_fails` — explicit oversized PUB payload-size argument test now asserts parser rejection (`Invalid payload size`) | | `TestParsePubArg` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_PUB_argument_variations` (Theory) | | `TestParsePubBadSize` | PARTIAL | `tests/NATS.Server.Tests/ParserTests.cs: Parse_malformed_protocol_fails` covers some bad args; missing specific `mpay` (max payload per-client) test | | `TestParseHeaderPub` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_HPUB` | @@ -240,7 +240,7 @@ After porting work is completed: | `TestParseRouteMsg` (RMSG) | MISSING | No .NET equivalent — ROUTER RMSG parsing not yet ported | | `TestParseMsgSpace` | MISSING | No .NET equivalent — MSG opcode for routes not yet ported | | `TestShouldFail` | PARTIAL | `tests/NATS.Server.Tests/ParserTests.cs: Parse_malformed_protocol_fails` — covers subset; documented behavioral differences for byte-by-byte vs prefix-scan parser | -| `TestProtoSnippet` | MISSING | No .NET equivalent for `protoSnippet()` helper | +| `TestProtoSnippet` | PORTED | `tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs: ProtoSnippet_*` validates Go-style snippet behavior and parser error context wiring. | | `TestParseOK` | PORTED | `tests/NATS.Server.Tests/ParserTests.cs: Parse_case_insensitive` includes +OK (via `ParsedCommand.Simple`) | | `TestMaxControlLine` | PARTIAL | `tests/NATS.Server.Tests/ParserTests.cs: Parse_exceeding_max_control_line_fails` — covers basic enforcement; missing per-client-kind bypass (LEAF/ROUTER/GATEWAY exempt) | @@ -281,5 +281,8 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Added parser overflow parity test (`Parse_pub_size_overflow_fails`) and reclassified `TestParsePubSizeOverflow` from MISSING to PORTED. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: parser.go, proto.go, const.go; test cross-reference for all 5 Go test files | claude-sonnet-4-6 | +| 2026-02-25 | Executed protocol defaults parity batch: introduced missing const/default surfaces in `NatsProtocol`, wired `NatsOptions` and accept-loop defaults, added targeted tests (`ProtocolDefaultConstantsGapParityTests`), and reclassified 16 const.go rows from MISSING to PORTED | codex | +| 2026-02-25 | Executed protocol proto-wire parity batch: added `ProtoWire` scanners/encoder and parity tests (`ProtoWireParityTests`), and reclassified all 9 `proto.go` rows from MISSING to PORTED | codex | diff --git a/gaps/raft.md b/gaps/raft.md index 6da8c1b..5ff9f9d 100644 --- a/gaps/raft.md +++ b/gaps/raft.md @@ -91,12 +91,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | RaftNode (interface) | raft.go:40-92 | PARTIAL | src/NATS.Server/Raft/IRaftNode.cs:5 | Interface declared but empty — none of the 40+ methods from Go are defined | | RaftNodeCheckpoint (interface) | raft.go:98-103 | PARTIAL | src/NATS.Server/Raft/RaftSnapshotCheckpoint.cs:7 | Chunk assembly exists but LoadLastSnapshot, AppendEntriesSeq, Abort, InstallSnapshot not matching Go's interface contract | | WAL (interface) | raft.go:105-118 | PARTIAL | src/NATS.Server/Raft/RaftWal.cs:20 | .NET RaftWal is a concrete file-based WAL; does not implement Go's WAL interface (StoreMsg, LoadMsg, RemoveMsg, Compact, Purge, Truncate, State, FastState, Stop, Delete) | -| Peer (struct) | raft.go:120-125 | PARTIAL | src/NATS.Server/Raft/RaftPeerState.cs:7 | .NET has NextIndex/MatchIndex/LastContact/Active but missing Lag and Current fields from Go's Peer export | +| Peer (struct) | raft.go:120-125 | PORTED | src/NATS.Server/Raft/RaftPeerState.cs | Added missing parity fields (`Lag`, `Current`) plus helpers to recalculate lag and refresh current-state from heartbeat/contact timing | | RaftState (enum) | raft.go:127-135 | PORTED | src/NATS.Server/Raft/RaftState.cs:4 | All four states: Follower, Leader, Candidate, Closed | -| RaftState.String() | raft.go:137-149 | MISSING | — | No .NET ToString override for RaftState enum | -| RaftConfig (struct) | raft.go:301-317 | MISSING | — | No equivalent configuration struct (Name, Store, Log, Track, Observer, Recovering, ScaleUp) | -| CommittedEntry (struct) | raft.go:2506-2509 | PARTIAL | src/NATS.Server/Raft/CommitQueue.cs:9 | CommitQueue exists as channel wrapper, but no CommittedEntry struct with Index+Entries | -| Entry (struct) | raft.go:2641-2644 | PARTIAL | src/NATS.Server/Raft/RaftWireFormat.cs:73 | RaftEntryWire has Type+Data but is wire-format only; no general Entry type used for proposals | +| RaftState.String() | raft.go:137-149 | PORTED | src/NATS.Server/Raft/RaftStateExtensions.cs:9 | Added Go-style `RaftState.String()` extension mapping to Follower/Leader/Candidate/Closed | +| RaftConfig (struct) | raft.go:301-317 | PORTED | src/NATS.Server/Raft/RaftConfig.cs:8 | Added `RaftConfig` model with Name/Store/Log/Track/Observer/Recovering/ScaleUp fields for parity shape | +| CommittedEntry (struct) | raft.go:2506-2509 | PORTED | src/NATS.Server/Raft/CommitQueue.cs (`CommittedEntry`) | Added explicit committed-entry shape with `Index` and `Entries` list for parity with Go commit delivery payload | +| Entry (struct) | raft.go:2641-2644 | PORTED | src/NATS.Server/Raft/RaftEntry.cs | Added general `RaftEntry` model (`Type`, `Data`) with conversion helpers to/from wire-entry shape | | EntryType (enum) | raft.go:2605-2619 | PORTED | src/NATS.Server/Raft/RaftWireFormat.cs:54-63 | All types present including EntryCatchup (mapped as RaftEntryType) | ### golang/nats-server/server/raft.go — Exported RaftNode Interface Methods @@ -104,53 +104,53 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | Propose() | raft.go:909-924 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:308 | ProposeAsync exists but synchronous replication model, no write-error checking, no proposal queue | -| ProposeMulti() | raft.go:928-945 | MISSING | — | No batch proposal support | +| ProposeMulti() | raft.go:928-945 | PORTED | src/NATS.Server/Raft/RaftNode.cs (`ProposeMultiAsync`) | Added ordered multi-command proposal API returning committed indexes per input command | | ForwardProposal() | raft.go:949-959 | PARTIAL | src/NATS.Server/Raft/NatsRaftTransport.cs:182 | Transport has ForwardProposal but RaftNode does not call it automatically for non-leaders | | InstallSnapshot() | raft.go:1295-1311 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:699 | InstallSnapshotAsync exists but no checkpointing, no WAL compaction, no highwayhash verification | | CreateSnapshotCheckpoint() | raft.go:1356-1360 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:519 | CreateSnapshotCheckpointAsync exists but simplified — no async write, no WAL compaction tracking | | SendSnapshot() | raft.go:1284-1290 | MISSING | — | No direct snapshot send as append entry | | NeedSnapshot() | raft.go:1551-1555 | MISSING | — | No equivalent check | -| Applied() | raft.go:1183-1185 | MISSING | — | No callback from upper layer for applied index tracking (delegates to Processed) | +| Applied() | raft.go:1183-1185 | PORTED | src/NATS.Server/Raft/RaftNode.cs:403 | Added `Applied(long)` callback returning `(entries,bytes)` and delegating progress to processed tracking | | Processed() | raft.go:1193-1240 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:664 | MarkProcessed exists but much simpler — no aflr signaling, no leader state transition, no byte estimation | | State() | raft.go:2025-2027 | PORTED | src/NATS.Server/Raft/RaftNode.cs:43 | Role property (uses RaftRole enum instead of RaftState) | -| Size() | raft.go:2037-2043 | MISSING | — | No WAL size reporting | -| Progress() | raft.go:2030-2034 | MISSING | — | No combined (index, commit, applied) return | +| Size() | raft.go:2037-2043 | PORTED | src/NATS.Server/Raft/RaftNode.cs:771 | Added `Size()` accessor returning entry count and UTF-8 command-byte estimate from current log contents | +| Progress() | raft.go:2030-2034 | PORTED | src/NATS.Server/Raft/RaftNode.cs:765 | Added `Progress()` accessor returning `(index, commit, applied)` | | Leader() | raft.go:1712-1717 | PORTED | src/NATS.Server/Raft/RaftNode.cs:42 | IsLeader property | -| LeaderSince() | raft.go:1721-1726 | MISSING | — | No leader-since timestamp tracking | +| LeaderSince() | raft.go:1721-1726 | PORTED | src/NATS.Server/Raft/RaftNode.cs:63 | Added nullable `LeaderSince` timestamp and leadership transition updates | | Quorum() | raft.go:3070-3083 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:201 | HasQuorum() exists but uses different window calculation (2x electionTimeout vs Go's lostQuorumInterval) | | Current() | raft.go:1840-1847 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:879 | IsCurrent exists but no commit==applied check, no forward-progress polling | | Healthy() | raft.go:1850-1857 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:892 | IsHealthy exists but different semantics — checks peer responsiveness, not isCurrent(true) | | Term() | raft.go:3119-3123 | PORTED | src/NATS.Server/Raft/RaftNode.cs:41 | Term property | -| Leaderless() | raft.go:1876-1883 | MISSING | — | No atomic hasleader flag | -| GroupLeader() | raft.go:1865-1872 | MISSING | — | No leader ID tracking (only IsLeader bool) | -| HadPreviousLeader() | raft.go:1860-1862 | MISSING | — | No pleader atomic flag | +| Leaderless() | raft.go:1876-1883 | PORTED | src/NATS.Server/Raft/RaftNode.cs:65 | Added lock-free `Leaderless` derived from tracked group leader state | +| GroupLeader() | raft.go:1865-1872 | PORTED | src/NATS.Server/Raft/RaftNode.cs:64 | Added `GroupLeader` tracking, updated on election/heartbeat/stepdown | +| HadPreviousLeader() | raft.go:1860-1862 | PORTED | src/NATS.Server/Raft/RaftNode.cs:66 | Added `HadPreviousLeader` flag that remains true after first observed leader | | StepDown() | raft.go:1900-1977 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:706 | RequestStepDown exists but no preferred leader selection, no leader transfer, no EntryLeaderTransfer | -| SetObserver() | raft.go:2394-2396 | MISSING | — | No observer mode | -| IsObserver() | raft.go:2387-2391 | MISSING | — | No observer mode | +| SetObserver() | raft.go:2394-2396 | PORTED | src/NATS.Server/Raft/RaftNode.cs:799 | Added `SetObserver(bool)` toggle for observer mode | +| IsObserver() | raft.go:2387-2391 | PORTED | src/NATS.Server/Raft/RaftNode.cs:68 | Added `IsObserver` accessor | | Campaign() | raft.go:1980-1984 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:771 | CampaignImmediately exists but no random campaign timeout | | CampaignImmediately() | raft.go:1987-1993 | PORTED | src/NATS.Server/Raft/RaftNode.cs:771 | CampaignImmediately() | | ID() | raft.go:2045-2051 | PORTED | src/NATS.Server/Raft/RaftNode.cs:40 | Id property | -| Group() | raft.go:2053-2056 | MISSING | — | No group name tracking | +| Group() | raft.go:2053-2056 | PORTED | src/NATS.Server/Raft/RaftNode.cs:42 | Added `GroupName` tracking on `RaftNode` with constructor support; defaults to node ID when unspecified | | Peers() | raft.go:2058-2077 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:872 | GetPeerStates returns Dict but missing Lag calculation | | ProposeKnownPeers() | raft.go:2080-2089 | MISSING | — | No peer state broadcast | | UpdateKnownPeers() | raft.go:2092-2096 | MISSING | — | No peer state update | | ProposeAddPeer() | raft.go:962-983 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:372 | ProposeAddPeerAsync exists but synchronous replication, no forwarding to leader | | ProposeRemovePeer() | raft.go:986-1019 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:415 | ProposeRemovePeerAsync exists but no forwarding, no self-removal handling | | MembershipChangeInProgress() | raft.go:1021-1025 | PORTED | src/NATS.Server/Raft/RaftNode.cs:67 | MembershipChangeInProgress property | -| AdjustClusterSize() | raft.go:1059-1079 | MISSING | — | No cluster size adjustment | -| AdjustBootClusterSize() | raft.go:1038-1055 | MISSING | — | No boot cluster size adjustment | -| ClusterSize() | raft.go:1029-1033 | MISSING | — | No explicit cluster size property | +| AdjustClusterSize() | raft.go:1059-1079 | PORTED | src/NATS.Server/Raft/RaftNode.cs:790 | Added leader-gated cluster-size adjustment with Go floor behavior (`min 2`) | +| AdjustBootClusterSize() | raft.go:1038-1055 | PORTED | src/NATS.Server/Raft/RaftNode.cs:781 | Added boot-time cluster-size adjustment gated on no current/previous leader | +| ClusterSize() | raft.go:1029-1033 | PORTED | src/NATS.Server/Raft/RaftNode.cs:778 | Added explicit `ClusterSize()` accessor | | ApplyQ() | raft.go:2106 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:53 | CommitQueue exists as Channel-based queue, different API than ipQueue | | PauseApply() | raft.go:1084-1092 | MISSING | — | No apply pausing | | ResumeApply() | raft.go:1111-1156 | MISSING | — | No apply resuming with replay | | DrainAndReplaySnapshot() | raft.go:1162-1177 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:537 | DrainAndReplaySnapshotAsync exists but simplified — no catchup cancellation, no commit preservation | | LeadChangeC() | raft.go:2110 | MISSING | — | No leader change channel | | QuitC() | raft.go:2113 | MISSING | — | No quit channel | -| Created() | raft.go:2115-2118 | MISSING | — | No creation timestamp | -| Stop() | raft.go:2120-2122 | MISSING | — | No graceful shutdown (Dispose exists but minimal) | -| WaitForStop() | raft.go:2124-2128 | MISSING | — | No wait-for-stop mechanism | -| Delete() | raft.go:2130-2143 | MISSING | — | No delete with WAL cleanup | -| IsDeleted() | raft.go:2145-2149 | MISSING | — | No deleted flag | +| Created() | raft.go:2115-2118 | PORTED | src/NATS.Server/Raft/RaftNode.cs:43 | Added `CreatedUtc` timestamp captured at node construction and exposed for runtime introspection | +| Stop() | raft.go:2120-2122 | PORTED | src/NATS.Server/Raft/RaftNode.cs:1095 | Added `Stop()` lifecycle API that transitions to follower, clears leader markers, and signals stop waiters | +| WaitForStop() | raft.go:2124-2128 | PORTED | src/NATS.Server/Raft/RaftNode.cs:1104 | Added synchronous `WaitForStop()` backed by stop completion signal | +| Delete() | raft.go:2130-2143 | PORTED | src/NATS.Server/Raft/RaftNode.cs:1109 | Added `Delete()` lifecycle API that stops node, marks deleted, and removes persisted raft directory when configured | +| IsDeleted() | raft.go:2145-2149 | PORTED | src/NATS.Server/Raft/RaftNode.cs:69 | Added `IsDeleted` state accessor | | RecreateInternalSubs() | raft.go:658-747 | MISSING | — | No NATS internal subscription management | | IsSystemAccount() | raft.go:648-649 | NOT_APPLICABLE | — | .NET does not have system account NRG routing | | GetTrafficAccountName() | raft.go:652-656 | NOT_APPLICABLE | — | .NET does not have account NRG routing | @@ -224,7 +224,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | selectNextLeader() | raft.go:1887-1897 | MISSING | — | No next-leader selection by highest index | | resetElectionTimeout() | raft.go:2241-2243 | PORTED | src/NATS.Server/Raft/RaftNode.cs:737 | ResetElectionTimeout with Timer | | randElectionTimeout() | raft.go:2235-2238 | PORTED | src/NATS.Server/Raft/RaftNode.cs:727 | RandomizedElectionTimeout | -| randCampaignTimeout() | raft.go:1995-1998 | MISSING | — | No separate campaign timeout | +| randCampaignTimeout() | raft.go:1995-1998 | PORTED | src/NATS.Server/Raft/RaftNode.cs:822 | Added `RandomizedCampaignTimeout()` using Go-equivalent 100-800ms jitter window | ### golang/nats-server/server/raft.go — Wire Format & RPC @@ -350,12 +350,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| | Election timeout defaults | raft.go:277-287 | PARTIAL | src/NATS.Server/Raft/RaftNode.cs:56-57 | .NET uses 150-300ms defaults; Go uses 4-9s defaults. Different design choice | -| hbInterval | raft.go:283 | MISSING | — | No heartbeat interval constant | -| lostQuorumInterval | raft.go:284 | MISSING | — | No lost quorum interval | -| observerModeInterval | raft.go:286 | MISSING | — | No observer mode interval | -| peerRemoveTimeout | raft.go:287 | MISSING | — | No peer remove timeout | +| hbInterval | raft.go:283 | PORTED | src/NATS.Server/Raft/RaftNode.cs:10 | Added `HbIntervalDefault = 1s` constant | +| lostQuorumInterval | raft.go:284 | PORTED | src/NATS.Server/Raft/RaftNode.cs:11 | Added `LostQuorumIntervalDefault = 10s` constant | +| observerModeInterval | raft.go:286 | PORTED | src/NATS.Server/Raft/RaftNode.cs:12 | Added `ObserverModeIntervalDefault = 48h` constant | +| peerRemoveTimeout | raft.go:287 | PORTED | src/NATS.Server/Raft/RaftNode.cs:13 | Added `PeerRemoveTimeoutDefault = 5m` constant | | Error sentinels | raft.go:319-343 | PARTIAL | — | .NET uses InvalidOperationException instead of typed error sentinels | -| noLeader / noVote constants | raft.go:4954-4956 | MISSING | — | No explicit no-leader/no-vote constants | +| noLeader / noVote constants | raft.go:4954-4956 | PORTED | src/NATS.Server/Raft/RaftNode.cs:5 | Added explicit `NoLeader` / `NoVote` empty-string constants | | paeDropThreshold / paeWarnThreshold | raft.go:4399-4401 | MISSING | — | No pending append entry limits | | maxBatch / maxEntries | raft.go:3004-3005 | MISSING | — | No proposal batching thresholds | | extensionState | raft.go:4462-4468 | MISSING | — | No domain extension state tracking | @@ -387,3 +387,5 @@ After porting work is completed: |------|--------|----| | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap analysis completed: 196 items inventoried across 12 categories. Summary: 46 PORTED, 38 PARTIAL, 99 MISSING, 13 NOT_APPLICABLE, 0 DEFERRED. Wire format is well-ported; core state machine (run loop, catchup, WAL integration) is largely missing. | claude-opus | +| 2026-02-25 | Ported RAFT API/lifecycle parity batch: LeaderSince/GroupLeader/Leaderless/HadPreviousLeader, observer toggles, Progress/Size/Applied, cluster-size adjustors, stop/delete APIs, campaign timeout jitter, and core timing/leader constants with targeted tests in `RaftNodeParityBatch2Tests`. | codex | +| 2026-02-26 | Ported RAFT parity batch: added `ProposeMultiAsync`, peer parity fields (`Lag`, `Current`) + refresh helpers, explicit `CommittedEntry` payload type, and general `RaftEntry` model/wire conversion helpers with focused tests in `RaftParityBatch3Tests`. | codex | diff --git a/gaps/routes.md b/gaps/routes.md index 0fac506..66e4ee7 100644 --- a/gaps/routes.md +++ b/gaps/routes.md @@ -92,12 +92,12 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `RouteType` (type alias + consts `Implicit`/`Explicit`) | route.go:36–44 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs` — implicit/explicit distinction tracked in `RouteConnection` handshake and `RouteManager.ConnectToRouteWithRetryAsync` | No explicit enum; Implicit/Explicit distinction is encoded in how routes are established (solicited vs inbound) | | `route` struct (unexported) | route.go:56–94 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:8` | Fields `remoteID`, `poolIdx`, `accName`, `noPool`, `compression`, `gossipMode` are present. Fields for `lnoc`, `lnocu`, `jetstream`, `connectURLs`, `wsConnURLs`, `gatewayURL`, `leafnodeURL`, `hash`, `idHash`, `startNewRoute`, `retry` are MISSING — not modelled in .NET | | `routeInfo` struct (unexported) | route.go:97–101 | MISSING | — | Used internally for deferred pool-connection creation after first PONG; no .NET equivalent | -| `gossipDefault`/`gossipDisabled`/`gossipOverride` consts | route.go:104–108 | MISSING | — | Gossip mode bytes used in INFO propagation; not implemented in .NET | -| `connectInfo` struct | route.go:110–124 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:328` (`BuildConnectInfoJson`) | .NET builds a simplified JSON payload; `connectInfo` fields Echo, Verbose, Pedantic, TLS, Headers, Cluster, Dynamic, LNOC, LNOCU are all MISSING from the .NET payload | -| `ConProto`/`InfoProto` protocol format strings | route.go:127–130 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:73,83` | Handshake uses a simplified `ROUTE ` format rather than `CONNECT ` / `INFO ` | +| `gossipDefault`/`gossipDisabled`/`gossipOverride` consts | route.go:104–108 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:17–19` | Gossip mode constants are defined as byte constants (`GossipDefault`, `GossipDisabled`, `GossipOverride`) | +| `connectInfo` struct | route.go:110–124 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:378` (`BuildConnectInfoJson`) | Connect payload now includes parity fields: `echo`, `verbose`, `pedantic`, `tls_required`, `headers`, `cluster`, `dynamic`, `lnoc`, `lnocu` | +| `ConProto`/`InfoProto` protocol format strings | route.go:127–130 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:11–12,83–95` | CONNECT/INFO format constants added, but active wire handshake remains simplified `ROUTE ` rather than full CONNECT/INFO exchange | | `clusterTLSInsecureWarning` const | route.go:134 | NOT_APPLICABLE | — | TLS not yet implemented in .NET port; warning string has no counterpart | -| `defaultRouteMaxPingInterval` const | route.go:140 | MISSING | — | Ping interval management for compression RTT auto-mode not implemented | -| `routeConnectDelay`/`routeConnectMaxDelay`/`routeMaxPingInterval` vars | route.go:145–148 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:486` (250ms hardcoded delay) | .NET hardcodes 250ms retry delay; Go uses configurable `DEFAULT_ROUTE_CONNECT` with exponential backoff | +| `defaultRouteMaxPingInterval` const | route.go:140 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:20` | `DefaultRouteMaxPingInterval` constant added | +| `routeConnectDelay`/`routeConnectMaxDelay`/`routeMaxPingInterval` vars | route.go:145–148 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:14–15,20,751–757` | Route reconnect delay now uses bounded exponential backoff (`ComputeRetryDelay`) with dedicated delay/max constants; still not runtime-configurable from route config | | `(c *client) removeReplySub` | route.go:151 | MISSING | — | Reply-sub cleanup for remote reply subs not implemented | | `(c *client) processAccountSub` | route.go:167 | NOT_APPLICABLE | — | Gateway-only path; gateway sub interest not in routes module | | `(c *client) processAccountUnsub` | route.go:174 | NOT_APPLICABLE | — | Gateway-only path | @@ -110,48 +110,48 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `(c *client) processRouteInfo` | route.go:549 | MISSING | — | Full INFO processing (cluster name negotiation, compression negotiation, duplicate detection, account route setup) not implemented; .NET handshake is a simple ID exchange | | `(s *Server) negotiateRouteCompression` | route.go:897 | PARTIAL | `src/NATS.Server/Routes/RouteCompressionCodec.cs:82` (`NegotiateCompression`) | .NET has the negotiation logic; but integration into handshake (INFO exchange, switching compression writer/reader mid-stream) is MISSING | | `(s *Server) updateRemoteRoutePerms` | route.go:953 | MISSING | — | Route permission update on INFO reload not implemented | -| `(s *Server) sendAsyncInfoToClients` | route.go:1015 | MISSING | — | Async INFO broadcast to connected clients not implemented | -| `(s *Server) processImplicitRoute` | route.go:1043 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:107` (`ProcessImplicitRoute`) | .NET collects discovered URLs; missing: duplicate-ID check, pinned-account re-solicitation, `hasThisRouteConfigured` guard | -| `(s *Server) hasThisRouteConfigured` | route.go:1104 | MISSING | — | Check whether incoming gossip URL is already a configured explicit route; not implemented | +| `(s *Server) sendAsyncInfoToClients` | route.go:1015 | PORTED | `src/NATS.Server/NatsServer.cs:181` | Added `UpdateServerINFOAndSendINFOToClients()` INFO refresh/broadcast path to connected clients; validated with targeted socket-level parity test. | +| `(s *Server) processImplicitRoute` | route.go:1043 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:115` (`ProcessImplicitRoute`) | Now guards discovered URLs with `HasThisRouteConfigured` and normalized duplicate checks; pinned-account re-solicitation and duplicate-ID handling remain missing | +| `(s *Server) hasThisRouteConfigured` | route.go:1104 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:164` | Implemented with normalized URL matching against explicit configured routes and known route URLs | | `(s *Server) forwardNewRouteInfoToKnownServers` | route.go:1139 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:127` (`ForwardNewRouteInfoToKnownServers`) | .NET raises an event with the new peer URL; missing: gossip mode logic (`gossipDefault`/`gossipDisabled`/`gossipOverride`), pinned-account route filtering, serialized INFO JSON sending | | `(c *client) canImport` | route.go:1226 | MISSING | — | Route import permission check not implemented | | `(c *client) canExport` | route.go:1235 | MISSING | — | Route export permission check not implemented | | `(c *client) setRoutePermissions` | route.go:1244 | MISSING | — | Route permission mapping (Import→Publish, Export→Subscribe) not implemented | | `asubs` struct | route.go:1263 | NOT_APPLICABLE | — | Internal Go helper to group subscriptions by account during cleanup; .NET uses LINQ equivalents | -| `getAccNameFromRoutedSubKey` | route.go:1273 | MISSING | — | Sub key parsing for account name extraction not implemented | -| `(c *client) getRoutedSubKeyInfo` | route.go:1290 | MISSING | — | Helper to determine account/key info for a route's subscriptions; not implemented | -| `(c *client) removeRemoteSubs` | route.go:1299 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:577` (`RemoveRoute`) | .NET removes the route connection but does NOT remove individual remote subscriptions from the SubList on close | -| `(c *client) removeRemoteSubsForAcc` | route.go:1352 | MISSING | — | Per-account remote sub removal for dedicated route transition not implemented | -| `(c *client) parseUnsubProto` | route.go:1366 | MISSING | — | RS-/LS- protocol arg parser not implemented; .NET `ReadFramesAsync` only extracts account/subject/queue loosely | +| `getAccNameFromRoutedSubKey` | route.go:1273 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:176` (`GetAccNameFromRoutedSubKey`) | Routed-sub key parsing helper added and validated with parity tests | +| `(c *client) getRoutedSubKeyInfo` | route.go:1290 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:179` (`GetRoutedSubKeyInfo`) | Routed-sub key decomposition helper added (route/account/subject/queue) and covered by tests | +| `(c *client) removeRemoteSubs` | route.go:1299 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:195` (`RemoveRemoteSubs`), `src/NATS.Server/Routes/RouteManager.cs:593` (`WatchRouteAsync`), `src/NATS.Server/NatsServer.cs:960` (`RemoveRemoteSubscriptionsForRoute`) | Route close/removal now triggers remote-sub cleanup from account SubLists when the last connection for a remote server is gone | +| `(c *client) removeRemoteSubsForAcc` | route.go:1352 | PARTIAL | `src/NATS.Server/Subscriptions/SubList.cs:229` (`RemoveRemoteSubsForAccount`), `src/NATS.Server/Routes/RouteManager.cs:286` (`UnregisterAccountRoute`), `src/NATS.Server/NatsServer.cs:966` (`RemoveRemoteSubscriptionsForRouteAccount`) | Per-account cleanup path is now wired on dedicated-route unregistration; full dedicated-route transition parity remains incomplete | +| `(c *client) parseUnsubProto` | route.go:1366 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:357` (`TryParseRemoteUnsub`) | Dedicated RS-/LS- parser now handles account/subject/optional-queue extraction and is used by frame processing | | `(c *client) processRemoteUnsub` | route.go:1404 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:177–185` | .NET fires `RemoteSubscriptionReceived` with `IsRemoval=true`; missing: sub key lookup and removal from SubList, gateway/leafnode interest updates | | `(c *client) processRemoteSub` | route.go:1489 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:167–175` | .NET fires `RemoteSubscriptionReceived`; missing: key construction with type byte prefix, account lookup/creation, permission check (`canExport`), SubList insertion, gateway/leafnode updates, queue-weight delta tracking | -| `(c *client) addRouteSubOrUnsubProtoToBuf` | route.go:1729 | PARTIAL | `src/NATS.Server/Routes/RouteConnection.cs:95–109` (`SendRsPlusAsync`/`SendRsMinusAsync`) | .NET sends RS+/RS- with account and optional queue; missing: LS+/LS- variant for leaf origin clusters, queue weight field in RS+ | +| `(c *client) addRouteSubOrUnsubProtoToBuf` | route.go:1729 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:179` (`SendRouteSubOrUnSubProtosAsync`) | Added low-level buffered route-proto sender that batches RS+/RS-/LS+/LS- control lines into a single write/flush. | | `(s *Server) sendSubsToRoute` | route.go:1781 | MISSING | — | Bulk send of local subscription interest to newly connected route not implemented; .NET only propagates incremental sub/unsub | -| `(c *client) sendRouteSubProtos` | route.go:1881 | MISSING | — | Batch RS+ send not implemented | -| `(c *client) sendRouteUnSubProtos` | route.go:1890 | MISSING | — | Batch RS- send not implemented | -| `(c *client) sendRouteSubOrUnSubProtos` | route.go:1898 | MISSING | — | Low-level batch RS+/RS-/LS+/LS- sender not implemented | +| `(c *client) sendRouteSubProtos` | route.go:1881 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:146` (`SendRouteSubProtosAsync`) | Added batched RS+ emission from remote-subscription models with queue/weight support. | +| `(c *client) sendRouteUnSubProtos` | route.go:1890 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:165` (`SendRouteUnSubProtosAsync`) | Added batched RS- emission from remote-subscription models. | +| `(c *client) sendRouteSubOrUnSubProtos` | route.go:1898 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:179` (`SendRouteSubOrUnSubProtosAsync`) | Added low-level batch sender for arbitrary route sub/unsub protocol lines. | | `(s *Server) createRoute` | route.go:1935 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:447,462` (`HandleInboundRouteAsync`/`ConnectToRouteWithRetryAsync`) | .NET creates a RouteConnection and performs handshake; missing: TLS setup, auth timeout timer, CONNECT protocol sending, INFO JSON sending, compression negotiation, ping timer | | `routeShouldDelayInfo` | route.go:2082 | MISSING | — | Logic to delay initial INFO until pool connection auth is confirmed not implemented | | `(s *Server) generateRouteInitialInfoJSON` | route.go:2090 | MISSING | — | Route INFO JSON generation (with nonce, pool index, gossip mode, compression) not implemented | | `(s *Server) addRoute` | route.go:2113 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:496` (`Register`) | .NET registers route in dictionary; missing: pool index management, duplicate detection with `handleDuplicateRoute`, per-account route registration in `accRoutes`, `sendSubsToRoute` call, gateway/leafnode URL propagation, `forwardNewRouteInfoToKnownServers` | -| `hasSolicitedRoute` | route.go:2438 | MISSING | — | Helper to find a solicited route in a pool slice; not implemented | -| `upgradeRouteToSolicited` | route.go:2458 | MISSING | — | Upgrade an inbound route to solicited status; not implemented | +| `hasSolicitedRoute` | route.go:2438 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:721` | Implemented helper to query whether a given remote server currently has a solicited route | +| `upgradeRouteToSolicited` | route.go:2458 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:730` | Implemented route upgrade helper to flip an existing route into solicited mode | | `handleDuplicateRoute` | route.go:2473 | MISSING | — | Duplicate route resolution (close extra connection, preserve retry flag) not implemented | | `(c *client) importFilter` | route.go:2510 | MISSING | — | Permission-based subscription filter for sending to routes not implemented | | `(s *Server) updateRouteSubscriptionMap` | route.go:2519 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:381,392` (`PropagateLocalSubscription`/`PropagateLocalUnsubscription`) | .NET broadcasts RS+/RS- to all routes; missing: account routePoolIdx-based routing, queue-weight dedup (`sqmu`/`lqws`), no-pool route handling, gateway/leafnode interest updates | | `(s *Server) startRouteAcceptLoop` | route.go:2696 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:333` (`StartAsync`) | .NET binds and starts accept loop, solicits configured routes; missing: cluster name logging, TLS config on accept, routeInfo construction, advertise/NoAdvertise, LeafNode/Gateway URL propagation | | `(s *Server) setRouteInfoHostPortAndIP` | route.go:2829 | MISSING | — | Route INFO host/port/IP with Cluster.Advertise support not implemented | | `(s *Server) StartRouting` | route.go:2849 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:333` (`StartAsync`) | Functionally equivalent: starts accept loop and solicits routes | -| `(s *Server) reConnectToRoute` | route.go:2861 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:462` (`ConnectToRouteWithRetryAsync`) | .NET retries indefinitely with 250ms delay; missing: random jitter delay, explicit vs implicit distinction affecting delay, quit-channel integration | -| `(s *Server) routeStillValid` | route.go:2881 | MISSING | — | Check that a route URL is still in configured routes list (for reconnect guard) not implemented | -| `(s *Server) connectToRoute` | route.go:2890 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:462` (`ConnectToRouteWithRetryAsync`) | .NET connects and retries; missing: explicit/implicit distinction, ConnectRetries limit, exponential backoff (`ConnectBackoff`), `routesToSelf` exclusion, address randomization from DNS | -| `(c *client) isSolicitedRoute` | route.go:2976 | MISSING | — | Helper predicate; not implemented | +| `(s *Server) reConnectToRoute` | route.go:2861 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:512` (`ConnectToRouteWithRetryAsync`) | Retry loop now uses bounded exponential backoff and route validity guard; jitter, explicit/implicit-specific delay behavior, and quit-channel parity remain missing | +| `(s *Server) routeStillValid` | route.go:2881 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:180` | Implemented reconnect guard that validates configured and discovered route URLs using normalized comparisons | +| `(s *Server) connectToRoute` | route.go:2890 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:512` (`ConnectToRouteWithRetryAsync`) | Exponential backoff and route-validity checks are implemented; ConnectRetries/ConnectBackoff config parity, routes-to-self exclusion, and DNS randomization are still missing | +| `(c *client) isSolicitedRoute` | route.go:2976 | PORTED | `src/NATS.Server/Routes/RouteConnection.cs:35,370` | `IsSolicited` state is tracked on route connections and exposed via `IsSolicitedRoute()` helper | | `(s *Server) saveRouteTLSName` | route.go:2985 | NOT_APPLICABLE | — | TLS not yet implemented in .NET port | | `(s *Server) solicitRoutes` | route.go:2996 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:347–354` | .NET solicits configured routes with pool connections; missing: per-account (pinned) route solicitation, `saveRouteTLSName` | | `(c *client) processRouteConnect` | route.go:3011 | MISSING | — | Parsing and validation of inbound CONNECT from route (cluster name check, wrong-port detection, LNOC/LNOCU flags) not implemented; .NET uses a simpler handshake | | `(s *Server) removeAllRoutesExcept` | route.go:3085 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:602` (`RemoveAllRoutesExcept`) | Equivalent behavior: remove all routes not in the keep-set | -| `(s *Server) removeRoute` | route.go:3113 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:577` (`RemoveRoute`) | .NET removes from `_routes` dict; missing: per-account route cleanup (`accRoutes`), hash removal, gateway/leafnode URL withdrawal, noPool counter, reconnect-after-noPool logic | -| `(s *Server) isDuplicateServerName` | route.go:3233 | MISSING | — | Duplicate server name detection across routes not implemented | +| `(s *Server) removeRoute` | route.go:3113 | PARTIAL | `src/NATS.Server/Routes/RouteManager.cs:632` (`RemoveRoute`) | Remove path now also cleans hash index and per-account route mappings tied to removed connections; gateway/leafnode URL withdrawal, noPool counters, and reconnect-after-noPool logic remain missing | +| `(s *Server) isDuplicateServerName` | route.go:3233 | PORTED | `src/NATS.Server/Routes/RouteManager.cs:748` | Duplicate server-name detection helper implemented against current connected-server ID set | | `(s *Server) forEachNonPerAccountRoute` | route.go:3263 | NOT_APPLICABLE | — | Internal Go iterator over route slice; .NET uses `_routes.Values` LINQ directly | | `(s *Server) forEachRoute` | route.go:3277 | NOT_APPLICABLE | — | Internal Go iterator; .NET enumerates `_routes` and `_accountRoutes` directly | | `(s *Server) forEachRouteIdx` | route.go:3292 | NOT_APPLICABLE | — | Internal Go pool-index iterator; .NET `ComputeRoutePoolIdx` achieves equivalent selection | @@ -182,5 +182,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Ported async INFO broadcast parity slice: wired/validated `UpdateServerINFOAndSendINFOToClients()` as the `sendAsyncInfoToClients` equivalent and added targeted socket broadcast test (`RouteInfoBroadcastParityBatch4Tests`). | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: 57 Go symbols classified across route.go (3,314 lines). Counts: PORTED 4, PARTIAL 21, MISSING 23, NOT_APPLICABLE 9, DEFERRED 0 | auto | +| 2026-02-25 | Ported route parity helper batch: gossip/default constants, connect-info parity fields, configured-route/reconnect guard helpers, solicited-route helpers, duplicate-server-name detection, RS-/LS- parser, and LS+/LS- + queue-weight wire helpers; updated row statuses and notes | codex | +| 2026-02-25 | Ported routed-sub key helpers and remote-sub cleanup parity batch: added `getAccNameFromRoutedSubKey`/`getRoutedSubKeyInfo` equivalents plus route-close and per-account cleanup plumbing with targeted tests | codex | +| 2026-02-25 | Ported route batch-proto parity batch: added buffered batch sender APIs (`SendRouteSubProtosAsync`, `SendRouteUnSubProtosAsync`, `SendRouteSubOrUnSubProtosAsync`) for RS+/RS-/LS+/LS- protocol frames with targeted tests (`RouteBatchProtoParityBatch3Tests`) | codex | diff --git a/gaps/subscriptions.md b/gaps/subscriptions.md index 2e887e2..bc75eaa 100644 --- a/gaps/subscriptions.md +++ b/gaps/subscriptions.md @@ -100,14 +100,14 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `level` (struct) | sublist.go:96 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:967` (private `TrieLevel`) | Inner class; `nodes`, `pwc`, `fwc` all present | | `newNode()` | sublist.go:102 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:974` | Inline `new TrieNode()` | | `newLevel()` | sublist.go:107 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:967` | Inline `new TrieLevel()` | -| `NewSublist(bool)` | sublist.go:117 | PARTIAL | `src/NATS.Server/Subscriptions/SubList.cs:11` | .NET `SubList` always starts with cache; no `enableCache` constructor param | +| `NewSublist(bool)` | sublist.go:117 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:40` | Added `SubList(bool enableCache)` constructor to explicitly control cache behavior | | `NewSublistWithCache()` | sublist.go:125 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:11` | Default `new SubList()` has cache enabled | -| `NewSublistNoCache()` | sublist.go:130 | MISSING | — | No .NET equivalent; SubList always caches | -| `CacheEnabled()` | sublist.go:135 | MISSING | — | No public method to query whether cache is on; `CacheCount` exists but different semantics | -| `RegisterNotification()` | sublist.go:149 | MISSING | — | Go sends `true/false` on channel when first/last interest added/removed; .NET uses `InterestChanged` event which fires on every change but doesn't replicate channel-based deferred notify semantics | -| `RegisterQueueNotification()` | sublist.go:153 | MISSING | — | Queue-specific interest notification; no .NET equivalent | -| `ClearNotification()` | sublist.go:227 | MISSING | — | No .NET equivalent for removing a notification channel | -| `ClearQueueNotification()` | sublist.go:231 | MISSING | — | No .NET equivalent | +| `NewSublistNoCache()` | sublist.go:130 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:46` | Added `NewSublistNoCache()` factory returning `new SubList(false)` | +| `CacheEnabled()` | sublist.go:135 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:48` | Added `CacheEnabled()` to expose current cache mode | +| `RegisterNotification()` | sublist.go:149 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:50` | Added first/last-interest callback registration (`Action`) and transition notifications in `Insert`/`Remove` | +| `RegisterQueueNotification()` | sublist.go:153 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:57` | Added queue-scoped first/last-interest callback registration with immediate current-state callback and insert/remove transition tracking | +| `ClearNotification()` | sublist.go:227 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:52` | Added notification callback clearing method | +| `ClearQueueNotification()` | sublist.go:231 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:83` | Added queue-scoped notification de-registration across both insert/remove transition maps | | `chkForInsertNotification()` | sublist.go:301 | NOT_APPLICABLE | — | Internal helper for channel notification; replaced by `InterestChanged` event emission in `Insert()` | | `chkForRemoveNotification()` | sublist.go:317 | NOT_APPLICABLE | — | Internal helper; replaced by `InterestChanged` event emission in `Remove()` | | `sendNotification()` | sublist.go:253 | NOT_APPLICABLE | — | Non-blocking channel send; Go-specific pattern, no .NET equivalent needed | @@ -129,7 +129,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `hasInterest()` | sublist.go:624 | NOT_APPLICABLE | — | Internal; maps to `HasInterestLevel()` private helper | | `reduceCacheCount()` | sublist.go:675 | PORTED | `src/NATS.Server/Subscriptions/SubListCacheSweeper.cs:7` + `SubList.cs:458` | Background goroutine mapped to `SubListCacheSweeper` + `SweepCache()` | | `isRemoteQSub()` | sublist.go:689 | NOT_APPLICABLE | — | Go has client.kind == ROUTER/LEAF; .NET uses `RemoteSubscription` model instead | -| `UpdateRemoteQSub()` | sublist.go:695 | MISSING | — | Go updates weight of remote qsub; .NET uses `ApplyRemoteSub()` for a different model (full add/remove) | +| `UpdateRemoteQSub()` | sublist.go:695 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:218` | Added remote queue-sub weight update path that mutates existing entry and bumps generation | | `addNodeToResults()` | sublist.go:706 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:549` (private `AddNodeToResults()`) | Remote qsub weight expansion present in Go missing in .NET | | `findQSlot()` | sublist.go:745 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:562` (inline in `AddNodeToResults`) | Inlined in .NET | | `matchLevel()` | sublist.go:757 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:506` (private `MatchLevel()`) | Core trie descent algorithm | @@ -144,33 +144,33 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `Count()` | sublist.go:1023 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:41` (`Count` property) | | | `CacheCount()` | sublist.go:1030 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:97` (`CacheCount` property) | | | `SublistStats` (struct) | sublist.go:1038 | PORTED | `src/NATS.Server/Subscriptions/SubListStats.cs:3` | All public fields present; unexported fields `totFanout`, `cacheCnt`, `cacheHits` dropped (computed inline in `Stats()`) | -| `SublistStats.add()` | sublist.go:1052 | MISSING | — | Aggregates multiple SublistStats into one; used for cluster monitoring; no .NET equivalent | +| `SublistStats.add()` | sublist.go:1052 | PORTED | `src/NATS.Server/Subscriptions/SubListStats.cs:18` | Added `Add(SubListStats)` aggregation including cache-hit, fanout, and max-fanout rollups | | `Stats()` | sublist.go:1076 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:580` | Full fanout stats computed | -| `numLevels()` | sublist.go:1120 | MISSING | — | Debug/test utility counting trie depth; not ported | -| `visitLevel()` | sublist.go:1126 | MISSING | — | Internal helper for `numLevels()`; not ported | -| `subjectHasWildcard()` | sublist.go:1159 | PARTIAL | `src/NATS.Server/Subscriptions/SubjectMatch.cs:50` (`IsLiteral()` — inverse) | .NET `!IsLiteral()` is equivalent but not a dedicated function | +| `numLevels()` | sublist.go:1120 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:1030` | Added internal trie-depth utility (`NumLevels`) for parity/debug verification | +| `visitLevel()` | sublist.go:1126 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:1223` | Added recursive depth traversal helper used by `NumLevels()` | +| `subjectHasWildcard()` | sublist.go:1159 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:72` | Added dedicated `SubjectHasWildcard()` helper | | `subjectIsLiteral()` | sublist.go:1174 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:50` (`IsLiteral()`) | Exact equivalent | | `IsValidPublishSubject()` | sublist.go:1187 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:67` | | | `IsValidSubject()` | sublist.go:1192 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:9` | | | `isValidSubject()` (internal, checkRunes) | sublist.go:1196 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:211` (`IsValidSubject(string, bool)`) | | -| `IsValidLiteralSubject()` | sublist.go:1236 | PARTIAL | `src/NATS.Server/Subscriptions/SubjectMatch.cs:50` (`IsLiteral()`) | `IsLiteral()` does not validate the subject first; `IsValidPublishSubject()` combines both | +| `IsValidLiteralSubject()` | sublist.go:1236 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:74` | Added dedicated `IsValidLiteralSubject()` helper mapped to publish-subject validation | | `isValidLiteralSubject()` (tokens iter) | sublist.go:1241 | NOT_APPLICABLE | — | Takes `iter.Seq[string]` (Go 1.23 iterator); C# uses different iteration model | -| `ValidateMapping()` | sublist.go:1258 | MISSING | — | Validates a mapping destination subject string including `{{function()}}` syntax; no public .NET equivalent | +| `ValidateMapping()` | sublist.go:1258 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:138` | Added public destination-template validator with Go-compatible function parsing checks. | | `analyzeTokens()` | sublist.go:1298 | NOT_APPLICABLE | — | Internal helper used only in `SubjectsCollide()`; logic inlined in .NET `SubjectsCollide()` | | `tokensCanMatch()` | sublist.go:1314 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:199` (private `TokensCanMatch()`) | | | `SubjectsCollide()` | sublist.go:1326 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:159` | | | `numTokens()` | sublist.go:1374 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:118` (`NumTokens()`) | | | `tokenAt()` | sublist.go:1389 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:132` (`TokenAt()`) | Go is 1-based index; .NET is 0-based | | `tokenizeSubjectIntoSlice()` | sublist.go:1407 | NOT_APPLICABLE | — | Internal slice-reuse helper; .NET uses `Tokenize()` private method in SubList | -| `SubjectMatchesFilter()` | sublist.go:1421 | PARTIAL | `src/NATS.Server/JetStream/Storage/MemStore.cs:1175` (private), `FileStore.cs:773` (private) | Duplicated as private methods in MemStore and FileStore; not a public standalone function | -| `subjectIsSubsetMatch()` | sublist.go:1426 | MISSING | — | No public .NET equivalent; logic exists privately in MemStore/FileStore | -| `isSubsetMatch()` | sublist.go:1434 | MISSING | — | Internal; no public .NET equivalent | -| `isSubsetMatchTokenized()` | sublist.go:1444 | MISSING | — | Internal; no public .NET equivalent | +| `SubjectMatchesFilter()` | sublist.go:1421 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:205`; consumers: `src/NATS.Server/JetStream/Storage/MemStore.cs:1175`, `src/NATS.Server/JetStream/Storage/FileStore.cs:773` | Added standalone `SubjectMatch.SubjectMatchesFilter()` and switched JetStream stores to use it. | +| `subjectIsSubsetMatch()` | sublist.go:1426 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:207` (`SubjectIsSubsetMatch`) | Added direct port that tokenizes the subject and delegates to subset matching. | +| `isSubsetMatch()` | sublist.go:1434 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:213` (`IsSubsetMatch`) | Added token-array vs test-subject subset matcher. | +| `isSubsetMatchTokenized()` | sublist.go:1444 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:219` (`IsSubsetMatchTokenized`) | Added tokenized subset matcher with Go-compatible `*`/`>` handling. | | `matchLiteral()` | sublist.go:1483 | PORTED | `src/NATS.Server/Subscriptions/SubjectMatch.cs:75` (`MatchLiteral()`) | | | `addLocalSub()` | sublist.go:1552 | NOT_APPLICABLE | — | Filters by client kind (CLIENT/SYSTEM/JETSTREAM/ACCOUNT/LEAF); no .NET equivalent needed (client kind routing done elsewhere) | | `Sublist.addNodeToSubs()` | sublist.go:1562 | NOT_APPLICABLE | — | Internal helper for `localSubs()`; not ported | | `Sublist.collectLocalSubs()` | sublist.go:1581 | NOT_APPLICABLE | — | Internal helper for `localSubs()`; not ported | -| `Sublist.localSubs()` | sublist.go:1597 | MISSING | — | Returns only local-client subscriptions (excludes routes/gateways); no .NET equivalent | +| `Sublist.localSubs()` | sublist.go:1597 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:1015` | Added local-sub collector filtering to CLIENT/SYSTEM/JETSTREAM/ACCOUNT kinds with optional LEAF inclusion | | `Sublist.All()` | sublist.go:1604 | PORTED | `src/NATS.Server/Subscriptions/SubList.cs:712` | | | `Sublist.addAllNodeToSubs()` | sublist.go:1610 | NOT_APPLICABLE | — | Internal helper for `All()`; inlined in .NET | | `Sublist.collectAllSubs()` | sublist.go:1627 | NOT_APPLICABLE | — | Internal; inlined in `CollectAllSubs()` private method in .NET | @@ -182,26 +182,26 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| Transform type constants (`NoTransform`…`Random`) | subject_transform.go:43 | PARTIAL | `src/NATS.Server/Subscriptions/SubjectTransform.cs:682` (private `TransformType` enum) | `Random` (value 11 in Go) is absent from the .NET enum and switch statement; all others ported | +| Transform type constants (`NoTransform`…`Random`) | subject_transform.go:43 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:794` (private `TransformType` enum) | Added `Random` transform type and execution branch parity. | | `subjectTransform` (struct) | subject_transform.go:61 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:11` | Internal fields mapped to `_source`, `_dest`, `_sourceTokens`, `_destTokens`, `_ops` | | `SubjectTransformer` (interface) | subject_transform.go:73 | NOT_APPLICABLE | — | Go exports interface for polymorphism; C# uses concrete `SubjectTransform` class directly | -| `NewSubjectTransformWithStrict()` | subject_transform.go:81 | MISSING | — | Strict mode validates that all source wildcards are used in dest; no .NET equivalent | +| `NewSubjectTransformWithStrict()` | subject_transform.go:81 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:126` | Added strict factory variant that rejects transforms when any source wildcard is unused by destination mapping. | | `NewSubjectTransform()` | subject_transform.go:198 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:31` (`Create()`) | Non-strict creation | -| `NewSubjectTransformStrict()` | subject_transform.go:202 | MISSING | — | Strict version for import mappings; no .NET equivalent | +| `NewSubjectTransformStrict()` | subject_transform.go:202 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:135` | Added strict convenience constructor delegating to strict factory mode. | | `getMappingFunctionArgs()` | subject_transform.go:206 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:639` (private `GetFunctionArgs()`) | | | `transformIndexIntArgsHelper()` | subject_transform.go:215 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:610` (private `ParseIndexIntArgs()`) | | -| `indexPlaceHolders()` | subject_transform.go:237 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:486` (`ParseDestToken()` + `ParseMustacheToken()`) | Split into two methods; `Random` branch missing | -| `transformTokenize()` | subject_transform.go:378 | MISSING | — | Converts `foo.*.*` to `foo.$1.$2`; used for import subject mapping reversal; no .NET equivalent | -| `transformUntokenize()` | subject_transform.go:399 | MISSING | — | Inverse of above; used in `reverse()`; no .NET equivalent | +| `indexPlaceHolders()` | subject_transform.go:237 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:586` (`ParseDestToken()` + `ParseMustacheToken()`) | Split into two methods, including `Random(...)` placeholder parsing branch. | +| `transformTokenize()` | subject_transform.go:378 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:156` | Added wildcard tokenization helper converting `*` capture positions into `$N` placeholders. | +| `transformUntokenize()` | subject_transform.go:399 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:173` | Added inverse helper converting `$N` placeholders back to `*` tokens. | | `tokenizeSubject()` | subject_transform.go:414 | NOT_APPLICABLE | — | Internal tokenizer; .NET uses `string.Split('.')` or `Tokenize()` private method | | `subjectTransform.Match()` | subject_transform.go:433 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:126` (`Apply()`) | Renamed; returns `null` instead of error on no-match | -| `subjectTransform.TransformSubject()` | subject_transform.go:456 | PARTIAL | `src/NATS.Server/Subscriptions/SubjectTransform.cs:126` (via `Apply()`) | `TransformSubject` (apply without match check) not separately exposed; `Apply()` always checks match | -| `subjectTransform.getRandomPartition()` | subject_transform.go:460 | MISSING | — | `Random` transform type not implemented in .NET | +| `subjectTransform.TransformSubject()` | subject_transform.go:456 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:251` | Added dedicated transform-only entrypoint (`TransformSubject`) that applies destination mapping without source match guard. | +| `subjectTransform.getRandomPartition()` | subject_transform.go:460 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:374` | Added random-partition helper and transform dispatch support (`random(n)` in range `[0,n)`, zero when `n<=0`). | | `subjectTransform.getHashPartition()` | subject_transform.go:469 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:226` (`ComputePartition()` + `Fnv1A32()`) | FNV-1a 32-bit hash ported | -| `subjectTransform.TransformTokenizedSubject()` | subject_transform.go:482 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:144` (private `TransformTokenized()`) | All transform types except `Random` ported | -| `subjectTransform.reverse()` | subject_transform.go:638 | MISSING | — | Produces the inverse transform; used for import subject mapping; no .NET equivalent | +| `subjectTransform.TransformTokenizedSubject()` | subject_transform.go:482 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:261` (private `TransformTokenized()`) | Transform execution now includes `Random` and strict/helper parity branches. | +| `subjectTransform.reverse()` | subject_transform.go:638 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:188` | Added inverse-transform builder for wildcard-mapped transforms used in import mapping reversal scenarios. | | `subjectInfo()` | subject_transform.go:666 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:451` (private `SubjectInfo()`) | | -| `ValidateMapping()` (in subject_transform.go context) | sublist.go:1258 | MISSING | — | Also defined via `NewSubjectTransform`; validates mapping destination with function syntax; no .NET public equivalent | +| `ValidateMapping()` (in subject_transform.go context) | sublist.go:1258 | PORTED | `src/NATS.Server/Subscriptions/SubjectTransform.cs:138` | Shared mapping validator exposed publicly and used for subject-transform destination validation parity. | --- @@ -228,5 +228,9 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Executed subscriptions batch 4 subject-transform parity closures: added strict constructors (`NewSubjectTransformWithStrict`, `NewSubjectTransformStrict`), public mapping validator, random transform type/partition helper, tokenize/untokenize helpers, reverse-transform builder, and dedicated `TransformSubject` API with targeted tests (`SubjectTransformParityBatch3Tests`). | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: analyzed sublist.go (~1,729 lines) and subject_transform.go (~689 lines) against all .NET Subscriptions/*.cs files. Counted 49 PORTED, 6 PARTIAL, 22 MISSING, 27 NOT_APPLICABLE, 0 DEFERRED. | claude-sonnet-4-6 | +| 2026-02-25 | Executed subscriptions batch 1: added cache-mode constructors/factories and first/last-interest notification APIs to `SubList`, added subject helper aliases to `SubjectMatch`, added targeted tests (`SubListCtorAndNotificationParityTests`), and reclassified 7 rows (4 MISSING + 3 PARTIAL) to PORTED | codex | +| 2026-02-25 | Executed subscriptions batch 2: added standalone subset/filter APIs to `SubjectMatch` (`SubjectMatchesFilter`, `SubjectIsSubsetMatch`, `IsSubsetMatch`, `IsSubsetMatchTokenized`), switched MemStore/FileStore subject filter helpers to use them, and added targeted tests (`SubjectSubsetMatchParityBatch1Tests`). Reclassified 4 open rows to PORTED. | codex | +| 2026-02-25 | Executed subscriptions batch 3: added queue-scoped notification APIs, remote queue-weight updater, `SubListStats.Add`, trie depth helpers (`NumLevels`/`VisitLevel`), and local-sub collection (`LocalSubs`) with targeted tests (`SubListParityBatch2Tests`). Reclassified 7 open rows to PORTED. | codex | diff --git a/gaps/tls-security.md b/gaps/tls-security.md index 85d17cf..639b7e2 100644 --- a/gaps/tls-security.md +++ b/gaps/tls-security.md @@ -132,7 +132,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Server.reloadOCSP | golang/nats-server/server/ocsp.go:734 | MISSING | — | No OCSP hot-reload support | | hasOCSPStatusRequest | golang/nats-server/server/ocsp.go:804 | MISSING | — | No MustStaple TLS extension detection | | OCSPMonitor.writeOCSPStatus | golang/nats-server/server/ocsp.go:840 | MISSING | — | No atomic file write for OCSP status persistence | -| parseCertPEM | golang/nats-server/server/ocsp.go:867 | PARTIAL | src/NATS.Server/Tls/TlsHelper.cs:17 | `LoadCaCertificates` uses `ImportFromPemFile` but does not validate PEM block type | +| parseCertPEM | golang/nats-server/server/ocsp.go:867 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:33 | Added `ParseCertPem(string)` with explicit PEM block scanning/validation (`CERTIFICATE` only), multi-cert bundle parsing, and typed failures on unexpected block types. `LoadCaCertificates` now uses this parser. | | getOCSPIssuerLocally | golang/nats-server/server/ocsp.go:892 | MISSING | — | No local issuer resolution from cert bundle | | getOCSPIssuer | golang/nats-server/server/ocsp.go:932 | MISSING | — | No issuer resolution logic | | ocspStatusString | golang/nats-server/server/ocsp.go:968 | PORTED | src/NATS.Server/Events/EventTypes.cs:647 | `OcspEventBuilder.ParseStatus` and `OcspStatus` enum | @@ -142,7 +142,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| parseOCSPPeer | golang/nats-server/server/ocsp_peer.go:29 | MISSING | — | No config-file parsing for OCSP peer options | +| parseOCSPPeer | golang/nats-server/server/ocsp_peer.go:29 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1101 | `ParseOcspPeer` supports short-form bool and long-form map parsing with Go-matching field names and conversion behavior | | peerFromVerifiedChains | golang/nats-server/server/ocsp_peer.go:130 | MISSING | — | No peer extraction from verified chains | | Server.plugTLSOCSPPeer | golang/nats-server/server/ocsp_peer.go:138 | PARTIAL | src/NATS.Server/Tls/TlsHelper.cs:36 | .NET uses X509RevocationMode.Online when OcspPeerVerify set; missing full OCSP peer plugin pattern with per-chain validation | | Server.plugClientTLSOCSPPeer | golang/nats-server/server/ocsp_peer.go:163 | PARTIAL | src/NATS.Server/Tls/TlsHelper.cs:41 | RemoteCertificateValidationCallback with revocation check, but no OCSP-specific chain walking or event publishing | @@ -213,34 +213,34 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| DefaultAllowedClockSkew | golang/nats-server/server/certidp/certidp.go:30 | MISSING | — | No OCSP clock skew constant | -| DefaultOCSPResponderTimeout | golang/nats-server/server/certidp/certidp.go:31 | MISSING | — | No OCSP responder timeout constant | -| DefaultTTLUnsetNextUpdate | golang/nats-server/server/certidp/certidp.go:32 | MISSING | — | No default TTL when NextUpdate is unset | -| StatusAssertion (type) | golang/nats-server/server/certidp/certidp.go:35 | PARTIAL | src/NATS.Server/Events/EventTypes.cs:595 | `OcspStatus` enum exists (Good, Revoked, Unknown) but no JSON marshal/unmarshal or bidirectional maps | -| GetStatusAssertionStr | golang/nats-server/server/certidp/certidp.go:56 | PORTED | src/NATS.Server/Events/EventTypes.cs:647 | `OcspEventBuilder.ParseStatus` provides string-to-enum; reverse mapping implicit | -| ChainLink (struct) | golang/nats-server/server/certidp/certidp.go:93 | MISSING | — | No chain link struct with Leaf/Issuer/OCSPWebEndpoints | -| OCSPPeerConfig (struct) | golang/nats-server/server/certidp/certidp.go:100 | MISSING | — | No OCSP peer config struct (Verify, Timeout, ClockSkew, WarnOnly, UnknownIsGood, AllowWhenCAUnreachable, TTLUnsetNextUpdate) | -| NewOCSPPeerConfig | golang/nats-server/server/certidp/certidp.go:110 | MISSING | — | No peer config factory | +| DefaultAllowedClockSkew | golang/nats-server/server/certidp/certidp.go:30 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:105 | `OCSPPeerConfig.DefaultAllowedClockSkew` set to 30 seconds | +| DefaultOCSPResponderTimeout | golang/nats-server/server/certidp/certidp.go:31 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:106 | `OCSPPeerConfig.DefaultOCSPResponderTimeout` set to 2 seconds | +| DefaultTTLUnsetNextUpdate | golang/nats-server/server/certidp/certidp.go:32 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:107 | `OCSPPeerConfig.DefaultTTLUnsetNextUpdate` set to 1 hour | +| StatusAssertion (type) | golang/nats-server/server/certidp/certidp.go:35 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:9 | Added `StatusAssertion` enum with JSON converter and bidirectional string/int maps | +| GetStatusAssertionStr | golang/nats-server/server/certidp/certidp.go:56 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:40 | `StatusAssertionMaps.GetStatusAssertionStr(int)` with unknown fallback | +| ChainLink (struct) | golang/nats-server/server/certidp/certidp.go:93 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:81 | Added `ChainLink` type with `Leaf`, `Issuer`, and `OCSPWebEndpoints` | +| OCSPPeerConfig (struct) | golang/nats-server/server/certidp/certidp.go:100 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:103 | Added `OCSPPeerConfig` with matching fields and defaults | +| NewOCSPPeerConfig | golang/nats-server/server/certidp/certidp.go:110 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:117 | Added `OCSPPeerConfig.NewOCSPPeerConfig()` factory | | Log (struct) | golang/nats-server/server/certidp/certidp.go:123 | NOT_APPLICABLE | — | .NET uses ILogger injection; no need for function-pointer log struct | -| CertInfo (struct) | golang/nats-server/server/certidp/certidp.go:131 | MISSING | — | No cert info DTO for events | -| GenerateFingerprint | golang/nats-server/server/certidp/certidp.go:179 | PARTIAL | src/NATS.Server/Tls/TlsHelper.cs:88 | `GetCertificateHash` uses SHA256 on SPKI (not raw cert as Go does); different hash input | -| getWebEndpoints | golang/nats-server/server/certidp/certidp.go:184 | MISSING | — | No OCSP endpoint URL extraction/filtering | -| GetSubjectDNForm | golang/nats-server/server/certidp/certidp.go:203 | MISSING | — | No subject RDN sequence formatting | -| GetIssuerDNForm | golang/nats-server/server/certidp/certidp.go:212 | MISSING | — | No issuer RDN sequence formatting | -| CertOCSPEligible | golang/nats-server/server/certidp/certidp.go:221 | MISSING | — | No OCSP eligibility check based on AIA extension | -| GetLeafIssuerCert | golang/nats-server/server/certidp/certidp.go:237 | MISSING | — | No positional issuer extraction from chain | -| OCSPResponseCurrent | golang/nats-server/server/certidp/certidp.go:250 | MISSING | — | No OCSP response currency check with clock skew and TTL fallback | -| ValidDelegationCheck | golang/nats-server/server/certidp/certidp.go:288 | MISSING | — | No OCSP response delegation validation per RFC 6960 section 4.2.2.2 | +| CertInfo (struct) | golang/nats-server/server/certidp/certidp.go:131 | PORTED | src/NATS.Server/Tls/OcspPeerConfig.cs:88 | Added `CertInfo` DTO with subject, issuer, fingerprint, and raw fields | +| GenerateFingerprint | golang/nats-server/server/certidp/certidp.go:179 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:95 | Added `GenerateFingerprint` using SHA-256 of raw certificate bytes and base64 encoding | +| getWebEndpoints | golang/nats-server/server/certidp/certidp.go:184 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:101 | Added `GetWebEndpoints` filtering to valid absolute HTTP/HTTPS URIs | +| GetSubjectDNForm | golang/nats-server/server/certidp/certidp.go:203 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:117 | Added subject DN helper returning empty string for null cert | +| GetIssuerDNForm | golang/nats-server/server/certidp/certidp.go:212 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:122 | Added issuer DN helper returning empty string for null cert | +| CertOCSPEligible | golang/nats-server/server/certidp/certidp.go:221 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:142 | Added AIA OCSP responder extraction + HTTP(S) endpoint filtering, and populates `ChainLink.OCSPWebEndpoints` on success | +| GetLeafIssuerCert | golang/nats-server/server/certidp/certidp.go:237 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:165 | Added positional issuer extraction helper (`leafPos + 1`) with bounds/self-signed guards | +| OCSPResponseCurrent | golang/nats-server/server/certidp/certidp.go:250 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:193 | Added OCSP response currency window checks with configurable clock-skew and fallback TTL when `NextUpdate` is unset | +| ValidDelegationCheck | golang/nats-server/server/certidp/certidp.go:288 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:224 | Added delegation validation: direct issuer response accepted, delegated responder requires OCSPSigning EKU | ### certidp/messages.go — CertIDP message types | Go Symbol | Go File:Line | Status | .NET Equivalent | Notes | |-----------|:-------------|--------|:----------------|-------| -| Error message constants | golang/nats-server/server/certidp/messages.go:17 | MISSING | — | No equivalent error/debug message constants; .NET uses structured logging | -| Debug message constants | golang/nats-server/server/certidp/messages.go:47 | MISSING | — | Debug format strings not ported; .NET logs differently | -| MsgTLSClientRejectConnection | golang/nats-server/server/certidp/messages.go:81 | PARTIAL | src/NATS.Server/Events/EventTypes.cs:520 | Reject event type exists but literal reject reason string not exposed | -| MsgTLSServerRejectConnection | golang/nats-server/server/certidp/messages.go:82 | PARTIAL | src/NATS.Server/Events/EventTypes.cs:520 | Same as above | -| MsgCacheOnline / MsgCacheOffline | golang/nats-server/server/certidp/messages.go:96 | MISSING | — | No cache status notification messages | +| Error message constants | golang/nats-server/server/certidp/messages.go:17 | PORTED | src/NATS.Server/Tls/OcspPeerMessages.cs:6 | Ported certidp error string constants (returned and directly-logged variants) into `OcspPeerMessages` | +| Debug message constants | golang/nats-server/server/certidp/messages.go:47 | PORTED | src/NATS.Server/Tls/OcspPeerMessages.cs:36 | Ported certidp debug-format constants used by OCSP peer/cache workflows | +| MsgTLSClientRejectConnection | golang/nats-server/server/certidp/messages.go:81 | PORTED | src/NATS.Server/Tls/OcspPeerMessages.cs:5 | Added literal reject reason constant for client OCSP validation failures | +| MsgTLSServerRejectConnection | golang/nats-server/server/certidp/messages.go:82 | PORTED | src/NATS.Server/Tls/OcspPeerMessages.cs:6 | Added literal reject reason constant for server OCSP validation failures | +| MsgCacheOnline / MsgCacheOffline | golang/nats-server/server/certidp/messages.go:96 | PORTED | src/NATS.Server/Tls/OcspPeerMessages.cs:7 | Added cache online/offline informational message templates | ### certidp/ocsp_responder.go — OCSP responder client @@ -259,7 +259,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | MatchByMap | golang/nats-server/server/certstore/certstore.go:52 | NOT_APPLICABLE | — | .NET equivalent: X509FindType | | ParseCertStore | golang/nats-server/server/certstore/certstore.go:68 | NOT_APPLICABLE | — | .NET has built-in X509Store with StoreLocation | | ParseCertMatchBy | golang/nats-server/server/certstore/certstore.go:80 | NOT_APPLICABLE | — | .NET has X509FindType | -| GetLeafIssuer | golang/nats-server/server/certstore/certstore.go:88 | MISSING | — | Could port using X509Chain verification to find issuer | +| GetLeafIssuer | golang/nats-server/server/certstore/certstore.go:88 | PORTED | src/NATS.Server/Tls/TlsHelper.cs:176 | Added verified-chain issuer resolver using custom-root trust and returning chain element issuer | | credential (interface) | golang/nats-server/server/certstore/certstore.go:99 | NOT_APPLICABLE | — | .NET uses X509Certificate2 with private key; no separate credential interface needed | ### certstore/certstore_other.go — Non-Windows cert store stub @@ -336,5 +336,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Ported `parseCertPEM` parity by adding strict PEM certificate parser (`CERTIFICATE` blocks only), wiring `LoadCaCertificates` through it, and adding focused TLS helper tests for invalid block rejection and multi-cert bundles. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Completed full gap inventory: 12 Go source files analyzed, 144 symbols classified (20 PORTED, 9 PARTIAL, 70 MISSING, 45 NOT_APPLICABLE, 0 DEFERRED) | claude-opus | +| 2026-02-25 | Ported OCSP eligibility/issuer/currentness/delegation helpers, certstore leaf issuer resolution, and certidp error/debug message constants with targeted TLS/Ocsp parity tests | codex | diff --git a/gaps/utilities-and-other.md b/gaps/utilities-and-other.md index 561ea1b..3f1073b 100644 --- a/gaps/utilities-and-other.md +++ b/gaps/utilities-and-other.md @@ -120,13 +120,13 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `parseSize` (unexported) | golang/nats-server/server/util.go:82 | PORTED | src/NATS.Server/Protocol/NatsParser.cs:434 | `NatsParser.ParseSize(Span)` — exact behavioral match including -1 on error; tested in InfrastructureGoParityTests | | `parseInt64` (unexported) | golang/nats-server/server/util.go:113 | PORTED | src/NATS.Server/Protocol/NatsParser.cs:434 | Folded into `ParseSize` / inline parser logic; behavior covered by parser tests | | `secondsToDuration` (unexported) | golang/nats-server/server/util.go:127 | NOT_APPLICABLE | — | Go-specific `time.Duration` helper; .NET uses `TimeSpan.FromSeconds(double)` directly | -| `parseHostPort` (unexported) | golang/nats-server/server/util.go:134 | PARTIAL | src/NATS.Server/Configuration/ConfigProcessor.cs | ConfigProcessor parses `host:port` strings but does not have a standalone `ParseHostPort` helper matching Go's default-port fallback logic | +| `parseHostPort` (unexported) | golang/nats-server/server/util.go:134 | PORTED | src/NATS.Server/Server/ServerUtilities.cs:19 | Added `ParseHostPort(string, int)` with default-port fallback and 0/-1 port normalization | | `urlsAreEqual` (unexported) | golang/nats-server/server/util.go:158 | NOT_APPLICABLE | — | Uses `reflect.DeepEqual` on `*url.URL`; not needed in .NET port (no equivalent URL gossip pattern yet) | | `comma` (unexported) | golang/nats-server/server/util.go:169 | PORTED | tests/NATS.Server.Tests/InfrastructureGoParityTests.cs:960 | Ported as `CommaFormat` helper in InfrastructureGoParityTests (test-side only); monitoring output uses `string.Format("{0:N0}")` in production | | `natsListenConfig` | golang/nats-server/server/util.go:246 | PORTED | src/NATS.Server/NatsServer.cs | .NET `TcpListener` / `Socket` used without OS TCP keepalives; keepalive is disabled by default in .NET socket setup matching Go behavior | | `natsListen` (unexported) | golang/nats-server/server/util.go:252 | PORTED | src/NATS.Server/NatsServer.cs | Equivalent accept loop uses `TcpListener.AcceptTcpClientAsync` without system keepalives | -| `natsDialTimeout` (unexported) | golang/nats-server/server/util.go:258 | PARTIAL | src/NATS.Server/Routes/RouteConnection.cs | Route dialing exists but the explicit keepalive=-1 (disabled) setting is not verified in .NET route code | -| `redactURLList` (unexported) | golang/nats-server/server/util.go:270 | PARTIAL | tests/NATS.Server.Tests/InfrastructureGoParityTests.cs:979 | `RedactUrl` helper ported in test file; no production-side `redactURLList` for URL slices | +| `natsDialTimeout` (unexported) | golang/nats-server/server/util.go:258 | PORTED | src/NATS.Server/Routes/RouteManager.cs:547 | Added `CreateRouteDialSocket()` to explicitly disable TCP keepalive on outbound route dials before connect | +| `redactURLList` (unexported) | golang/nats-server/server/util.go:270 | PORTED | src/NATS.Server/Server/ServerUtilities.cs:79 | Added production `RedactUrlList(IEnumerable)` that redacts per-URL user-info passwords | | `redactURLString` (unexported) | golang/nats-server/server/util.go:296 | PORTED | tests/NATS.Server.Tests/InfrastructureGoParityTests.cs:979 | `RedactUrl` in InfrastructureGoParityTests matches behavior; also used in log redaction | | `getURLsAsString` (unexported) | golang/nats-server/server/util.go:308 | NOT_APPLICABLE | — | Internal URL slice utility for clustering; not needed in current .NET scope | | `copyBytes` (unexported) | golang/nats-server/server/util.go:317 | PORTED | (inline) | .NET uses `ReadOnlySpan.ToArray()`, `Array.Copy`, or `Buffer.BlockCopy` equivalently throughout the codebase | @@ -153,8 +153,8 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | `rateCounter` (type) | golang/nats-server/server/rate_counter.go:21 | PORTED | src/NATS.Server/Tls/TlsRateLimiter.cs | `TlsRateLimiter` provides equivalent token-bucket rate limiting for TLS handshakes; also `ApiRateLimiter` for JetStream API | | `newRateCounter` | golang/nats-server/server/rate_counter.go:30 | PORTED | src/NATS.Server/Tls/TlsRateLimiter.cs:9 | `TlsRateLimiter(long tokensPerSecond)` constructor | -| `rateCounter.allow` (unexported) | golang/nats-server/server/rate_counter.go:37 | PARTIAL | src/NATS.Server/Tls/TlsRateLimiter.cs:22 | `WaitAsync(CancellationToken)` is async/blocking rather than a synchronous allow-or-deny check; Go's `allow()` is non-blocking | -| `rateCounter.countBlocked` (unexported) | golang/nats-server/server/rate_counter.go:58 | MISSING | — | No equivalent "count blocked requests" metric; `TlsRateLimiter` does not expose a blocked-count stat | +| `rateCounter.allow` (unexported) | golang/nats-server/server/rate_counter.go:37 | PORTED | src/NATS.Server/Server/RateCounter.cs:21 | Added non-blocking `Allow()` with 1-second window and blocked-counter increment semantics | +| `rateCounter.countBlocked` (unexported) | golang/nats-server/server/rate_counter.go:58 | PORTED | src/NATS.Server/Server/RateCounter.cs:42 | Added `CountBlocked()` that returns and resets blocked count | ### `golang/nats-server/server/sendq.go` @@ -206,13 +206,13 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ErrReservedPublishSubject` | golang/nats-server/server/errors.go:49 | PORTED | src/NATS.Server/NatsClient.cs | Reserved subject check on publish | | `ErrBadPublishSubject` | golang/nats-server/server/errors.go:52 | PORTED | src/NATS.Server/Protocol/NatsProtocol.cs:28 | `ErrInvalidPublishSubject` constant | | `ErrBadSubject` | golang/nats-server/server/errors.go:55 | PORTED | src/NATS.Server/Protocol/NatsProtocol.cs:29 | `ErrInvalidSubject` constant | -| `ErrBadQualifier` | golang/nats-server/server/errors.go:58 | MISSING | — | No dedicated bad-qualifier error; transform validation throws `ArgumentException` | +| `ErrBadQualifier` | golang/nats-server/server/errors.go:58 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:10 | Added parity literal constant for bad transform qualifier error | | `ErrBadClientProtocol` | golang/nats-server/server/errors.go:61 | PORTED | src/NATS.Server/NatsClient.cs | Protocol version validation on CONNECT | | `ErrTooManyConnections` | golang/nats-server/server/errors.go:64 | PORTED | src/NATS.Server/Protocol/NatsProtocol.cs:25 | `ErrMaxConnectionsExceeded` constant | -| `ErrTooManyAccountConnections` | golang/nats-server/server/errors.go:68 | MISSING | — | Account-level connection limits not yet implemented | +| `ErrTooManyAccountConnections` | golang/nats-server/server/errors.go:68 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:11 | Added parity error literal constant; enforcement work remains tracked separately in account-limit behavior gaps | | `ErrLeafNodeLoop` | golang/nats-server/server/errors.go:72 | PORTED | src/NATS.Server/LeafNode/ | Leaf node loop detection implemented | -| `ErrTooManySubs` | golang/nats-server/server/errors.go:76 | MISSING | — | Per-connection subscription limit not yet enforced | -| `ErrTooManySubTokens` | golang/nats-server/server/errors.go:79 | MISSING | — | Subject token count limit not yet enforced | +| `ErrTooManySubs` | golang/nats-server/server/errors.go:76 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:12 | Added parity error literal constant | +| `ErrTooManySubTokens` | golang/nats-server/server/errors.go:79 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:13 | Added parity error literal constant | | `ErrClientConnectedToRoutePort` | golang/nats-server/server/errors.go:83 | PORTED | src/NATS.Server/Routes/ | Wrong port detection on route listener | | `ErrClientConnectedToLeafNodePort` | golang/nats-server/server/errors.go:87 | PORTED | src/NATS.Server/LeafNode/ | Wrong port detection on leaf node listener | | `ErrLeafNodeHasSameClusterName` | golang/nats-server/server/errors.go:91 | PORTED | src/NATS.Server/LeafNode/ | Same-cluster-name rejection | @@ -220,22 +220,22 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ErrConnectedToWrongPort` | golang/nats-server/server/errors.go:98 | PORTED | src/NATS.Server/NatsServer.cs | Port sniffing / wrong-port close | | `ErrAccountExists` | golang/nats-server/server/errors.go:102 | PORTED | src/NATS.Server/Auth/Account.cs | Duplicate account registration check | | `ErrBadAccount` | golang/nats-server/server/errors.go:105 | PORTED | src/NATS.Server/Auth/ | Bad/malformed account | -| `ErrReservedAccount` | golang/nats-server/server/errors.go:108 | MISSING | — | Reserved account name check not yet implemented | +| `ErrReservedAccount` | golang/nats-server/server/errors.go:108 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:14 | Added parity error literal constant | | `ErrMissingAccount` | golang/nats-server/server/errors.go:111 | PORTED | src/NATS.Server/Auth/ | Missing account lookup | -| `ErrMissingService` | golang/nats-server/server/errors.go:114 | MISSING | — | Service export/import not yet ported | -| `ErrBadServiceType` | golang/nats-server/server/errors.go:117 | MISSING | — | Service latency tracking not yet ported | -| `ErrBadSampling` | golang/nats-server/server/errors.go:120 | MISSING | — | Latency sampling validation not yet ported | +| `ErrMissingService` | golang/nats-server/server/errors.go:114 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:15 | Added parity error literal constant | +| `ErrBadServiceType` | golang/nats-server/server/errors.go:117 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:16 | Added parity error literal constant | +| `ErrBadSampling` | golang/nats-server/server/errors.go:120 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:17 | Added parity error literal constant | | `ErrAccountValidation` | golang/nats-server/server/errors.go:123 | PORTED | src/NATS.Server/Auth/ | Account validation logic | | `ErrAccountExpired` | golang/nats-server/server/errors.go:126 | PORTED | src/NATS.Server/Auth/ | Account expiry check | | `ErrNoAccountResolver` | golang/nats-server/server/errors.go:129 | PORTED | src/NATS.Server/Auth/ | No resolver configured check | -| `ErrAccountResolverUpdateTooSoon` | golang/nats-server/server/errors.go:132 | MISSING | — | Resolver update rate limiting not yet ported | -| `ErrAccountResolverSameClaims` | golang/nats-server/server/errors.go:135 | MISSING | — | Same-claims dedup not yet ported | -| `ErrStreamImportAuthorization` | golang/nats-server/server/errors.go:138 | MISSING | — | Stream import auth not yet ported | -| `ErrStreamImportBadPrefix` | golang/nats-server/server/errors.go:141 | MISSING | — | Stream import prefix validation not yet ported | -| `ErrStreamImportDuplicate` | golang/nats-server/server/errors.go:144 | MISSING | — | Duplicate import detection not yet ported | -| `ErrServiceImportAuthorization` | golang/nats-server/server/errors.go:147 | MISSING | — | Service import auth not yet ported | -| `ErrImportFormsCycle` | golang/nats-server/server/errors.go:150 | MISSING | — | Import cycle detection not yet ported | -| `ErrCycleSearchDepth` | golang/nats-server/server/errors.go:153 | MISSING | — | Cycle search depth limit not yet ported | +| `ErrAccountResolverUpdateTooSoon` | golang/nats-server/server/errors.go:132 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:18 | Added parity error literal constant | +| `ErrAccountResolverSameClaims` | golang/nats-server/server/errors.go:135 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:19 | Added parity error literal constant | +| `ErrStreamImportAuthorization` | golang/nats-server/server/errors.go:138 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:20 | Added parity error literal constant | +| `ErrStreamImportBadPrefix` | golang/nats-server/server/errors.go:141 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:21 | Added parity error literal constant | +| `ErrStreamImportDuplicate` | golang/nats-server/server/errors.go:144 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:22 | Added parity error literal constant | +| `ErrServiceImportAuthorization` | golang/nats-server/server/errors.go:147 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:23 | Added parity error literal constant | +| `ErrImportFormsCycle` | golang/nats-server/server/errors.go:150 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:24 | Added parity error literal constant | +| `ErrCycleSearchDepth` | golang/nats-server/server/errors.go:153 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:25 | Added parity error literal constant | | `ErrClientOrRouteConnectedToGatewayPort` | golang/nats-server/server/errors.go:157 | PORTED | src/NATS.Server/Gateways/ | Wrong port detection on gateway listener | | `ErrWrongGateway` | golang/nats-server/server/errors.go:161 | PORTED | src/NATS.Server/Gateways/ | Wrong gateway name on connect | | `ErrGatewayNameHasSpaces` | golang/nats-server/server/errors.go:165 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs | Config validation rejects spaces in gateway name | @@ -251,7 +251,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `ErrClusterNameHasSpaces` | golang/nats-server/server/errors.go:199 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs | Config validation | | `ErrMalformedSubject` | golang/nats-server/server/errors.go:202 | PORTED | src/NATS.Server/Subscriptions/SubjectMatch.cs | Subject validation rejects malformed subjects | | `ErrSubscribePermissionViolation` | golang/nats-server/server/errors.go:205 | PORTED | src/NATS.Server/NatsClient.cs | Subscribe permission check | -| `ErrNoTransforms` | golang/nats-server/server/errors.go:208 | MISSING | — | Transform selection logic not yet fully ported | +| `ErrNoTransforms` | golang/nats-server/server/errors.go:208 | PORTED | src/NATS.Server/Server/ServerErrorConstants.cs:26 | Added parity error literal constant | | `ErrCertNotPinned` | golang/nats-server/server/errors.go:211 | PORTED | src/NATS.Server/Tls/TlsHelper.cs | Pinned cert validation | | `ErrDuplicateServerName` | golang/nats-server/server/errors.go:215 | PORTED | src/NATS.Server/Routes/ | Duplicate server name on cluster connect | | `ErrMinimumVersionRequired` | golang/nats-server/server/errors.go:218 | PORTED | src/NATS.Server/Routes/ | Minimum version enforcement on cluster | @@ -270,11 +270,11 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `configErr` (type) | golang/nats-server/server/errors.go:261 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1434 | `ConfigProcessorException` with error list | | `configErr.Source` | golang/nats-server/server/errors.go:267 | PARTIAL | src/NATS.Server/Configuration/ConfigProcessor.cs | Source file/line tracking in errors is partial; exception message includes context but not file:line:col | | `configErr.Error` | golang/nats-server/server/errors.go:272 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs | Exception `Message` property | -| `unknownConfigFieldErr` (type) | golang/nats-server/server/errors.go:280 | PARTIAL | src/NATS.Server/Configuration/ConfigProcessor.cs | Unknown fields trigger `ConfigProcessorException` but without the specific `unknownConfigFieldErr` type distinction | -| `configWarningErr` (type) | golang/nats-server/server/errors.go:292 | MISSING | — | No distinction between warnings and errors in .NET config processor; all surfaced as exceptions | +| `unknownConfigFieldErr` (type) | golang/nats-server/server/errors.go:280 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1597 | Added `UnknownConfigFieldWarning` type with field/source metadata and Go-style warning message (`unknown field `). | +| `configWarningErr` (type) | golang/nats-server/server/errors.go:292 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1588 | Added `ConfigWarningException` base warning type to distinguish warning payloads from hard errors. | | `processConfigErr` (type) | golang/nats-server/server/errors.go:303 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1434 | `ConfigProcessorException` accumulates all errors | | `processConfigErr.Error` | golang/nats-server/server/errors.go:310 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs | `Message` + `Errors` list | -| `processConfigErr.Warnings` | golang/nats-server/server/errors.go:322 | MISSING | — | No separate warnings list; warnings folded into errors or logged | +| `processConfigErr.Warnings` | golang/nats-server/server/errors.go:322 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1581 | Added `ConfigProcessorException.Warnings` list and wired unknown top-level field warnings into thrown config errors when validation errors are present. | | `processConfigErr.Errors` | golang/nats-server/server/errors.go:327 | PORTED | src/NATS.Server/Configuration/ConfigProcessor.cs:1437 | `ConfigProcessorException.Errors` property | | `errCtx` (type) | golang/nats-server/server/errors.go:332 | PORTED | tests/NATS.Server.Tests/InfrastructureGoParityTests.cs:1069 | `WrappedNatsException` (test-file-scoped) mirrors the error context wrapping; production code uses standard .NET exception chaining | | `NewErrorCtx` | golang/nats-server/server/errors.go:338 | PORTED | tests/NATS.Server.Tests/InfrastructureGoParityTests.cs:1069 | Equivalent via `new WrappedNatsException(inner, ctx)` in tests; production uses `new Exception(msg, inner)` | @@ -444,5 +444,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Ported config warning parity slice: added `ConfigWarningException`, `UnknownConfigFieldWarning`, and `ConfigProcessorException.Warnings`; wired unknown top-level fields as warning entries and added focused configuration parity tests. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated — all Go source files analyzed, 131 symbols classified | auto | +| 2026-02-25 | Ported missing `errors.go` parity literals into `ServerErrorConstants` and added targeted parity tests for the newly added constants | codex | diff --git a/gaps/websocket.md b/gaps/websocket.md index c310859..27121b3 100644 --- a/gaps/websocket.md +++ b/gaps/websocket.md @@ -90,7 +90,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the |-----------|:-------------|--------|:----------------|-------| | `wsOpCode` (type) | websocket.go:41 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:1` | Int constants replace Go type alias; `WsConstants.TextMessage`, `BinaryMessage`, etc. | | `websocket` (struct) | websocket.go:108 | PORTED | `src/NATS.Server/WebSocket/WsConnection.cs:8` | Fields mapped: `compress`, `maskread`/`maskwrite`, `browser`, `nocompfrag`. Cookie fields moved to `WsUpgradeResult`. `frames`/`fs` buffer management is now in `WsConnection.WriteAsync`. `compressor` (reuse) is NOT pooled — recreated per call. | -| `srvWebsocket` (struct) | websocket.go:126 | PARTIAL | `src/NATS.Server/NatsServer.cs:538` | `_wsListener`, `_options.WebSocket` cover port/host/tls; `allowedOrigins` managed via `WsOriginChecker`. `connectURLsMap` ref-count URL set and `authOverride` flag are MISSING — not ported. | +| `srvWebsocket` (struct) | websocket.go:126 | PARTIAL | `src/NATS.Server/NatsServer.cs:538` | `_wsListener`, `_options.WebSocket` cover port/host/tls; `allowedOrigins` managed via `WsOriginChecker`; explicit `authOverride` flag is now computed via `WsAuthConfig.Apply(...)`. `connectURLsMap` ref-count URL set is still missing. | | `allowedOrigin` (struct) | websocket.go:145 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:80` | Private `AllowedOrigin` record struct with `Scheme` and `Port`. | | `wsUpgradeResult` (struct) | websocket.go:150 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:346` | `WsUpgradeResult` readonly record struct with equivalent fields. `kind` maps to `WsClientKind` enum. | | `wsReadInfo` (struct) | websocket.go:156 | PORTED | `src/NATS.Server/WebSocket/WsReadInfo.cs:10` | All fields ported: `rem`→`Remaining`, `fs`→`FrameStart`, `ff`→`FirstFrame`, `fc`→`FrameCompressed`, `mask`→`ExpectMask`, `mkpos`→`MaskKeyPos`, `mkey`→`MaskKey`, `cbufs`→`CompressedBuffers`, `coff`→`CompressedOffset`. Extra .NET fields added for control frame output. | @@ -104,7 +104,7 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `decompressorPool` | websocket.go:99 | PARTIAL | `src/NATS.Server/WebSocket/WsCompression.cs:193` | Go uses `sync.Pool` for `flate.Reader` reuse. .NET creates a new `DeflateStream` per decompression call — no pooling. Functional but slightly less efficient under high load. | | `compressLastBlock` | websocket.go:100 | PORTED | `src/NATS.Server/WebSocket/WsConstants.cs:62` | .NET uses 4-byte `DecompressTrailer` (sync marker only); Go uses 9-byte block. Both work correctly — difference is .NET `DeflateStream` does not need the final stored block. | | `wsGUID` | websocket.go:103 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:177` | Inline string literal in `ComputeAcceptKey`. | -| `wsTestRejectNoMasking` | websocket.go:106 | MISSING | — | Test-only hook to force masking rejection. No equivalent test hook in .NET. | +| `wsTestRejectNoMasking` | websocket.go:106 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:14` | Added test hook `RejectNoMaskingForTest`; when set, no-masking leaf upgrade requests are explicitly rejected | #### Methods on `wsReadInfo` @@ -148,16 +148,16 @@ Add rows to the Gap Inventory table below. Group by Go source file. Include the | `srvWebsocket.checkOrigin()` | websocket.go:933 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:32` | `WsOriginChecker.CheckOrigin()` — same-origin and allowed-list checks. Go checks `r.TLS != nil` for TLS detection; .NET uses `isTls` parameter passed at call site. | | `wsGetHostAndPort()` | websocket.go:985 | PORTED | `src/NATS.Server/WebSocket/WsOriginChecker.cs:65` | `WsOriginChecker.GetHostAndPort()` and `ParseHostPort()` — missing-port defaults to 80/443 by TLS flag. | | `wsAcceptKey()` | websocket.go:1004 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:175` | `WsUpgrade.ComputeAcceptKey()` — SHA-1 of key + GUID, base64 encoded. | -| `wsMakeChallengeKey()` | websocket.go:1011 | MISSING | — | Generates a random 16-byte base64 client key for outbound WS connections (leaf node acting as WS client). No .NET equivalent. Needed when .NET server connects outbound as a WS leaf. | -| `validateWebsocketOptions()` | websocket.go:1020 | PARTIAL | `src/NATS.Server/WebSocket/WebSocketTlsConfig.cs:24` | `WebSocketTlsConfig.Validate()` checks cert+key pair consistency. Full Go validation (TLS required unless NoTLS, AllowedOrigins parseable, NoAuthUser in users list, Token/Username incompatible with users/nkeys, JWTCookie requires TrustedOperators, TLSPinnedCerts, reserved header names) is MISSING from .NET. | +| `wsMakeChallengeKey()` | websocket.go:1011 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:192` | Added `MakeChallengeKey()` generating a random base64-encoded 16-byte challenge nonce | +| `validateWebsocketOptions()` | websocket.go:1020 | PORTED | `src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs:11` | Added `WebSocketOptionsValidator.Validate(NatsOptions)` covering TLS cert/key requirement, allowed-origin URI parsing, `NoAuthUser` membership, username/token conflicts with users/nkeys, `JwtCookie` trusted-operator requirement, TLS pinned-cert validation, and reserved response-header protection. Startup now enforces this validation in `NatsServer.StartAsync()` before opening WS listener (`src/NATS.Server/NatsServer.cs:631`). | | `Server.wsSetOriginOptions()` | websocket.go:1083 | PARTIAL | `src/NATS.Server/WebSocket/WsUpgrade.cs:49` | Origin checking is constructed inline in `TryUpgradeAsync` from `options.SameOrigin` and `options.AllowedOrigins`. The Go method persists parsed origins in `srvWebsocket.allowedOrigins` map and supports hot-reload. .NET constructs a `WsOriginChecker` per request — no hot-reload support, but functionally equivalent for initial config. | | `Server.wsSetHeadersOptions()` | websocket.go:1111 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:141` | Custom headers applied inline in `TryUpgradeAsync` from `options.Headers`. | -| `Server.wsConfigAuth()` | websocket.go:1131 | MISSING | — | Sets `srvWebsocket.authOverride` flag based on username/token/noAuthUser presence. No equivalent flag computation in .NET — `WebSocketOptions` properties are read directly. Functionally equivalent but the explicit override flag is absent. | +| `Server.wsConfigAuth()` | websocket.go:1131 | PORTED | `src/NATS.Server/WebSocket/WsAuthConfig.cs:5` | Added explicit auth-override computation (`Username`/`Token`/`NoAuthUser`) and startup application via `NatsServer.StartAsync()` before WS listener initialization | | `Server.startWebsocketServer()` | websocket.go:1137 | PORTED | `src/NATS.Server/NatsServer.cs:538` | `NatsServer.StartAsync()` section at line 538 sets up the WS listener, logs, and launches `RunWebSocketAcceptLoopAsync`. Go uses `http.Server` + mux; .NET uses raw `TcpListener`/`Socket.AcceptAsync`. LEAF and MQTT routing at connection time is PARTIAL — LEAF path is wired (`WsClientKind.Leaf`) but MQTT is not handled in `AcceptWebSocketClientAsync`. `lame-duck` / `ldmCh` signaling is MISSING. | | `Server.wsGetTLSConfig()` | websocket.go:1264 | PARTIAL | `src/NATS.Server/NatsServer.cs:807` | TLS is applied once at accept time via `TlsConnectionWrapper.NegotiateAsync`. Go uses `GetConfigForClient` callback for hot-reload TLS config. .NET does not support hot TLS config reload for WS. | | `Server.createWSClient()` | websocket.go:1273 | PORTED | `src/NATS.Server/NatsServer.cs:799` | `AcceptWebSocketClientAsync()` — creates `WsConnection`, constructs `NatsClient`, wires `IsWebSocket`/`WsInfo`, registers client. Go also sends INFO immediately and sets auth timer; .NET's `NatsClient.RunAsync()` handles INFO send and auth timer. | -| `isWSURL()` | websocket.go:1544 | MISSING | — | Helper to detect `ws://` scheme in a URL. Used by leaf node / route URL parsing. No .NET equivalent — not yet needed since outbound WS connections are not implemented. | -| `isWSSURL()` | websocket.go:1548 | MISSING | — | Helper to detect `wss://` scheme in a URL. Same as above — not yet needed. | +| `isWSURL()` | websocket.go:1544 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:203` | Added helper to detect absolute `ws://` URLs via URI scheme parsing | +| `isWSSURL()` | websocket.go:1548 | PORTED | `src/NATS.Server/WebSocket/WsUpgrade.cs:215` | Added helper to detect absolute `wss://` URLs via URI scheme parsing | --- @@ -184,5 +184,7 @@ After porting work is completed: | Date | Change | By | |------|--------|----| +| 2026-02-26 | Ported `validateWebsocketOptions()` parity by adding `WebSocketOptionsValidator`, wiring startup enforcement, and adding focused validator tests including TLS pinned cert validation. | codex | +| 2026-02-25 | Ported `Server.wsConfigAuth()` parity by adding `WsAuthConfig` auth-override computation and applying it during WS startup; added `WebSocketOptions.AuthOverride` plus focused tests. | codex | | 2026-02-25 | File created with LLM analysis instructions | auto | | 2026-02-25 | Full gap inventory populated: 37 Go symbols classified (26 PORTED, 7 PARTIAL, 4 MISSING, 0 NOT_APPLICABLE, 0 DEFERRED) | auto | diff --git a/src/NATS.Server.Host/Program.cs b/src/NATS.Server.Host/Program.cs index 0d34632..cbe63d6 100644 --- a/src/NATS.Server.Host/Program.cs +++ b/src/NATS.Server.Host/Program.cs @@ -3,6 +3,27 @@ using NATS.Server.Configuration; using Serilog; using Serilog.Sinks.SystemConsole.Themes; +static void PrintUsage() +{ + Console.WriteLine("NATS.Server.Host"); + Console.WriteLine("Usage: nats-server [options]"); + Console.WriteLine(" -h, --help Show this help"); + Console.WriteLine(" -c Config file path"); + Console.WriteLine(" -p, --port Client listen port"); + Console.WriteLine(" -a, --addr Client listen host"); + Console.WriteLine(" -m, --http_port Monitoring HTTP port"); + Console.WriteLine(" --https_port Monitoring HTTPS port"); + Console.WriteLine(" -l, --log Log file path"); + Console.WriteLine(" -D, --debug Enable debug logging"); + Console.WriteLine(" -V, --trace Enable trace logging"); +} + +if (args.Any(a => a is "-h" or "--help")) +{ + PrintUsage(); + return; +} + // First pass: scan args for -c flag to get config file path string? configFile = null; for (int i = 0; i < args.Length; i++) diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index afe5b03..55d54ae 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -8,6 +8,7 @@ public sealed class Account : IDisposable { public const string GlobalAccountName = "$G"; public const string SystemAccountName = "$SYS"; + public const string ClientInfoHdr = "Nats-Request-Info"; public string Name { get; } public SubList SubList { get; } = new(); @@ -789,6 +790,100 @@ public sealed class Account : IDisposable return matchResult.PlainSubs.Length > 0 || matchResult.QueueSubs.Length > 0; } + /// + /// Returns true if this account has at least one matching subscription for the given subject. + /// Go reference: accounts.go SubscriptionInterest. + /// + public bool SubscriptionInterest(string subject) => Interest(subject) > 0; + + /// + /// Returns the total number of matching subscriptions (plain + queue) for the given subject. + /// Go reference: accounts.go Interest. + /// + public int Interest(string subject) + { + var (plainCount, queueCount) = SubList.NumInterest(subject); + return plainCount + queueCount; + } + + /// + /// Returns the total number of outstanding response mappings for service exports. + /// Go reference: accounts.go NumPendingAllResponses. + /// + public int NumPendingAllResponses() => NumPendingResponses(string.Empty); + + /// + /// Returns the number of outstanding response mappings for service exports. + /// When is empty, counts all mappings. + /// Go reference: accounts.go NumPendingResponses. + /// + public int NumPendingResponses(string filter) + { + if (string.IsNullOrEmpty(filter)) + return Exports.Responses.Count; + + var se = GetServiceExportEntry(filter); + if (se == null) + return 0; + + var count = 0; + foreach (var (_, si) in Exports.Responses) + { + if (ReferenceEquals(si.Export, se)) + count++; + } + + return count; + } + + /// + /// Returns the number of configured service import subjects. + /// Go reference: accounts.go NumServiceImports. + /// + public int NumServiceImports() => Imports.Services.Count; + + /// + /// Removes a response service import mapping. + /// Go reference: accounts.go removeRespServiceImport. + /// + public void RemoveRespServiceImport(ServiceImport? serviceImport, ResponseServiceImportRemovalReason reason = ResponseServiceImportRemovalReason.Ok) + { + if (serviceImport == null) + return; + + string? replyPrefix = null; + foreach (var (prefix, si) in Exports.Responses) + { + if (ReferenceEquals(si, serviceImport)) + { + replyPrefix = prefix; + break; + } + } + + if (replyPrefix == null) + return; + + // Current parity scope removes the response mapping. Reason-specific + // metrics/latency side effects are tracked separately. + ResponseRouter.CleanupResponse(this, replyPrefix, serviceImport); + _ = reason; + } + + private ServiceExport? GetServiceExportEntry(string subject) + { + if (Exports.Services.TryGetValue(subject, out var exact)) + return exact; + + foreach (var (pattern, export) in Exports.Services) + { + if (SubjectMatch.MatchLiteral(subject, pattern)) + return export; + } + + return null; + } + /// /// Returns all service import subjects registered on this account that are currently /// shadowed by a local subscription in the SubList. @@ -945,6 +1040,17 @@ public sealed record ActivationCheckResult( DateTime? ExpiresAt, TimeSpan? TimeToExpiry); +/// +/// Reason for removing a response service import. +/// Go reference: accounts.go rsiReason enum. +/// +public enum ResponseServiceImportRemovalReason +{ + Ok = 0, + NoDelivery = 1, + Timeout = 2, +} + /// /// Snapshot of account JWT claim fields used for hot-reload diff detection. /// Go reference: server/accounts.go — AccountClaims / jwt.AccountClaims fields applied in updateAccountClaimsWithRefresh (~line 3374). diff --git a/src/NATS.Server/Auth/AuthService.cs b/src/NATS.Server/Auth/AuthService.cs index 9a687e7..1a6eef5 100644 --- a/src/NATS.Server/Auth/AuthService.cs +++ b/src/NATS.Server/Auth/AuthService.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using NATS.Server.Protocol; namespace NATS.Server.Auth; @@ -33,11 +34,13 @@ public sealed class AuthService var authRequired = false; var nonceRequired = false; Dictionary? usersMap = null; + var users = NormalizeUsers(options.Users); + var nkeys = NormalizeNKeys(options.NKeys); // TLS certificate mapping (highest priority when enabled) - if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 }) + if (options.TlsMap && options.TlsVerify && users is { Count: > 0 }) { - authenticators.Add(new TlsMapAuthenticator(options.Users)); + authenticators.Add(new TlsMapAuthenticator(users)); authRequired = true; } @@ -63,19 +66,19 @@ public sealed class AuthService // Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword - if (options.NKeys is { Count: > 0 }) + if (nkeys is { Count: > 0 }) { - authenticators.Add(new NKeyAuthenticator(options.NKeys)); + authenticators.Add(new NKeyAuthenticator(nkeys)); authRequired = true; nonceRequired = true; } - if (options.Users is { Count: > 0 }) + if (users is { Count: > 0 }) { - authenticators.Add(new UserPasswordAuthenticator(options.Users)); + authenticators.Add(new UserPasswordAuthenticator(users)); authRequired = true; usersMap = new Dictionary(StringComparer.Ordinal); - foreach (var u in options.Users) + foreach (var u in users) usersMap[u.Username] = u; } @@ -169,4 +172,77 @@ public sealed class AuthService .Replace('+', '-') .Replace('/', '_'); } + + private static IReadOnlyList? NormalizeUsers(IReadOnlyList? users) + { + if (users is null) + return null; + + var normalized = new List(users.Count); + foreach (var user in users) + { + normalized.Add(new User + { + Username = user.Username, + Password = user.Password, + Account = string.IsNullOrWhiteSpace(user.Account) ? Account.GlobalAccountName : user.Account, + Permissions = NormalizePermissions(user.Permissions), + ConnectionDeadline = user.ConnectionDeadline, + AllowedConnectionTypes = user.AllowedConnectionTypes, + ProxyRequired = user.ProxyRequired, + }); + } + + return normalized; + } + + private static IReadOnlyList? NormalizeNKeys(IReadOnlyList? nkeys) + { + if (nkeys is null) + return null; + + var normalized = new List(nkeys.Count); + foreach (var nkey in nkeys) + { + normalized.Add(new NKeyUser + { + Nkey = nkey.Nkey, + Account = string.IsNullOrWhiteSpace(nkey.Account) ? Account.GlobalAccountName : nkey.Account, + Permissions = NormalizePermissions(nkey.Permissions), + SigningKey = nkey.SigningKey, + Issued = nkey.Issued, + AllowedConnectionTypes = nkey.AllowedConnectionTypes, + ProxyRequired = nkey.ProxyRequired, + }); + } + + return normalized; + } + + private static Permissions? NormalizePermissions(Permissions? permissions) + { + if (permissions?.Response is null) + return permissions; + + var publish = permissions.Publish; + if (publish?.Allow is null) + { + publish = new SubjectPermission + { + Allow = [], + Deny = publish?.Deny, + }; + } + + var response = permissions.Response; + var maxMsgs = response.MaxMsgs == 0 ? NatsProtocol.DefaultAllowResponseMaxMsgs : response.MaxMsgs; + var expires = response.Expires == TimeSpan.Zero ? NatsProtocol.DefaultAllowResponseExpiration : response.Expires; + + return new Permissions + { + Publish = publish, + Subscribe = permissions.Subscribe, + Response = new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires }, + }; + } } diff --git a/src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs b/src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs index 8bac70a..3a9873f 100644 --- a/src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs +++ b/src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs @@ -2,6 +2,10 @@ namespace NATS.Server.Auth; public sealed class ExternalAuthCalloutAuthenticator : IAuthenticator { + public const string AuthCalloutSubject = "$SYS.REQ.USER.AUTH"; + public const string AuthRequestSubject = "nats-authorization-request"; + public const string AuthRequestXKeyHeader = "Nats-Server-Xkey"; + private readonly IExternalAuthClient _client; private readonly TimeSpan _timeout; diff --git a/src/NATS.Server/Auth/NKeyUser.cs b/src/NATS.Server/Auth/NKeyUser.cs index ee9eca8..8714015 100644 --- a/src/NATS.Server/Auth/NKeyUser.cs +++ b/src/NATS.Server/Auth/NKeyUser.cs @@ -6,4 +6,7 @@ public sealed class NKeyUser public Permissions? Permissions { get; init; } public string? Account { get; init; } public string? SigningKey { get; init; } + public DateTimeOffset? Issued { get; init; } + public IReadOnlySet? AllowedConnectionTypes { get; init; } + public bool ProxyRequired { get; init; } } diff --git a/src/NATS.Server/Auth/TlsMapAuthenticator.cs b/src/NATS.Server/Auth/TlsMapAuthenticator.cs index b213e94..10b3338 100644 --- a/src/NATS.Server/Auth/TlsMapAuthenticator.cs +++ b/src/NATS.Server/Auth/TlsMapAuthenticator.cs @@ -40,9 +40,88 @@ public sealed class TlsMapAuthenticator : IAuthenticator if (cn != null && _usersByCn.TryGetValue(cn, out user)) return BuildResult(user); + // Try SAN-based values + var email = cert.GetNameInfo(X509NameType.EmailName, forIssuer: false); + if (!string.IsNullOrWhiteSpace(email) && _usersByCn.TryGetValue(email, out user)) + return BuildResult(user); + + var dns = cert.GetNameInfo(X509NameType.DnsName, forIssuer: false); + if (!string.IsNullOrWhiteSpace(dns) && _usersByCn.TryGetValue(dns, out user)) + return BuildResult(user); + + var uri = cert.GetNameInfo(X509NameType.UrlName, forIssuer: false); + if (!string.IsNullOrWhiteSpace(uri) && _usersByCn.TryGetValue(uri, out user)) + return BuildResult(user); + + // Match using full RDN + DC components if present. + var dcs = GetTlsAuthDcs(dn); + if (!string.IsNullOrEmpty(dcs)) + { + var rdnWithDcs = string.IsNullOrEmpty(dnString) ? dcs : $"{dnString},{dcs}"; + if (_usersByDn.TryGetValue(rdnWithDcs, out user)) + return BuildResult(user); + } + return null; } + internal static string GetTlsAuthDcs(X500DistinguishedName dn) + { + if (string.IsNullOrWhiteSpace(dn.Name)) + return string.Empty; + + var dcs = new List(); + foreach (var rdn in dn.Name.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!rdn.StartsWith("DC=", StringComparison.OrdinalIgnoreCase)) + continue; + + dcs.Add("DC=" + rdn[3..].Trim()); + } + + return string.Join(",", dcs); + } + + internal static string[] DnsAltNameLabels(string dnsAltName) + { + if (string.IsNullOrWhiteSpace(dnsAltName)) + return []; + + return dnsAltName.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries); + } + + internal static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList urls) + { + foreach (var url in urls) + { + if (url == null) + continue; + + var hostLabels = url.DnsSafeHost.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries); + if (hostLabels.Length != dnsAltNameLabels.Length) + continue; + + var i = 0; + if (dnsAltNameLabels.Length > 0 && dnsAltNameLabels[0] == "*") + i = 1; + + var matched = true; + for (; i < dnsAltNameLabels.Length; i++) + { + if (!string.Equals(dnsAltNameLabels[i], hostLabels[i], StringComparison.Ordinal)) + { + matched = false; + break; + } + } + + if (matched) + return true; + } + + return false; + } + private static string? ExtractCn(X500DistinguishedName dn) { var dnString = dn.Name; diff --git a/src/NATS.Server/Auth/User.cs b/src/NATS.Server/Auth/User.cs index 0f7d315..ee0b616 100644 --- a/src/NATS.Server/Auth/User.cs +++ b/src/NATS.Server/Auth/User.cs @@ -7,4 +7,6 @@ public sealed class User public Permissions? Permissions { get; init; } public string? Account { get; init; } public DateTimeOffset? ConnectionDeadline { get; init; } + public IReadOnlySet? AllowedConnectionTypes { get; init; } + public bool ProxyRequired { get; init; } } diff --git a/src/NATS.Server/ClientConnectionType.cs b/src/NATS.Server/ClientConnectionType.cs new file mode 100644 index 0000000..b472e41 --- /dev/null +++ b/src/NATS.Server/ClientConnectionType.cs @@ -0,0 +1,17 @@ +namespace NATS.Server; + +// Go reference: server/client.go NON_CLIENT/NATS/MQTT/WS constants. +public enum ClientConnectionType +{ + NonClient = 0, + Nats = 1, + Mqtt = 2, + WebSocket = 3, +} + +// Go reference: server/client.go ClientProtoZero/ClientProtoInfo constants. +public static class ClientProtocolVersion +{ + public const int ClientProtoZero = 0; + public const int ClientProtoInfo = 1; +} diff --git a/src/NATS.Server/Configuration/ConfigProcessor.cs b/src/NATS.Server/Configuration/ConfigProcessor.cs index 14d9cae..560c998 100644 --- a/src/NATS.Server/Configuration/ConfigProcessor.cs +++ b/src/NATS.Server/Configuration/ConfigProcessor.cs @@ -4,6 +4,8 @@ using System.Globalization; using System.Text.RegularExpressions; using NATS.Server.Auth; +using NATS.Server.JetStream; +using NATS.Server.Tls; namespace NATS.Server.Configuration; @@ -43,12 +45,13 @@ public static class ConfigProcessor public static void ApplyConfig(Dictionary config, NatsOptions opts) { var errors = new List(); + var warnings = new List(); foreach (var (key, value) in config) { try { - ProcessKey(key, value, opts, errors); + ProcessKey(key, value, opts, errors, warnings); } catch (Exception ex) { @@ -58,11 +61,16 @@ public static class ConfigProcessor if (errors.Count > 0) { - throw new ConfigProcessorException("Configuration errors", errors); + throw new ConfigProcessorException("Configuration errors", errors, warnings); } } - private static void ProcessKey(string key, object? value, NatsOptions opts, List errors) + private static void ProcessKey( + string key, + object? value, + NatsOptions opts, + List errors, + List warnings) { // Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries), // but we normalize here for the switch statement. @@ -277,8 +285,15 @@ public static class ConfigProcessor ParseWebSocket(wsDict, opts, errors); break; - // Unknown keys silently ignored (accounts, resolver, operator, etc.) + // Accounts block — each key is an account name containing users/limits + case "accounts": + if (value is Dictionary accountsDict) + ParseAccounts(accountsDict, opts, errors); + break; + + // Unknown keys silently ignored (resolver, operator, etc.) default: + warnings.Add(new UnknownConfigFieldWarning(key).Message); break; } } @@ -745,6 +760,9 @@ public static class ConfigProcessor case "store_dir": options.StoreDir = ToString(value); break; + case "domain": + options.Domain = ToString(value); + break; case "max_mem_store": try { @@ -766,6 +784,68 @@ public static class ConfigProcessor errors.Add($"Invalid jetstream.max_file_store: {ex.Message}"); } + break; + case "sync_interval": + try + { + options.SyncInterval = ParseDuration(value); + } + catch (Exception ex) + { + errors.Add($"Invalid jetstream.sync_interval: {ex.Message}"); + } + + break; + case "sync_always": + options.SyncAlways = ToBool(value); + break; + case "compress_ok": + options.CompressOk = ToBool(value); + break; + case "unique_tag": + options.UniqueTag = ToString(value); + break; + case "strict": + options.Strict = ToBool(value); + break; + case "max_ack_pending": + options.MaxAckPending = ToInt(value); + break; + case "memory_max_stream_bytes": + try + { + options.MemoryMaxStreamBytes = ParseByteSize(value); + } + catch (Exception ex) + { + errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}"); + } + + break; + case "store_max_stream_bytes": + try + { + options.StoreMaxStreamBytes = ParseByteSize(value); + } + catch (Exception ex) + { + errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}"); + } + + break; + case "max_bytes_required": + options.MaxBytesRequired = ToBool(value); + break; + case "tiers": + if (value is Dictionary tiers) + { + foreach (var (tierName, rawTier) in tiers) + { + if (rawTier is Dictionary tierDict) + options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors); + } + } + break; } } @@ -773,6 +853,47 @@ public static class ConfigProcessor return options; } + private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary dict, List errors) + { + var tier = new JetStreamTier { Name = tierName }; + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "memory" or "max_memory": + try + { + tier.Memory = ParseByteSize(value); + } + catch (Exception ex) + { + errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}"); + } + + break; + case "store" or "max_store": + try + { + tier.Store = ParseByteSize(value); + } + catch (Exception ex) + { + errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}"); + } + + break; + case "streams" or "max_streams": + tier.Streams = ToInt(value); + break; + case "consumers" or "max_consumers": + tier.Consumers = ToInt(value); + break; + } + } + + return tier; + } + // ─── Authorization parsing ───────────────────────────────────── private static void ParseAuthorization(Dictionary dict, NatsOptions opts, List errors) @@ -831,12 +952,80 @@ public static class ConfigProcessor } } + // ─── Accounts parsing ────────────────────────────────────────── + + /// + /// Parses the top-level "accounts" block. Each key is an account name, and each + /// value is a dictionary that may contain "users" (array) and account-level limits. + /// Users are stamped with the account name and appended to opts.Users / opts.NKeys. + /// Go reference: opts.go — configureAccounts / parseAccounts. + /// + private static void ParseAccounts(Dictionary accountsDict, NatsOptions opts, List errors) + { + opts.Accounts ??= new Dictionary(); + + foreach (var (accountName, accountValue) in accountsDict) + { + if (accountValue is not Dictionary acctDict) + { + errors.Add($"Expected account '{accountName}' value to be a map"); + continue; + } + + int maxConnections = 0; + int maxSubscriptions = 0; + List? userList = null; + + foreach (var (key, value) in acctDict) + { + switch (key.ToLowerInvariant()) + { + case "users": + if (value is List ul) + userList = ul; + break; + case "max_connections" or "max_conns": + maxConnections = ToInt(value); + break; + case "max_subscriptions" or "max_subs": + maxSubscriptions = ToInt(value); + break; + } + } + + opts.Accounts[accountName] = new AccountConfig + { + MaxConnections = maxConnections, + MaxSubscriptions = maxSubscriptions, + }; + + if (userList is not null) + { + var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName); + + if (plainUsers.Count > 0) + { + var existing = opts.Users?.ToList() ?? []; + existing.AddRange(plainUsers); + opts.Users = existing; + } + + if (nkeyUsers.Count > 0) + { + var existing = opts.NKeys?.ToList() ?? []; + existing.AddRange(nkeyUsers); + opts.NKeys = existing; + } + } + } + } + /// /// Splits a users array into plain users and NKey users. /// An entry with an "nkey" field is an NKey user; entries with "user" are plain users. /// Go reference: opts.go — parseUsers (lines ~2500-2700). /// - private static (List PlainUsers, List NkeyUsers) ParseUsersAndNkeys(List list, List errors) + private static (List PlainUsers, List NkeyUsers) ParseUsersAndNkeys(List list, List errors, string? defaultAccount = null) { var plainUsers = new List(); var nkeyUsers = new List(); @@ -888,7 +1077,7 @@ public static class ConfigProcessor { Nkey = nkey, Permissions = permissions, - Account = account, + Account = account ?? defaultAccount, }); continue; } @@ -903,7 +1092,7 @@ public static class ConfigProcessor { Username = username, Password = password ?? string.Empty, - Account = account, + Account = account ?? defaultAccount, Permissions = permissions, }); } @@ -1087,6 +1276,9 @@ public static class ConfigProcessor case "handshake_first_fallback": opts.TlsHandshakeFirstFallback = ParseDuration(value); break; + case "ocsp_peer": + ParseOcspPeer(value, opts, errors); + break; default: // Unknown TLS keys silently ignored break; @@ -1094,6 +1286,31 @@ public static class ConfigProcessor } } + private static void ParseOcspPeer(object? value, NatsOptions opts, List errors) + { + switch (value) + { + case bool verify: + opts.OcspPeerVerify = verify; + return; + case Dictionary dict: + try + { + var cfg = OCSPPeerConfig.Parse(dict); + opts.OcspPeerVerify = cfg.Verify; + } + catch (FormatException ex) + { + errors.Add(ex.Message); + } + + return; + default: + errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]"); + return; + } + } + // ─── Tags parsing ────────────────────────────────────────────── private static void ParseTags(Dictionary dict, NatsOptions opts) @@ -1431,8 +1648,28 @@ public static class ConfigProcessor /// Thrown when one or more configuration validation errors are detected. /// All errors are collected rather than failing on the first one. /// -public sealed class ConfigProcessorException(string message, List errors) +public sealed class ConfigProcessorException(string message, List errors, List? warnings = null) : Exception(message) { public IReadOnlyList Errors => errors; + public IReadOnlyList Warnings => warnings ?? []; +} + +/// +/// Represents a non-fatal configuration warning. +/// Go reference: configWarningErr. +/// +public class ConfigWarningException(string message, string? source = null) : Exception(message) +{ + public string? SourceLocation { get; } = source; +} + +/// +/// Warning used when an unknown config field is encountered. +/// Go reference: unknownConfigFieldErr. +/// +public sealed class UnknownConfigFieldWarning(string field, string? source = null) + : ConfigWarningException($"unknown field {field}", source) +{ + public string Field { get; } = field; } diff --git a/src/NATS.Server/Configuration/GatewayOptions.cs b/src/NATS.Server/Configuration/GatewayOptions.cs index f85e9ec..248f529 100644 --- a/src/NATS.Server/Configuration/GatewayOptions.cs +++ b/src/NATS.Server/Configuration/GatewayOptions.cs @@ -26,6 +26,115 @@ public sealed class GatewayOptions /// public sealed class RemoteGatewayOptions { + private int _connAttempts; + public string? Name { get; set; } public List Urls { get; set; } = []; + public bool Implicit { get; set; } + public byte[]? Hash { get; set; } + public byte[]? OldHash { get; set; } + public string? TlsName { get; private set; } + public bool VarzUpdateUrls { get; set; } + + /// + /// Deep clone helper for remote gateway options. + /// Go reference: RemoteGatewayOpts.clone. + /// + public RemoteGatewayOptions Clone() + { + return new RemoteGatewayOptions + { + Name = Name, + Urls = [.. Urls], + Implicit = Implicit, + Hash = Hash == null ? null : [.. Hash], + OldHash = OldHash == null ? null : [.. OldHash], + TlsName = TlsName, + VarzUpdateUrls = VarzUpdateUrls, + }; + } + + public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts); + + public int GetConnAttempts() => Volatile.Read(ref _connAttempts); + + public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0); + + public bool IsImplicit() => Implicit; + + public List GetUrls(Random? random = null) + { + var urls = new List(); + foreach (var url in Urls) + { + if (TryNormalizeRemoteUrl(url, out var uri)) + urls.Add(uri); + } + + random ??= Random.Shared; + for (var i = urls.Count - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (urls[i], urls[j]) = (urls[j], urls[i]); + } + + return urls; + } + + public List GetUrlsAsStrings() + { + var result = new List(); + foreach (var uri in GetUrls()) + result.Add($"{uri.Scheme}://{uri.Authority}"); + return result; + } + + public void UpdateUrls(IEnumerable configuredUrls, IEnumerable discoveredUrls) + { + var merged = new List(); + AddUrlsInternal(merged, configuredUrls); + AddUrlsInternal(merged, discoveredUrls); + Urls = merged; + } + + public void SaveTlsHostname(string url) + { + if (TryNormalizeRemoteUrl(url, out var uri)) + TlsName = uri.Host; + } + + public void AddUrls(IEnumerable discoveredUrls) + { + AddUrlsInternal(Urls, discoveredUrls); + } + + private static void AddUrlsInternal(List target, IEnumerable urls) + { + var seen = new HashSet(target, StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (!TryNormalizeRemoteUrl(url, out var uri)) + continue; + + var normalized = $"{uri.Scheme}://{uri.Authority}"; + if (seen.Add(normalized)) + target.Add(normalized); + } + } + + private static bool TryNormalizeRemoteUrl(string? raw, out Uri uri) + { + uri = default!; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + var normalized = raw.Contains("://", StringComparison.Ordinal) ? raw : $"nats://{raw}"; + if (Uri.TryCreate(normalized, UriKind.Absolute, out var parsed) && parsed is not null) + { + uri = parsed; + return true; + } + + return false; + } } diff --git a/src/NATS.Server/Configuration/JetStreamOptions.cs b/src/NATS.Server/Configuration/JetStreamOptions.cs index 6655a47..7838200 100644 --- a/src/NATS.Server/Configuration/JetStreamOptions.cs +++ b/src/NATS.Server/Configuration/JetStreamOptions.cs @@ -4,6 +4,11 @@ namespace NATS.Server.Configuration; // Controls the lifecycle parameters for the JetStream subsystem. public sealed class JetStreamOptions { + // Go: server/jetstream.go constants (dynJetStreamConfig defaults) + public const string JetStreamStoreDir = "jetstream"; + public const long JetStreamMaxStoreDefault = 1L << 40; // 1 TiB + public const long JetStreamMaxMemDefault = 256L * 1024 * 1024; // 256 MiB + /// /// Directory where JetStream persists stream data. /// Maps to Go's JetStreamConfig.StoreDir (jetstream.go:enableJetStream:430). @@ -41,4 +46,64 @@ public sealed class JetStreamOptions /// Maps to Go's Options.JetStreamDomain (opts.go). /// public string? Domain { get; set; } + + /// + /// File-store sync interval. + /// Go reference: server/jetstream.go JetStreamConfig.SyncInterval. + /// + public TimeSpan SyncInterval { get; set; } + + /// + /// Forces sync on each write when true. + /// Go reference: server/jetstream.go JetStreamConfig.SyncAlways. + /// + public bool SyncAlways { get; set; } + + /// + /// Whether compression is allowed for JetStream replication/storage paths. + /// Go reference: server/jetstream.go JetStreamConfig.CompressOK. + /// + public bool CompressOk { get; set; } + + /// + /// Unique placement tag used in clustered deployments. + /// Go reference: server/jetstream.go JetStreamConfig.UniqueTag. + /// + public string? UniqueTag { get; set; } + + /// + /// Enables strict validation mode for JetStream. + /// Go reference: server/jetstream.go JetStreamConfig.Strict. + /// + public bool Strict { get; set; } + + /// + /// Account-level maximum pending acknowledgements. + /// Go reference: server/jetstream.go JetStreamAccountLimits.MaxAckPending. + /// + public int MaxAckPending { get; set; } + + /// + /// Maximum bytes allowed per memory-backed stream. + /// Go reference: server/jetstream.go JetStreamAccountLimits.MemoryMaxStreamBytes. + /// + public long MemoryMaxStreamBytes { get; set; } + + /// + /// Maximum bytes allowed per file-backed stream. + /// Go reference: server/jetstream.go JetStreamAccountLimits.StoreMaxStreamBytes. + /// + public long StoreMaxStreamBytes { get; set; } + + /// + /// When true, stream configs must specify explicit MaxBytes. + /// Go reference: server/jetstream.go JetStreamAccountLimits.MaxBytesRequired. + /// + public bool MaxBytesRequired { get; set; } + + /// + /// Optional per-tier JetStream limits keyed by tier name. + /// Go reference: server/jetstream.go JetStreamAccountLimits tiers. + /// + public Dictionary Tiers { get; set; } = new(StringComparer.Ordinal); } diff --git a/src/NATS.Server/Configuration/LeafNodeOptions.cs b/src/NATS.Server/Configuration/LeafNodeOptions.cs index 5c784a2..907608d 100644 --- a/src/NATS.Server/Configuration/LeafNodeOptions.cs +++ b/src/NATS.Server/Configuration/LeafNodeOptions.cs @@ -17,6 +17,109 @@ public sealed class RemoteLeafOptions /// Whether to not randomize URL order. public bool DontRandomize { get; init; } + + private int _urlIndex = -1; + private TimeSpan _connectDelay; + private Timer? _migrateTimer; + + /// Last URL selected by . + public string? CurrentUrl { get; private set; } + + /// Saved TLS hostname for SNI usage on solicited connections. + public string? TlsName { get; private set; } + + /// Username parsed from URL user-info fallback. + public string? Username { get; private set; } + + /// Password parsed from URL user-info fallback. + public string? Password { get; private set; } + + /// + /// Returns the next URL using round-robin order and updates . + /// Go reference: leafnode.go leafNodeCfg.pickNextURL. + /// + public string PickNextUrl() + { + if (Urls.Count == 0) + throw new InvalidOperationException("No remote leaf URLs configured."); + + var idx = Interlocked.Increment(ref _urlIndex); + var next = Urls[idx % Urls.Count]; + CurrentUrl = next; + return next; + } + + /// + /// Returns the current selected URL, or null if no URL has been selected yet. + /// Go reference: leafnode.go leafNodeCfg.getCurrentURL. + /// + public string? GetCurrentUrl() => CurrentUrl; + + /// + /// Returns the currently configured reconnect/connect delay for this remote. + /// Go reference: leafnode.go leafNodeCfg.getConnectDelay. + /// + public TimeSpan GetConnectDelay() => _connectDelay; + + /// + /// Sets reconnect/connect delay for this remote. + /// Go reference: leafnode.go leafNodeCfg.setConnectDelay. + /// + public void SetConnectDelay(TimeSpan delay) => _connectDelay = delay; + + /// + /// Starts or replaces the JetStream migration timer callback for this remote leaf. + /// Go reference: leafnode.go leafNodeCfg.migrateTimer. + /// + public void StartMigrateTimer(TimerCallback callback, TimeSpan delay) + { + ArgumentNullException.ThrowIfNull(callback); + var timer = new Timer(callback, null, delay, Timeout.InfiniteTimeSpan); + var previous = Interlocked.Exchange(ref _migrateTimer, timer); + previous?.Dispose(); + } + + /// + /// Cancels the JetStream migration timer if active. + /// Go reference: leafnode.go leafNodeCfg.cancelMigrateTimer. + /// + public void CancelMigrateTimer() + { + var timer = Interlocked.Exchange(ref _migrateTimer, null); + timer?.Dispose(); + } + + /// + /// Saves TLS hostname from URL for future SNI usage. + /// Go reference: leafnode.go leafNodeCfg.saveTLSHostname. + /// + public void SaveTlsHostname(string url) + { + if (TryParseUrl(url, out var uri)) + TlsName = uri.Host; + } + + /// + /// Saves username/password from URL user info for fallback auth. + /// Go reference: leafnode.go leafNodeCfg.saveUserPassword. + /// + public void SaveUserPassword(string url) + { + if (!TryParseUrl(url, out var uri) || string.IsNullOrEmpty(uri.UserInfo)) + return; + + var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None); + Username = Uri.UnescapeDataString(parts[0]); + Password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + } + + private static bool TryParseUrl(string url, out Uri uri) + { + if (Uri.TryCreate(url, UriKind.Absolute, out uri!)) + return true; + + return Uri.TryCreate($"nats://{url}", UriKind.Absolute, out uri!); + } } public sealed class LeafNodeOptions diff --git a/src/NATS.Server/Configuration/NatsConfLexer.cs b/src/NATS.Server/Configuration/NatsConfLexer.cs index 2a17cab..832d063 100644 --- a/src/NATS.Server/Configuration/NatsConfLexer.cs +++ b/src/NATS.Server/Configuration/NatsConfLexer.cs @@ -1,6 +1,10 @@ // Port of Go conf/lex.go — state-machine tokenizer for NATS config files. // Reference: golang/nats-server/conf/lex.go +using System.Buffers; +using System.Globalization; +using System.Text; + namespace NATS.Server.Configuration; public sealed class NatsConfLexer @@ -145,16 +149,23 @@ public sealed class NatsConfLexer return Eof; } - if (_input[_pos] == '\n') + var span = _input.AsSpan(_pos); + var status = Rune.DecodeFromUtf16(span, out var rune, out var consumed); + if (status != OperationStatus.Done || consumed <= 0) + { + consumed = 1; + rune = new Rune(_input[_pos]); + } + + if (rune.Value == '\n') { _line++; _lstart = _pos; } - var c = _input[_pos]; - _width = 1; - _pos += _width; - return c; + _width = consumed; + _pos += consumed; + return rune.IsBmp ? (char)rune.Value : '\uFFFD'; } private void Ignore() @@ -186,6 +197,20 @@ public sealed class NatsConfLexer return null; } + private LexState? Errorf(string format, params object?[] args) + { + if (args.Length == 0) + return Errorf(format); + + var escapedArgs = new object?[args.Length]; + for (var i = 0; i < args.Length; i++) + { + escapedArgs[i] = args[i] is char c ? EscapeSpecial(c) : args[i]; + } + + return Errorf(string.Format(CultureInfo.InvariantCulture, format, escapedArgs)); + } + // --- Helper methods --- private static bool IsWhitespace(char c) => c is '\t' or ' '; @@ -1476,9 +1501,8 @@ public sealed class NatsConfLexer var r = lx.Peek(); if (IsNL(r) || r == Eof) { - // Consume the comment text but don't emit it as a user-visible token. - // Just ignore it and pop back. - lx.Ignore(); + // Match Go behavior: emit comment body as a text token. + lx.Emit(TokenType.Text); return lx.Pop(); } diff --git a/src/NATS.Server/Configuration/NatsConfParser.cs b/src/NATS.Server/Configuration/NatsConfParser.cs index e948bb8..037f038 100644 --- a/src/NATS.Server/Configuration/NatsConfParser.cs +++ b/src/NATS.Server/Configuration/NatsConfParser.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using System.Text.Json; namespace NATS.Server.Configuration; @@ -15,6 +16,7 @@ namespace NATS.Server.Configuration; /// public static class NatsConfParser { + private const string BcryptPrefix = "2a$"; // Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$' // and emits a Variable token whose value begins with "2a$" or "2b$". private const string BcryptPrefix2A = "2a$"; @@ -34,12 +36,24 @@ public static class NatsConfParser return state.Mapping; } + /// + /// Pedantic compatibility API (Go: ParseWithChecks). + /// Uses the same parser behavior as . + /// + public static Dictionary ParseWithChecks(string data) => Parse(data); + /// /// Parses a NATS configuration file into a dictionary. /// public static Dictionary ParseFile(string filePath) => ParseFile(filePath, includeDepth: 0); + /// + /// Pedantic compatibility API (Go: ParseFileWithChecks). + /// Uses the same parser behavior as . + /// + public static Dictionary ParseFileWithChecks(string filePath) => ParseFile(filePath); + private static Dictionary ParseFile(string filePath, int includeDepth) { var data = File.ReadAllText(filePath); @@ -68,6 +82,94 @@ public static class NatsConfParser return (state.Mapping, digest); } + /// + /// Pedantic compatibility API (Go: ParseFileWithChecksDigest). + /// + public static (Dictionary Config, string Digest) ParseFileWithChecksDigest(string filePath) + { + var data = File.ReadAllText(filePath); + var tokens = NatsConfLexer.Tokenize(data); + var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty; + var state = new ParserState(tokens, baseDir, [], includeDepth: 0); + state.Run(); + CleanupUsedEnvVars(state.Mapping); + + var digest = ComputeConfigDigest(state.Mapping); + return (state.Mapping, digest); + } + + // Go pedantic mode removes env-var wrapper nodes from parsed maps before digesting. + // The current parser does not persist env-wrapper nodes, so this remains a no-op hook. + private static void CleanupUsedEnvVars(Dictionary _) { } + + private static string ComputeConfigDigest(Dictionary config) + { + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + WriteCanonicalJsonValue(writer, config); + writer.Flush(); + } + + var hashBytes = SHA256.HashData(ms.ToArray()); + return "sha256:" + Convert.ToHexStringLower(hashBytes); + } + + private static void WriteCanonicalJsonValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + return; + case Dictionary map: + writer.WriteStartObject(); + foreach (var key in map.Keys.OrderBy(static k => k, StringComparer.Ordinal)) + { + writer.WritePropertyName(key); + WriteCanonicalJsonValue(writer, map[key]); + } + writer.WriteEndObject(); + return; + case List list: + writer.WriteStartArray(); + foreach (var item in list) + WriteCanonicalJsonValue(writer, item); + writer.WriteEndArray(); + return; + case IReadOnlyList stringList: + writer.WriteStartArray(); + foreach (var item in stringList) + writer.WriteStringValue(item); + writer.WriteEndArray(); + return; + case bool b: + writer.WriteBooleanValue(b); + return; + case int i: + writer.WriteNumberValue(i); + return; + case long l: + writer.WriteNumberValue(l); + return; + case double d: + writer.WriteNumberValue(d); + return; + case float f: + writer.WriteNumberValue(f); + return; + case decimal dec: + writer.WriteNumberValue(dec); + return; + case string s: + writer.WriteStringValue(s); + return; + default: + JsonSerializer.Serialize(writer, value, value.GetType()); + return; + } + } + /// /// Internal: parse an environment variable value by wrapping it in a synthetic /// key-value assignment and parsing it. Shares the parent's env var cycle tracker. @@ -99,6 +201,8 @@ public static class NatsConfParser // Key stack for map assignments. private readonly List _keys = new(4); + // Pedantic-mode key token stack (Go parser field: ikeys). + private readonly List _itemKeys = new(4); public Dictionary Mapping { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -182,6 +286,18 @@ public static class NatsConfParser return last; } + private void PushItemKey(Token token) => _itemKeys.Add(token); + + private Token PopItemKey() + { + if (_itemKeys.Count == 0) + return default; + + var last = _itemKeys[^1]; + _itemKeys.RemoveAt(_itemKeys.Count - 1); + return last; + } + private void SetValue(object? val) { // Array context: append the value. @@ -195,6 +311,7 @@ public static class NatsConfParser if (_ctx is Dictionary map) { var key = PopKey(); + _ = PopItemKey(); map[key] = val; return; } @@ -211,6 +328,7 @@ public static class NatsConfParser case TokenType.Key: PushKey(token.Value); + PushItemKey(token); break; case TokenType.String: @@ -262,6 +380,7 @@ public static class NatsConfParser break; case TokenType.Comment: + case TokenType.Text: // Skip comments entirely. break; @@ -347,7 +466,8 @@ public static class NatsConfParser // Special case: raw bcrypt strings ($2a$... or $2b$...). // The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$". - if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) || + if (varName.StartsWith(BcryptPrefix, StringComparison.Ordinal) || + varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) || varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal)) { SetValue("$" + varName); diff --git a/src/NATS.Server/Configuration/NatsConfToken.cs b/src/NATS.Server/Configuration/NatsConfToken.cs index 8054239..38eeea6 100644 --- a/src/NATS.Server/Configuration/NatsConfToken.cs +++ b/src/NATS.Server/Configuration/NatsConfToken.cs @@ -1,5 +1,7 @@ // Port of Go conf/lex.go token types. +using System.Text.Json; + namespace NATS.Server.Configuration; public enum TokenType @@ -7,6 +9,7 @@ public enum TokenType Error, Eof, Key, + Text, String, Bool, Integer, @@ -22,3 +25,34 @@ public enum TokenType } public readonly record struct Token(TokenType Type, string Value, int Line, int Position); + +/// +/// Pedantic token wrapper matching Go conf/parse.go token accessors. +/// +public sealed class PedanticToken +{ + private readonly Token _item; + private readonly object? _value; + private readonly bool _usedVariable; + private readonly string _sourceFile; + + public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "") + { + _item = item; + _value = value; + _usedVariable = usedVariable; + _sourceFile = sourceFile ?? string.Empty; + } + + public string MarshalJson() => JsonSerializer.Serialize(Value()); + + public object? Value() => _value ?? _item.Value; + + public int Line() => _item.Line; + + public bool IsUsedVariable() => _usedVariable; + + public string SourceFile() => _sourceFile; + + public int Position() => _item.Position; +} diff --git a/src/NATS.Server/Events/EventCompressor.cs b/src/NATS.Server/Events/EventCompressor.cs index 8252404..4df1ce9 100644 --- a/src/NATS.Server/Events/EventCompressor.cs +++ b/src/NATS.Server/Events/EventCompressor.cs @@ -1,10 +1,23 @@ // Go reference: server/events.go:2081-2090 — compressionType, snappyCompression, // and events.go:578-598 — internalSendLoop compression via s2.WriterSnappyCompat(). +using System.IO.Compression; using IronSnappy; namespace NATS.Server.Events; +/// +/// Compression encodings supported for event API responses. +/// Go reference: events.go compressionType constants. +/// +public enum EventCompressionType : sbyte +{ + None = 0, + Gzip = 1, + Snappy = 2, + Unsupported = 3, +} + /// /// Provides S2 (Snappy-compatible) compression for system event payloads. /// Maps to Go's compressionType / snappyCompression handling in events.go:2082-2098 @@ -12,6 +25,9 @@ namespace NATS.Server.Events; /// public static class EventCompressor { + public const string AcceptEncodingHeader = "Accept-Encoding"; + public const string ContentEncodingHeader = "Content-Encoding"; + // Default threshold: only compress payloads larger than this many bytes. // Compressing tiny payloads wastes CPU and may produce larger output. private const int DefaultThresholdBytes = 256; @@ -56,11 +72,23 @@ public static class EventCompressor /// Raw bytes to compress. /// Compressed bytes. Returns an empty array for empty input. public static byte[] Compress(ReadOnlySpan payload) + => Compress(payload, EventCompressionType.Snappy); + + /// + /// Compresses using the requested . + /// + public static byte[] Compress(ReadOnlySpan payload, EventCompressionType compression) { if (payload.IsEmpty) return []; - return Snappy.Encode(payload); + return compression switch + { + EventCompressionType.None => payload.ToArray(), + EventCompressionType.Gzip => CompressGzip(payload), + EventCompressionType.Snappy => Snappy.Encode(payload), + _ => throw new InvalidOperationException($"Unsupported compression type: {compression}."), + }; } /// @@ -71,11 +99,23 @@ public static class EventCompressor /// Decompressed bytes. Returns an empty array for empty input. /// Propagated from IronSnappy if data is corrupt. public static byte[] Decompress(ReadOnlySpan compressed) + => Decompress(compressed, EventCompressionType.Snappy); + + /// + /// Decompresses using the selected . + /// + public static byte[] Decompress(ReadOnlySpan compressed, EventCompressionType compression) { if (compressed.IsEmpty) return []; - return Snappy.Decode(compressed); + return compression switch + { + EventCompressionType.None => compressed.ToArray(), + EventCompressionType.Gzip => DecompressGzip(compressed), + EventCompressionType.Snappy => Snappy.Decode(compressed), + _ => throw new InvalidOperationException($"Unsupported compression type: {compression}."), + }; } /// @@ -105,6 +145,15 @@ public static class EventCompressor public static (byte[] Data, bool Compressed) CompressIfBeneficial( ReadOnlySpan payload, int thresholdBytes = DefaultThresholdBytes) + => CompressIfBeneficial(payload, EventCompressionType.Snappy, thresholdBytes); + + /// + /// Compresses using when payload size exceeds threshold. + /// + public static (byte[] Data, bool Compressed) CompressIfBeneficial( + ReadOnlySpan payload, + EventCompressionType compression, + int thresholdBytes = DefaultThresholdBytes) { if (!ShouldCompress(payload.Length, thresholdBytes)) { @@ -112,7 +161,7 @@ public static class EventCompressor return (payload.ToArray(), false); } - var compressed = Compress(payload); + var compressed = Compress(payload, compression); Interlocked.Increment(ref _totalCompressed); var saved = payload.Length - compressed.Length; if (saved > 0) @@ -135,4 +184,45 @@ public static class EventCompressor return (double)compressedSize / originalSize; } + + /// + /// Parses an HTTP Accept-Encoding value into a supported compression type. + /// Go reference: events.go getAcceptEncoding(). + /// + public static EventCompressionType GetAcceptEncoding(string? acceptEncoding) + { + if (string.IsNullOrWhiteSpace(acceptEncoding)) + return EventCompressionType.None; + + var value = acceptEncoding.ToLowerInvariant(); + if (value.Contains("snappy", StringComparison.Ordinal) + || value.Contains("s2", StringComparison.Ordinal)) + { + return EventCompressionType.Snappy; + } + + if (value.Contains("gzip", StringComparison.Ordinal)) + return EventCompressionType.Gzip; + + return EventCompressionType.Unsupported; + } + + private static byte[] CompressGzip(ReadOnlySpan payload) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(payload); + } + return output.ToArray(); + } + + private static byte[] DecompressGzip(ReadOnlySpan compressed) + { + using var input = new MemoryStream(compressed.ToArray()); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } } diff --git a/src/NATS.Server/Events/EventSubjects.cs b/src/NATS.Server/Events/EventSubjects.cs index 7b6d35e..0a661d1 100644 --- a/src/NATS.Server/Events/EventSubjects.cs +++ b/src/NATS.Server/Events/EventSubjects.cs @@ -25,8 +25,9 @@ public static class EventSubjects // Remote server and leaf node events public const string RemoteServerShutdown = "$SYS.SERVER.{0}.REMOTE.SHUTDOWN"; public const string RemoteServerUpdate = "$SYS.SERVER.{0}.REMOTE.UPDATE"; - public const string LeafNodeConnected = "$SYS.SERVER.{0}.LEAFNODE.CONNECT"; + public const string LeafNodeConnected = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT"; public const string LeafNodeDisconnected = "$SYS.SERVER.{0}.LEAFNODE.DISCONNECT"; + public const string RemoteLatency = "$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2"; // Request-reply subjects (server-specific) public const string ServerReq = "$SYS.REQ.SERVER.{0}.{1}"; @@ -36,13 +37,22 @@ public static class EventSubjects // Account-scoped request subjects public const string AccountReq = "$SYS.REQ.ACCOUNT.{0}.{1}"; + public const string UserDirectInfo = "$SYS.REQ.USER.INFO"; + public const string UserDirectReq = "$SYS.REQ.USER.{0}.INFO"; + public const string AccountNumSubsReq = "$SYS.REQ.ACCOUNT.NSUBS"; + public const string AccountSubs = "$SYS._INBOX_.{0}.NSUBS"; + public const string ClientKickReq = "$SYS.REQ.SERVER.{0}.KICK"; + public const string ClientLdmReq = "$SYS.REQ.SERVER.{0}.LDM"; + public const string ServerStatsPingReq = "$SYS.REQ.SERVER.PING.STATSZ"; + public const string ServerReloadReq = "$SYS.REQ.SERVER.{0}.RELOAD"; // Inbox for responses public const string InboxResponse = "$SYS._INBOX_.{0}"; // OCSP advisory events // Go reference: ocsp.go — OCSP peer reject and chain validation subjects. - public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.REJECT"; + public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT"; + public const string OcspPeerChainlinkInvalid = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID"; public const string OcspChainValidation = "$SYS.SERVER.{0}.OCSP.CHAIN.VALIDATION"; // JetStream advisory events diff --git a/src/NATS.Server/Events/EventTypes.cs b/src/NATS.Server/Events/EventTypes.cs index 5106123..bcff575 100644 --- a/src/NATS.Server/Events/EventTypes.cs +++ b/src/NATS.Server/Events/EventTypes.cs @@ -2,6 +2,35 @@ using System.Text.Json.Serialization; namespace NATS.Server.Events; +/// +/// Server capability flags. +/// Go reference: events.go ServerCapability constants. +/// +[Flags] +public enum ServerCapability : ulong +{ + None = 0, + JetStreamEnabled = 1UL << 0, + BinaryStreamSnapshot = 1UL << 1, + AccountNRG = 1UL << 2, +} + +/// +/// Server identity response model used by IDZ requests. +/// Go reference: events.go ServerID. +/// +public sealed class ServerID +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("host")] + public string Host { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; +} + /// /// Server identity block embedded in all system events. /// Go reference: events.go:249-265 ServerInfo struct. @@ -53,6 +82,27 @@ public sealed class EventServerInfo [JsonPropertyName("time")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public DateTime Time { get; set; } + + public void SetJetStreamEnabled() + { + JetStream = true; + Flags |= (ulong)ServerCapability.JetStreamEnabled; + } + + public bool JetStreamEnabled() => + (Flags & (ulong)ServerCapability.JetStreamEnabled) != 0; + + public void SetBinaryStreamSnapshot() => + Flags |= (ulong)ServerCapability.BinaryStreamSnapshot; + + public bool BinaryStreamSnapshot() => + (Flags & (ulong)ServerCapability.BinaryStreamSnapshot) != 0; + + public void SetAccountNRG() => + Flags |= (ulong)ServerCapability.AccountNRG; + + public bool AccountNRG() => + (Flags & (ulong)ServerCapability.AccountNRG) != 0; } /// @@ -536,10 +586,66 @@ public sealed class OcspPeerRejectEventMsg [JsonPropertyName("server")] public EventServerInfo Server { get; set; } = new(); + [JsonPropertyName("peer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public EventCertInfo? Peer { get; set; } + [JsonPropertyName("reason")] public string Reason { get; set; } = string.Empty; } +/// +/// Certificate identity block used by OCSP peer advisories. +/// Go reference: certidp.CertInfo payload embedded in events.go OCSP messages. +/// +public sealed class EventCertInfo +{ + [JsonPropertyName("subject")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Subject { get; set; } + + [JsonPropertyName("issuer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Issuer { get; set; } + + [JsonPropertyName("fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Fingerprint { get; set; } + + [JsonPropertyName("raw")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Raw { get; set; } +} + +/// +/// OCSP chain-link invalid advisory. +/// Go reference: events.go OCSPPeerChainlinkInvalidEventMsg. +/// +public sealed class OcspPeerChainlinkInvalidEventMsg +{ + public const string EventType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid"; + + [JsonPropertyName("type")] + public string Type { get; set; } = EventType; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTime Time { get; set; } + + [JsonPropertyName("server")] + public EventServerInfo Server { get; set; } = new(); + + [JsonPropertyName("link")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public EventCertInfo? Link { get; set; } + + [JsonPropertyName("peer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public EventCertInfo? Peer { get; set; } +} + /// /// OCSP chain validation advisory, published when a certificate's OCSP status /// is checked during TLS handshake. @@ -803,6 +909,149 @@ public sealed class AccNumConnsReq public string Account { get; set; } = string.Empty; } +/// +/// Account subscription-count request. +/// Go reference: events.go accNumSubsReq. +/// +public sealed class AccNumSubsReq +{ + [JsonPropertyName("server")] + public EventServerInfo Server { get; set; } = new(); + + [JsonPropertyName("acc")] + public string Account { get; set; } = string.Empty; +} + +/// +/// Shared request filter options for system request subjects. +/// Go reference: events.go EventFilterOptions. +/// +public class EventFilterOptions +{ + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + [JsonPropertyName("cluster")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cluster { get; set; } + + [JsonPropertyName("host")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Host { get; set; } + + [JsonPropertyName("tags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? Tags { get; set; } + + [JsonPropertyName("domain")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Domain { get; set; } +} + +public sealed class StatszEventOptions : EventFilterOptions; +public sealed class AccInfoEventOptions : EventFilterOptions; +public sealed class ConnzEventOptions : EventFilterOptions; +public sealed class RoutezEventOptions : EventFilterOptions; +public sealed class SubszEventOptions : EventFilterOptions; +public sealed class VarzEventOptions : EventFilterOptions; +public sealed class GatewayzEventOptions : EventFilterOptions; +public sealed class LeafzEventOptions : EventFilterOptions; +public sealed class AccountzEventOptions : EventFilterOptions; +public sealed class AccountStatzEventOptions : EventFilterOptions; +public sealed class JszEventOptions : EventFilterOptions; +public sealed class HealthzEventOptions : EventFilterOptions; +public sealed class ProfilezEventOptions : EventFilterOptions; +public sealed class ExpvarzEventOptions : EventFilterOptions; +public sealed class IpqueueszEventOptions : EventFilterOptions; +public sealed class RaftzEventOptions : EventFilterOptions; + +/// +/// Generic server API error payload. +/// Go reference: events.go server API response errors. +/// +public sealed class ServerAPIError +{ + [JsonPropertyName("code")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Code { get; set; } + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } +} + +/// +/// Generic server request/response envelope for $SYS.REQ services. +/// Go reference: events.go ServerAPIResponse. +/// +public class ServerAPIResponse +{ + [JsonPropertyName("server")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public EventServerInfo? Server { get; set; } + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Data { get; set; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ServerAPIError? Error { get; set; } +} + +public sealed class ServerAPIConnzResponse : ServerAPIResponse; +public sealed class ServerAPIRoutezResponse : ServerAPIResponse; +public sealed class ServerAPIGatewayzResponse : ServerAPIResponse; +public sealed class ServerAPIJszResponse : ServerAPIResponse; +public sealed class ServerAPIHealthzResponse : ServerAPIResponse; +public sealed class ServerAPIVarzResponse : ServerAPIResponse; +public sealed class ServerAPISubszResponse : ServerAPIResponse; +public sealed class ServerAPILeafzResponse : ServerAPIResponse; +public sealed class ServerAPIAccountzResponse : ServerAPIResponse; +public sealed class ServerAPIExpvarzResponse : ServerAPIResponse; +public sealed class ServerAPIpqueueszResponse : ServerAPIResponse; +public sealed class ServerAPIRaftzResponse : ServerAPIResponse; + +/// +/// Kick client request payload. +/// Go reference: events.go KickClientReq. +/// +public sealed class KickClientReq +{ + [JsonPropertyName("cid")] + public ulong ClientId { get; set; } +} + +/// +/// Lame duck mode client request payload. +/// Go reference: events.go LDMClientReq. +/// +public sealed class LDMClientReq +{ + [JsonPropertyName("cid")] + public ulong ClientId { get; set; } +} + +/// +/// User info payload for direct user info requests. +/// Go reference: events.go UserInfo. +/// +public sealed class UserInfo +{ + [JsonPropertyName("user")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? User { get; set; } + + [JsonPropertyName("acc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Account { get; set; } + + [JsonPropertyName("permissions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Permissions { get; set; } +} + /// /// Factory helpers that construct fully-populated system event messages, /// mirroring Go's inline struct initialization patterns in events.go. diff --git a/src/NATS.Server/Events/InternalEventSystem.cs b/src/NATS.Server/Events/InternalEventSystem.cs index 164254a..4d9df2b 100644 --- a/src/NATS.Server/Events/InternalEventSystem.cs +++ b/src/NATS.Server/Events/InternalEventSystem.cs @@ -61,7 +61,14 @@ public sealed record ConnectEventDetail( string RemoteAddress, string? AccountName, string? UserName, - DateTime ConnectedAt); + DateTime ConnectedAt, + string? Jwt = null, + string? IssuerKey = null, + string[]? Tags = null, + string? NameTag = null, + string? Kind = null, + string? ClientType = null, + string? MqttClientId = null); /// /// Detail payload for a client disconnect advisory. @@ -73,7 +80,15 @@ public sealed record DisconnectEventDetail( string RemoteAddress, string? AccountName, string Reason, - DateTime DisconnectedAt); + DateTime DisconnectedAt, + long RttNanos = 0, + string? Jwt = null, + string? IssuerKey = null, + string[]? Tags = null, + string? NameTag = null, + string? Kind = null, + string? ClientType = null, + string? MqttClientId = null); /// /// Manages the server's internal event system with Channel-based send/receive loops. @@ -114,14 +129,29 @@ public sealed class InternalEventSystem : IAsyncDisposable SystemAccount = systemAccount; SystemClient = systemClient; - // Hash server name for inbox routing (matches Go's shash) - ServerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serverName)))[..8].ToLowerInvariant(); + // Hash server name for inbox routing (matches Go's shash). + ServerHash = GetHash(serverName, GetHashSize()); _sendQueue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); _receiveQueue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); _receiveQueuePings = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); } + /// + /// Equivalent to Go getHashSize(): default short hash width used in eventing subjects. + /// + public static int GetHashSize() => 8; + + /// + /// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers. + /// + public static string GetHash(string value, int size) + { + ArgumentOutOfRangeException.ThrowIfLessThan(size, 1); + var full = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + return size >= full.Length ? full : full[..size]; + } + public void Start(NatsServer server) { _server = server; @@ -316,6 +346,13 @@ public sealed class InternalEventSystem : IAsyncDisposable Account = detail.AccountName, User = detail.UserName, Start = detail.ConnectedAt, + Jwt = detail.Jwt, + IssuerKey = detail.IssuerKey, + Tags = detail.Tags, + NameTag = detail.NameTag, + Kind = detail.Kind, + ClientType = detail.ClientType, + MqttClient = detail.MqttClientId, }, }; @@ -341,6 +378,14 @@ public sealed class InternalEventSystem : IAsyncDisposable Host = detail.RemoteAddress, Account = detail.AccountName, Stop = detail.DisconnectedAt, + RttNanos = detail.RttNanos, + Jwt = detail.Jwt, + IssuerKey = detail.IssuerKey, + Tags = detail.Tags, + NameTag = detail.NameTag, + Kind = detail.Kind, + ClientType = detail.ClientType, + MqttClient = detail.MqttClientId, }, Reason = detail.Reason, }; diff --git a/src/NATS.Server/Gateways/GatewayConnection.cs b/src/NATS.Server/Gateways/GatewayConnection.cs index e0ae086..e6279d2 100644 --- a/src/NATS.Server/Gateways/GatewayConnection.cs +++ b/src/NATS.Server/Gateways/GatewayConnection.cs @@ -16,6 +16,7 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable private Task? _loopTask; public string? RemoteId { get; private set; } + public bool IsOutbound { get; internal set; } public string RemoteEndpoint => socket.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString("N"); public Func? RemoteSubscriptionReceived { get; set; } public Func? MessageReceived { get; set; } diff --git a/src/NATS.Server/Gateways/GatewayManager.cs b/src/NATS.Server/Gateways/GatewayManager.cs index 3a7a965..76fa93b 100644 --- a/src/NATS.Server/Gateways/GatewayManager.cs +++ b/src/NATS.Server/Gateways/GatewayManager.cs @@ -65,6 +65,9 @@ public sealed class GatewayReconnectPolicy public sealed class GatewayManager : IAsyncDisposable { + public const string GatewayTlsInsecureWarning = + "Gateway TLS insecure configuration is enabled; verify certificates and hostname validation for production."; + private readonly GatewayOptions _options; private readonly ServerStats _stats; private readonly string _serverId; @@ -103,6 +106,43 @@ public sealed class GatewayManager : IAsyncDisposable _logger = logger; } + /// + /// Validates gateway options for required fields and basic endpoint correctness. + /// Go reference: validateGatewayOptions. + /// + public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error) + { + if (options is null) + { + error = "Gateway options are required."; + return false; + } + + if (string.IsNullOrWhiteSpace(options.Name)) + { + error = "Gateway name is required."; + return false; + } + + if (options.Port is < 0 or > 65535) + { + error = "Gateway port must be in range 0-65535."; + return false; + } + + foreach (var remote in options.Remotes) + { + if (string.IsNullOrWhiteSpace(remote)) + { + error = "Gateway remote entries cannot be empty."; + return false; + } + } + + error = null; + return true; + } + /// /// Gateway clusters auto-discovered via INFO gossip. /// Go reference: server/gateway.go processImplicitGateway. @@ -284,6 +324,48 @@ public sealed class GatewayManager : IAsyncDisposable public int GetConnectedGatewayCount() => _registrations.Values.Count(r => r.State == GatewayConnectionState.Connected); + /// + /// Returns the number of active outbound gateway connections. + /// Go reference: server/gateway.go NumOutboundGateways / numOutboundGateways. + /// + public int NumOutboundGateways() + => _connections.Values.Count(c => c.IsOutbound); + + /// + /// Returns the number of active inbound gateway connections. + /// Go reference: server/gateway.go numInboundGateways. + /// + public int NumInboundGateways() + => _connections.Values.Count(c => !c.IsOutbound); + + /// + /// Returns true if an inbound gateway connection exists for the given remote server id. + /// Go reference: server/gateway.go srvGateway.hasInbound. + /// + public bool HasInbound(string remoteServerId) + => _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal)); + + /// + /// Returns the first outbound gateway connection for the given remote server id, or null. + /// Go reference: server/gateway.go getOutboundGatewayConnection. + /// + public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId) + => _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal)); + + /// + /// Returns all outbound gateway connections. + /// Go reference: server/gateway.go getOutboundGatewayConnections. + /// + public IReadOnlyList GetOutboundGatewayConnections() + => _connections.Values.Where(c => c.IsOutbound).ToList(); + + /// + /// Returns all inbound gateway connections. + /// Go reference: server/gateway.go getInboundGatewayConnections. + /// + public IReadOnlyList GetInboundGatewayConnections() + => _connections.Values.Where(c => !c.IsOutbound).ToList(); + /// /// Atomically increments the messages-sent counter for the named gateway. /// Go reference: server/gateway.go outboundGateway.msgs. @@ -364,7 +446,7 @@ public sealed class GatewayManager : IAsyncDisposable var endPoint = ParseEndpoint(remote); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct); - var connection = new GatewayConnection(socket); + var connection = new GatewayConnection(socket) { IsOutbound = true }; await connection.PerformOutboundHandshakeAsync(_serverId, ct); Register(connection); return; diff --git a/src/NATS.Server/Gateways/ReplyMapper.cs b/src/NATS.Server/Gateways/ReplyMapper.cs index e80496e..e31eaa8 100644 --- a/src/NATS.Server/Gateways/ReplyMapper.cs +++ b/src/NATS.Server/Gateways/ReplyMapper.cs @@ -9,14 +9,42 @@ namespace NATS.Server.Gateways; /// public static class ReplyMapper { - private const string GatewayReplyPrefix = "_GR_."; + public const string GatewayReplyPrefix = "_GR_."; + public const string OldGatewayReplyPrefix = "$GR."; + public const int GatewayReplyPrefixLen = 5; + public const int OldGatewayReplyPrefixLen = 4; + public const int GatewayHashLen = 6; + public const int OldGatewayHashLen = 4; /// - /// Checks whether the subject starts with the gateway reply prefix _GR_.. + /// Checks whether the subject starts with either gateway reply prefix: + /// _GR_. (current) or $GR. (legacy). /// public static bool HasGatewayReplyPrefix(string? subject) - => !string.IsNullOrWhiteSpace(subject) - && subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal); + => IsGatewayRoutedSubject(subject, out _); + + /// + /// Returns true when the subject is gateway-routed and indicates if the legacy + /// old prefix ($GR.) was used. + /// Go reference: isGWRoutedSubjectAndIsOldPrefix. + /// + public static bool IsGatewayRoutedSubject(string? subject, out bool isOldPrefix) + { + isOldPrefix = false; + if (string.IsNullOrWhiteSpace(subject)) + return false; + + if (subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal)) + return true; + + if (subject.StartsWith(OldGatewayReplyPrefix, StringComparison.Ordinal)) + { + isOldPrefix = true; + return true; + } + + return false; + } /// /// Computes a deterministic FNV-1a hash of the reply subject. @@ -40,6 +68,26 @@ public static class ReplyMapper return (long)(hash & 0x7FFFFFFFFFFFFFFF); } + /// + /// Computes the short (6-char) gateway hash used in modern gateway reply routing. + /// Go reference: getGWHash. + /// + public static string ComputeGatewayHash(string gatewayName) + { + var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName)); + return Convert.ToHexString(digest.AsSpan(0, 3)).ToLowerInvariant(); + } + + /// + /// Computes the short (4-char) legacy gateway hash used with old prefixes. + /// Go reference: getOldHash. + /// + public static string ComputeOldGatewayHash(string gatewayName) + { + var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName)); + return Convert.ToHexString(digest.AsSpan(0, 2)).ToLowerInvariant(); + } + /// /// Converts a reply subject to gateway form with an explicit hash segment. /// Format: _GR_.{clusterId}.{hash}.{originalReply}. @@ -75,14 +123,14 @@ public static class ReplyMapper { restoredReply = string.Empty; - if (!HasGatewayReplyPrefix(gatewayReply)) + if (!IsGatewayRoutedSubject(gatewayReply, out _)) return false; var current = gatewayReply!; - while (HasGatewayReplyPrefix(current)) + while (IsGatewayRoutedSubject(current, out var isOldPrefix)) { - // Skip the "_GR_." prefix - var afterPrefix = current[GatewayReplyPrefix.Length..]; + var prefixLen = isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen; + var afterPrefix = current[prefixLen..]; // Find the first dot (end of clusterId) var firstDot = afterPrefix.IndexOf('.'); @@ -117,10 +165,10 @@ public static class ReplyMapper { clusterId = string.Empty; - if (!HasGatewayReplyPrefix(gatewayReply)) + if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix)) return false; - var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..]; + var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..]; var dot = afterPrefix.IndexOf('.'); if (dot <= 0) return false; @@ -137,10 +185,10 @@ public static class ReplyMapper { hash = 0; - if (!HasGatewayReplyPrefix(gatewayReply)) + if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix)) return false; - var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..]; + var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..]; // Skip clusterId var firstDot = afterPrefix.IndexOf('.'); diff --git a/src/NATS.Server/Internal/Avl/SequenceSet.cs b/src/NATS.Server/Internal/Avl/SequenceSet.cs index 397757c..4ef2707 100644 --- a/src/NATS.Server/Internal/Avl/SequenceSet.cs +++ b/src/NATS.Server/Internal/Avl/SequenceSet.cs @@ -254,31 +254,43 @@ public class SequenceSet /// Encodes the set to a compact binary format. public byte[] Encode() { - var encLen = EncodeLength(); - var buf = new byte[encLen]; + var buf = new byte[EncodeLength()]; + var written = Encode(buf); + return buf.AsSpan(0, written).ToArray(); + } - buf[0] = Magic; - buf[1] = Version; + /// + /// Encodes the set into a caller-provided buffer. + /// Returns the number of bytes written. + /// + public int Encode(byte[] destination) + { + var encLen = EncodeLength(); + if (destination.Length < encLen) + throw new ArgumentException("Destination buffer too small for encoded SequenceSet.", nameof(destination)); + + destination[0] = Magic; + destination[1] = Version; var i = HdrLen; - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i), (uint)_nodes); - BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i + 4), (uint)_size); + BinaryPrimitives.WriteUInt32LittleEndian(destination.AsSpan(i), (uint)_nodes); + BinaryPrimitives.WriteUInt32LittleEndian(destination.AsSpan(i + 4), (uint)_size); i += 8; Node.NodeIter(Root, n => { - BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), n.Base); + BinaryPrimitives.WriteUInt64LittleEndian(destination.AsSpan(i), n.Base); i += 8; for (var bi = 0; bi < NumBuckets; bi++) { - BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), n.Bits[bi]); + BinaryPrimitives.WriteUInt64LittleEndian(destination.AsSpan(i), n.Bits[bi]); i += 8; } - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(i), (ushort)n.Height); + BinaryPrimitives.WriteUInt16LittleEndian(destination.AsSpan(i), (ushort)n.Height); i += 2; }); - return buf.AsSpan(0, i).ToArray(); + return i; } /// Decodes a SequenceSet from a binary buffer. Returns the set and number of bytes read. diff --git a/src/NATS.Server/Internal/Gsl/GenericSubjectList.cs b/src/NATS.Server/Internal/Gsl/GenericSubjectList.cs index 7cc0e4d..72a1ce0 100644 --- a/src/NATS.Server/Internal/Gsl/GenericSubjectList.cs +++ b/src/NATS.Server/Internal/Gsl/GenericSubjectList.cs @@ -643,8 +643,14 @@ public class GenericSubjectList where T : IEquatable } /// -/// SimpleSubjectList is an alias for GenericSubjectList that uses int values, +/// Empty marker used by to mirror Go's `struct{}` payload. +/// Go reference: server/gsl/gsl.go SimpleSublist +/// +public readonly record struct SimpleSublistValue; + +/// +/// SimpleSubjectList is an alias for GenericSubjectList that uses an empty marker payload, /// useful for tracking interest only. /// Go reference: server/gsl/gsl.go SimpleSublist /// -public class SimpleSubjectList : GenericSubjectList; +public class SimpleSubjectList : GenericSubjectList; diff --git a/src/NATS.Server/Internal/SubjectTree/SubjectTree.cs b/src/NATS.Server/Internal/SubjectTree/SubjectTree.cs index 1fe4609..3b7e78e 100644 --- a/src/NATS.Server/Internal/SubjectTree/SubjectTree.cs +++ b/src/NATS.Server/Internal/SubjectTree/SubjectTree.cs @@ -1,3 +1,7 @@ +using System.IO; +using System.Text; +using NATS.Server.Internal.Gsl; + // Go reference: server/stree/stree.go namespace NATS.Server.Internal.SubjectTree; @@ -161,8 +165,57 @@ public class SubjectTree IterInternal(Root, [], ordered: false, cb); } + /// + /// Dumps a human-readable representation of the tree. + /// Go reference: server/stree/dump.go + /// + public void Dump(TextWriter writer) + { + Dump(writer, Root, 0); + writer.WriteLine(); + } + #region Internal Methods + private void Dump(TextWriter writer, INode? node, int depth) + { + if (node == null) + { + writer.WriteLine("EMPTY"); + return; + } + + if (node.IsLeaf) + { + var leaf = (Leaf)node; + writer.WriteLine($"{DumpPre(depth)} LEAF: Suffix: {QuoteBytes(leaf.Suffix)} Value: {leaf.Value}"); + return; + } + + var bn = node.Base!; + writer.WriteLine($"{DumpPre(depth)} {node.Kind} Prefix: {QuoteBytes(bn.Prefix)}"); + depth++; + node.Iter(n => + { + Dump(writer, n, depth); + return true; + }); + } + + private static string DumpPre(int depth) + { + if (depth == 0) + return "-- "; + + var sb = new StringBuilder(depth * 2 + 4); + for (var i = 0; i < depth; i++) + sb.Append(" "); + sb.Append("|__ "); + return sb.ToString(); + } + + private static string QuoteBytes(byte[] data) => $"\"{Encoding.UTF8.GetString(data)}\""; + /// /// Internal recursive insert. /// Go reference: server/stree/stree.go:insert @@ -613,4 +666,85 @@ public static class SubjectTreeHelper }); } } + + /// + /// Matches all items in the subject tree that have interest in the given sublist. + /// The callback is invoked at most once per matching subject. + /// Go reference: server/stree/stree.go IntersectGSL + /// + public static void IntersectGSL( + SubjectTree? tree, + GenericSubjectList? sublist, + Action cb) where SL : IEquatable + { + if (tree?.Root == null || sublist == null) + return; + + IntersectGslInternal(tree.Root, [], sublist, cb); + } + + private static void IntersectGslInternal( + INode node, + byte[] pre, + GenericSubjectList sublist, + Action cb) where SL : IEquatable + { + if (node.IsLeaf) + { + var leaf = (Leaf)node; + var subject = Concat(pre, leaf.Suffix); + if (sublist.HasInterest(BytesToString(subject))) + cb(subject, leaf.Value); + return; + } + + var bn = node.Base!; + pre = Concat(pre, bn.Prefix); + + foreach (var child in node.Children()) + { + if (child == null) + continue; + + var subject = Concat(pre, child.Path()); + if (!HasInterestForTokens(sublist, subject, pre.Length)) + continue; + + IntersectGslInternal(child, pre, sublist, cb); + } + } + + private static bool HasInterestForTokens( + GenericSubjectList sublist, + byte[] subject, + int since) where SL : IEquatable + { + for (var i = since; i < subject.Length; i++) + { + if (subject[i] != Parts.Tsep) + continue; + + if (!sublist.HasInterestStartingIn(BytesToString(subject.AsSpan(0, i)))) + return false; + } + + return true; + } + + private static string BytesToString(ReadOnlySpan data) + { + if (data.Length == 0) + return string.Empty; + return Encoding.UTF8.GetString(data); + } + + private static byte[] Concat(byte[] a, byte[] b) + { + if (a.Length == 0) return b; + if (b.Length == 0) return a; + var result = new byte[a.Length + b.Length]; + a.CopyTo(result, 0); + b.CopyTo(result, a.Length); + return result; + } } diff --git a/src/NATS.Server/Internal/SysMem/SystemMemory.cs b/src/NATS.Server/Internal/SysMem/SystemMemory.cs new file mode 100644 index 0000000..3414fb9 --- /dev/null +++ b/src/NATS.Server/Internal/SysMem/SystemMemory.cs @@ -0,0 +1,21 @@ +namespace NATS.Server.Internal.SysMem; + +/// +/// Cross-platform system memory query helpers. +/// Go reference: server/sysmem/mem_*.go Memory +/// +public static class SystemMemory +{ + /// + /// Returns total memory available to the runtime for the current OS. + /// + public static long Memory() + { + var total = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + if (total > 0) + return total; + + // Conservative fallback when runtime does not report a limit. + return Environment.Is64BitProcess ? long.MaxValue : int.MaxValue; + } +} diff --git a/src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs b/src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs index 7d3dd16..3ee4b27 100644 --- a/src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs +++ b/src/NATS.Server/Internal/TimeHashWheel/HashWheel.cs @@ -400,6 +400,12 @@ public class HashWheel /// internal Slot?[] Wheel => _wheel; + /// + /// Represents a hash wheel entry (sequence + expiration timestamp). + /// Go reference: server/thw/thw.go HashWheelEntry + /// + internal readonly record struct HashWheelEntry(ulong Sequence, long Expires); + /// /// Represents a single slot in the wheel containing entries that hash to the same position. /// diff --git a/src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs b/src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs index 2efe797..757febb 100644 --- a/src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs +++ b/src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs @@ -53,30 +53,57 @@ public static class ConsumerApiHandlers : JetStreamApiResponse.NotFound(subject); } - public static JetStreamApiResponse HandleNames(string subject, ConsumerManager consumerManager) + public static JetStreamApiResponse HandleNames(string subject, ReadOnlySpan payload, ConsumerManager consumerManager) { var stream = ParseStreamSubject(subject, NamesPrefix); if (stream == null) return JetStreamApiResponse.NotFound(subject); + var offset = ParseOffset(payload); + var all = consumerManager.ListNames(stream); + var page = offset >= all.Count ? [] : all.Skip(offset).ToList(); return new JetStreamApiResponse { - ConsumerNames = consumerManager.ListNames(stream), + ConsumerNames = page, + PaginationTotal = all.Count, + PaginationOffset = offset, }; } - public static JetStreamApiResponse HandleList(string subject, ConsumerManager consumerManager) + public static JetStreamApiResponse HandleList(string subject, ReadOnlySpan payload, ConsumerManager consumerManager) { var stream = ParseStreamSubject(subject, ListPrefix); if (stream == null) return JetStreamApiResponse.NotFound(subject); + var offset = ParseOffset(payload); + var all = consumerManager.ListConsumerInfos(stream); + var page = offset >= all.Count ? [] : all.Skip(offset).ToList(); return new JetStreamApiResponse { - ConsumerNames = consumerManager.ListNames(stream), + ConsumerInfoList = page, + PaginationTotal = all.Count, + PaginationOffset = offset, }; } + private static int ParseOffset(ReadOnlySpan payload) + { + if (payload.IsEmpty) return 0; + try + { + using var doc = JsonDocument.Parse(payload.ToArray()); + if (doc.RootElement.TryGetProperty("offset", out var el) && el.TryGetInt32(out var v)) + return Math.Max(v, 0); + } + catch (JsonException ex) + { + System.Diagnostics.Debug.WriteLine($"Malformed offset payload: {ex.Message}"); + } + + return 0; + } + public static JetStreamApiResponse HandlePause(string subject, ReadOnlySpan payload, ConsumerManager consumerManager) { var parsed = ParseSubject(subject, PausePrefix); @@ -254,15 +281,21 @@ public static class ConsumerApiHandlers { using var doc = JsonDocument.Parse(payload.ToArray()); var root = doc.RootElement; + + // The client wraps config in a "config" property (CreateConsumerRequest). + // Go reference: consumer.go — CreateConsumerRequest { Config ConsumerConfig `json:"config"` } + var configEl = root.TryGetProperty("config", out var nested) ? nested : root; var config = new ConsumerConfig(); - if (root.TryGetProperty("durable_name", out var durableEl)) + if (configEl.TryGetProperty("durable_name", out var durableEl)) config.DurableName = durableEl.GetString() ?? string.Empty; + else if (configEl.TryGetProperty("name", out var nameEl)) + config.DurableName = nameEl.GetString() ?? string.Empty; - if (root.TryGetProperty("filter_subject", out var filterEl)) + if (configEl.TryGetProperty("filter_subject", out var filterEl)) config.FilterSubject = filterEl.GetString(); - if (root.TryGetProperty("filter_subjects", out var filterSubjectsEl) && filterSubjectsEl.ValueKind == JsonValueKind.Array) + if (configEl.TryGetProperty("filter_subjects", out var filterSubjectsEl) && filterSubjectsEl.ValueKind == JsonValueKind.Array) { foreach (var item in filterSubjectsEl.EnumerateArray()) { @@ -272,41 +305,41 @@ public static class ConsumerApiHandlers } } - if (root.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True) + if (configEl.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True) config.Ephemeral = true; - if (root.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True) + if (configEl.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True) config.Push = true; - if (root.TryGetProperty("heartbeat_ms", out var hbEl) && hbEl.TryGetInt32(out var hbMs)) + if (configEl.TryGetProperty("heartbeat_ms", out var hbEl) && hbEl.TryGetInt32(out var hbMs)) config.HeartbeatMs = hbMs; - if (root.TryGetProperty("ack_wait_ms", out var ackWaitEl) && ackWaitEl.TryGetInt32(out var ackWait)) + if (configEl.TryGetProperty("ack_wait_ms", out var ackWaitEl) && ackWaitEl.TryGetInt32(out var ackWait)) config.AckWaitMs = ackWait; - if (root.TryGetProperty("max_deliver", out var maxDeliverEl) && maxDeliverEl.TryGetInt32(out var maxDeliver)) + if (configEl.TryGetProperty("max_deliver", out var maxDeliverEl) && maxDeliverEl.TryGetInt32(out var maxDeliver)) config.MaxDeliver = Math.Max(maxDeliver, 0); - if (root.TryGetProperty("max_ack_pending", out var maxAckPendingEl) && maxAckPendingEl.TryGetInt32(out var maxAckPending)) + if (configEl.TryGetProperty("max_ack_pending", out var maxAckPendingEl) && maxAckPendingEl.TryGetInt32(out var maxAckPending)) config.MaxAckPending = Math.Max(maxAckPending, 0); - if (root.TryGetProperty("flow_control", out var flowControlEl) && flowControlEl.ValueKind is JsonValueKind.True or JsonValueKind.False) + if (configEl.TryGetProperty("flow_control", out var flowControlEl) && flowControlEl.ValueKind is JsonValueKind.True or JsonValueKind.False) config.FlowControl = flowControlEl.GetBoolean(); - if (root.TryGetProperty("rate_limit_bps", out var rateLimitEl) && rateLimitEl.TryGetInt64(out var rateLimit)) + if (configEl.TryGetProperty("rate_limit_bps", out var rateLimitEl) && rateLimitEl.TryGetInt64(out var rateLimit)) config.RateLimitBps = Math.Max(rateLimit, 0); - if (root.TryGetProperty("opt_start_seq", out var optStartSeqEl) && optStartSeqEl.TryGetUInt64(out var optStartSeq)) + if (configEl.TryGetProperty("opt_start_seq", out var optStartSeqEl) && optStartSeqEl.TryGetUInt64(out var optStartSeq)) config.OptStartSeq = optStartSeq; - if (root.TryGetProperty("opt_start_time_utc", out var optStartTimeEl) + if (configEl.TryGetProperty("opt_start_time_utc", out var optStartTimeEl) && optStartTimeEl.ValueKind == JsonValueKind.String && DateTime.TryParse(optStartTimeEl.GetString(), out var optStartTime)) { config.OptStartTimeUtc = optStartTime.ToUniversalTime(); } - if (root.TryGetProperty("backoff_ms", out var backoffEl) && backoffEl.ValueKind == JsonValueKind.Array) + if (configEl.TryGetProperty("backoff_ms", out var backoffEl) && backoffEl.ValueKind == JsonValueKind.Array) { foreach (var item in backoffEl.EnumerateArray()) { @@ -315,7 +348,7 @@ public static class ConsumerApiHandlers } } - if (root.TryGetProperty("ack_policy", out var ackPolicyEl)) + if (configEl.TryGetProperty("ack_policy", out var ackPolicyEl)) { var ackPolicy = ackPolicyEl.GetString(); if (string.Equals(ackPolicy, "explicit", StringComparison.OrdinalIgnoreCase)) @@ -324,7 +357,7 @@ public static class ConsumerApiHandlers config.AckPolicy = AckPolicy.All; } - if (root.TryGetProperty("deliver_policy", out var deliverPolicyEl)) + if (configEl.TryGetProperty("deliver_policy", out var deliverPolicyEl)) { var deliver = deliverPolicyEl.GetString(); if (string.Equals(deliver, "last", StringComparison.OrdinalIgnoreCase)) @@ -339,7 +372,7 @@ public static class ConsumerApiHandlers config.DeliverPolicy = DeliverPolicy.LastPerSubject; } - if (root.TryGetProperty("replay_policy", out var replayPolicyEl)) + if (configEl.TryGetProperty("replay_policy", out var replayPolicyEl)) { var replay = replayPolicyEl.GetString(); if (string.Equals(replay, "original", StringComparison.OrdinalIgnoreCase)) diff --git a/src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs b/src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs index 4c1423b..82fab59 100644 --- a/src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs +++ b/src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs @@ -102,17 +102,47 @@ public static class StreamApiHandlers return JetStreamApiResponse.PurgeResponse((ulong)purged); } - public static JetStreamApiResponse HandleNames(StreamManager streamManager) + public static JetStreamApiResponse HandleNames(ReadOnlySpan payload, StreamManager streamManager) { + var offset = ParseOffset(payload); + var all = streamManager.ListNames(); + var page = offset >= all.Count ? [] : all.Skip(offset).ToList(); return new JetStreamApiResponse { - StreamNames = streamManager.ListNames(), + StreamNames = page, + PaginationTotal = all.Count, + PaginationOffset = offset, }; } - public static JetStreamApiResponse HandleList(StreamManager streamManager) + public static JetStreamApiResponse HandleList(ReadOnlySpan payload, StreamManager streamManager) { - return HandleNames(streamManager); + var offset = ParseOffset(payload); + var all = streamManager.ListStreamInfos(); + var page = offset >= all.Count ? [] : all.Skip(offset).ToList(); + return new JetStreamApiResponse + { + StreamInfoList = page, + PaginationTotal = all.Count, + PaginationOffset = offset, + }; + } + + private static int ParseOffset(ReadOnlySpan payload) + { + if (payload.IsEmpty) return 0; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(payload.ToArray()); + if (doc.RootElement.TryGetProperty("offset", out var el) && el.TryGetInt32(out var v)) + return Math.Max(v, 0); + } + catch (System.Text.Json.JsonException ex) + { + System.Diagnostics.Debug.WriteLine($"Malformed offset payload: {ex.Message}"); + } + + return 0; } public static JetStreamApiResponse HandleMessageGet(string subject, ReadOnlySpan payload, StreamManager streamManager) @@ -445,7 +475,9 @@ public static class StreamApiHandlers if (root.TryGetProperty("max_msgs_per", out var maxMsgsPerEl) && maxMsgsPerEl.TryGetInt32(out var maxMsgsPer)) config.MaxMsgsPer = maxMsgsPer; - if (root.TryGetProperty("max_age_ms", out var maxAgeMsEl) && maxAgeMsEl.TryGetInt32(out var maxAgeMs)) + if (root.TryGetProperty("max_age", out var maxAgeNsEl) && maxAgeNsEl.TryGetInt64(out var maxAgeNs)) + config.MaxAge = maxAgeNs; + else if (root.TryGetProperty("max_age_ms", out var maxAgeMsEl) && maxAgeMsEl.TryGetInt32(out var maxAgeMs)) config.MaxAgeMs = maxAgeMs; if (root.TryGetProperty("max_msg_size", out var maxMsgSizeEl) && maxMsgSizeEl.TryGetInt32(out var maxMsgSize)) diff --git a/src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs b/src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs new file mode 100644 index 0000000..c8ad3bd --- /dev/null +++ b/src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs @@ -0,0 +1,14 @@ +namespace NATS.Server.JetStream.Api; + +/// +/// JetStream API size and queue limits aligned with Go server constants. +/// Go reference: server/jetstream_api.go (JSMaxDescriptionLen, JSMaxMetadataLen, +/// JSMaxNameLen, JSDefaultRequestQueueLimit). +/// +public static class JetStreamApiLimits +{ + public const int JSMaxDescriptionLen = 4_096; + public const int JSMaxMetadataLen = 128 * 1024; + public const int JSMaxNameLen = 255; + public const int JSDefaultRequestQueueLimit = 10_000; +} diff --git a/src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs b/src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs index 45b1709..71f972a 100644 --- a/src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs +++ b/src/NATS.Server/JetStream/Api/JetStreamApiResponse.cs @@ -9,7 +9,9 @@ public sealed class JetStreamApiResponse public JetStreamConsumerInfo? ConsumerInfo { get; init; } public JetStreamAccountInfo? AccountInfo { get; init; } public IReadOnlyList? StreamNames { get; init; } + public IReadOnlyList? StreamInfoList { get; init; } public IReadOnlyList? ConsumerNames { get; init; } + public IReadOnlyList? ConsumerInfoList { get; init; } public JetStreamStreamMessage? StreamMessage { get; init; } public JetStreamDirectMessage? DirectMessage { get; init; } public JetStreamSnapshot? Snapshot { get; init; } @@ -17,6 +19,17 @@ public sealed class JetStreamApiResponse public bool Success { get; init; } public ulong Purged { get; init; } + /// + /// Total count of all items (before pagination). Used by list responses for offset-based pagination. + /// Go reference: jetstream_api.go — ApiPaged struct includes Total, Offset, Limit fields. + /// + public int PaginationTotal { get; init; } + + /// + /// Requested offset for pagination. Echoed back to client so it can calculate the next page. + /// + public int PaginationOffset { get; init; } + /// /// Whether the consumer is currently paused. Populated by pause/resume API responses. /// Go reference: server/consumer.go jsConsumerPauseResponse.paused field. @@ -29,6 +42,123 @@ public sealed class JetStreamApiResponse /// public DateTime? PauseUntil { get; init; } + /// + /// Returns a wire-format object for JSON serialization matching the Go server's + /// flat response structure (e.g., config/state at root level for stream responses, + /// not nested under a wrapper property). + /// + public object ToWireFormat() + { + if (StreamInfo != null) + { + if (Error != null) + return new { type = "io.nats.jetstream.api.v1.stream_create_response", error = Error }; + return new + { + type = "io.nats.jetstream.api.v1.stream_create_response", + config = ToWireConfig(StreamInfo.Config), + state = ToWireState(StreamInfo.State), + }; + } + + if (ConsumerInfo != null) + { + if (Error != null) + return new { type = "io.nats.jetstream.api.v1.consumer_create_response", error = Error }; + return new + { + type = "io.nats.jetstream.api.v1.consumer_create_response", + stream_name = ConsumerInfo.StreamName, + name = ConsumerInfo.Name, + config = ToWireConsumerConfig(ConsumerInfo.Config), + }; + } + + if (Error != null) + return new { error = Error }; + + if (StreamInfoList != null) + { + var wireStreams = StreamInfoList.Select(s => new + { + config = ToWireConfig(s.Config), + state = ToWireState(s.State), + }).ToList(); + return new { total = PaginationTotal, offset = PaginationOffset, limit = wireStreams.Count, streams = wireStreams }; + } + + if (StreamNames != null) + return new { total = PaginationTotal, offset = PaginationOffset, limit = StreamNames.Count, streams = StreamNames }; + + if (ConsumerInfoList != null) + { + var wireConsumers = ConsumerInfoList.Select(c => new + { + stream_name = c.StreamName, + name = c.Name, + config = ToWireConsumerConfig(c.Config), + }).ToList(); + return new { total = PaginationTotal, offset = PaginationOffset, limit = wireConsumers.Count, consumers = wireConsumers }; + } + + if (ConsumerNames != null) + return new { total = PaginationTotal, offset = PaginationOffset, limit = ConsumerNames.Count, consumers = ConsumerNames }; + + if (Purged > 0 || Success) + return new { success = Success, purged = Purged }; + + return new { success = Success }; + } + + /// + /// Creates a Go-compatible wire format for StreamConfig. + /// Only includes fields the Go server sends, with enums as lowercase strings. + /// Go reference: server/stream.go StreamConfig JSON marshaling. + /// + private static object ToWireConfig(StreamConfig c) => new + { + name = c.Name, + subjects = c.Subjects, + retention = c.Retention.ToString().ToLowerInvariant(), + max_consumers = c.MaxConsumers, + max_msgs = c.MaxMsgs, + max_bytes = c.MaxBytes, + max_age = c.MaxAge, + max_msgs_per_subject = c.MaxMsgsPer, + max_msg_size = c.MaxMsgSize, + storage = c.Storage.ToString().ToLowerInvariant(), + discard = c.Discard.ToString().ToLowerInvariant(), + num_replicas = c.Replicas, + duplicate_window = (long)c.DuplicateWindowMs * 1_000_000L, + sealed_field = c.Sealed, + deny_delete = c.DenyDelete, + deny_purge = c.DenyPurge, + allow_direct = c.AllowDirect, + first_seq = c.FirstSeq, + }; + + private static object ToWireState(ApiStreamState s) => new + { + messages = s.Messages, + bytes = s.Bytes, + first_seq = s.FirstSeq, + last_seq = s.LastSeq, + consumer_count = 0, + }; + + private static object ToWireConsumerConfig(ConsumerConfig c) => new + { + durable_name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName, + name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName, + deliver_policy = c.DeliverPolicy.ToString().ToLowerInvariant(), + ack_policy = c.AckPolicy.ToString().ToLowerInvariant(), + replay_policy = c.ReplayPolicy.ToString().ToLowerInvariant(), + ack_wait = (long)c.AckWaitMs * 1_000_000L, + max_deliver = c.MaxDeliver, + max_ack_pending = c.MaxAckPending, + filter_subject = c.FilterSubject, + }; + public static JetStreamApiResponse NotFound(string subject) => new() { Error = new JetStreamApiError @@ -99,6 +229,8 @@ public sealed class JetStreamStreamInfo public sealed class JetStreamConsumerInfo { + public string? Name { get; init; } + public string? StreamName { get; init; } public required ConsumerConfig Config { get; init; } } diff --git a/src/NATS.Server/JetStream/Api/JetStreamApiRouter.cs b/src/NATS.Server/JetStream/Api/JetStreamApiRouter.cs index d5ebab0..94ed6b1 100644 --- a/src/NATS.Server/JetStream/Api/JetStreamApiRouter.cs +++ b/src/NATS.Server/JetStream/Api/JetStreamApiRouter.cs @@ -249,10 +249,10 @@ public sealed class JetStreamApiRouter return StreamApiHandlers.HandleInfo(subject, _streamManager); if (subject.Equals(JetStreamApiSubjects.StreamNames, StringComparison.Ordinal)) - return StreamApiHandlers.HandleNames(_streamManager); + return StreamApiHandlers.HandleNames(payload, _streamManager); if (subject.Equals(JetStreamApiSubjects.StreamList, StringComparison.Ordinal)) - return StreamApiHandlers.HandleList(_streamManager); + return StreamApiHandlers.HandleList(payload, _streamManager); if (subject.StartsWith(JetStreamApiSubjects.StreamUpdate, StringComparison.Ordinal)) return StreamApiHandlers.HandleUpdate(subject, payload, _streamManager); @@ -288,10 +288,10 @@ public sealed class JetStreamApiRouter return ConsumerApiHandlers.HandleInfo(subject, _consumerManager); if (subject.StartsWith(JetStreamApiSubjects.ConsumerNames, StringComparison.Ordinal)) - return ConsumerApiHandlers.HandleNames(subject, _consumerManager); + return ConsumerApiHandlers.HandleNames(subject, payload, _consumerManager); if (subject.StartsWith(JetStreamApiSubjects.ConsumerList, StringComparison.Ordinal)) - return ConsumerApiHandlers.HandleList(subject, _consumerManager); + return ConsumerApiHandlers.HandleList(subject, payload, _consumerManager); if (subject.StartsWith(JetStreamApiSubjects.ConsumerDelete, StringComparison.Ordinal)) return ConsumerApiHandlers.HandleDelete(subject, _consumerManager); diff --git a/src/NATS.Server/JetStream/ConsumerManager.cs b/src/NATS.Server/JetStream/ConsumerManager.cs index 57e1376..042fff4 100644 --- a/src/NATS.Server/JetStream/ConsumerManager.cs +++ b/src/NATS.Server/JetStream/ConsumerManager.cs @@ -4,6 +4,7 @@ using NATS.Server.JetStream.Cluster; using NATS.Server.JetStream.Consumers; using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Storage; +using NATS.Server.JetStream.Validation; using NATS.Server.Subscriptions; namespace NATS.Server.JetStream; @@ -40,6 +41,12 @@ public sealed class ConsumerManager : IDisposable return JetStreamApiResponse.ErrorResponse(400, "durable name required"); } + if (!JetStreamConfigValidator.IsValidName(config.DurableName)) + return JetStreamApiResponse.ErrorResponse(400, "invalid durable name"); + + if (!JetStreamConfigValidator.IsMetadataWithinLimit(config.Metadata)) + return JetStreamApiResponse.ErrorResponse(400, "consumer metadata exceeds maximum size"); + if (config.FilterSubjects.Count == 0 && !string.IsNullOrWhiteSpace(config.FilterSubject)) config.FilterSubjects.Add(config.FilterSubject); @@ -58,6 +65,8 @@ public sealed class ConsumerManager : IDisposable { ConsumerInfo = new JetStreamConsumerInfo { + Name = handle.Config.DurableName, + StreamName = stream, Config = handle.Config, }, }; @@ -71,6 +80,8 @@ public sealed class ConsumerManager : IDisposable { ConsumerInfo = new JetStreamConsumerInfo { + Name = handle.Config.DurableName, + StreamName = stream, Config = handle.Config, }, }; @@ -95,6 +106,13 @@ public sealed class ConsumerManager : IDisposable .OrderBy(x => x, StringComparer.Ordinal) .ToArray(); + public IReadOnlyList ListConsumerInfos(string stream) + => _consumers + .Where(kv => string.Equals(kv.Key.Stream, stream, StringComparison.Ordinal)) + .OrderBy(kv => kv.Key.Name, StringComparer.Ordinal) + .Select(kv => new JetStreamConsumerInfo { Name = kv.Value.Config.DurableName, StreamName = stream, Config = kv.Value.Config }) + .ToList(); + public bool Pause(string stream, string durableName, bool paused) { if (!_consumers.TryGetValue((stream, durableName), out var handle)) diff --git a/src/NATS.Server/JetStream/JetStreamParityModels.cs b/src/NATS.Server/JetStream/JetStreamParityModels.cs new file mode 100644 index 0000000..1d544be --- /dev/null +++ b/src/NATS.Server/JetStream/JetStreamParityModels.cs @@ -0,0 +1,58 @@ +namespace NATS.Server.JetStream; + +/// +/// API usage counters for JetStream. +/// Go reference: server/jetstream.go JetStreamAPIStats. +/// +public sealed class JetStreamApiStats +{ + public int Level { get; set; } + public ulong Total { get; set; } + public ulong Errors { get; set; } + public int Inflight { get; set; } +} + +/// +/// Per-tier JetStream resource view. +/// Go reference: server/jetstream.go JetStreamTier. +/// +public sealed class JetStreamTier +{ + public string Name { get; set; } = string.Empty; + public long Memory { get; set; } + public long Store { get; set; } + public int Streams { get; set; } + public int Consumers { get; set; } +} + +/// +/// Per-account JetStream limits. +/// Go reference: server/jetstream.go JetStreamAccountLimits. +/// +public sealed class JetStreamAccountLimits +{ + public long MaxMemory { get; set; } + public long MaxStore { get; set; } + public int MaxStreams { get; set; } + public int MaxConsumers { get; set; } + public int MaxAckPending { get; set; } + public long MemoryMaxStreamBytes { get; set; } + public long StoreMaxStreamBytes { get; set; } + public bool MaxBytesRequired { get; set; } + public Dictionary Tiers { get; set; } = new(StringComparer.Ordinal); +} + +/// +/// Server-level JetStream usage stats. +/// Go reference: server/jetstream.go JetStreamStats. +/// +public sealed class JetStreamStats +{ + public long Memory { get; set; } + public long Store { get; set; } + public long ReservedMemory { get; set; } + public long ReservedStore { get; set; } + public int Accounts { get; set; } + public int HaAssets { get; set; } + public JetStreamApiStats Api { get; set; } = new(); +} diff --git a/src/NATS.Server/JetStream/Models/StreamConfig.cs b/src/NATS.Server/JetStream/Models/StreamConfig.cs index d7e432e..ed96160 100644 --- a/src/NATS.Server/JetStream/Models/StreamConfig.cs +++ b/src/NATS.Server/JetStream/Models/StreamConfig.cs @@ -8,7 +8,18 @@ public sealed class StreamConfig public int MaxMsgs { get; set; } public long MaxBytes { get; set; } public int MaxMsgsPer { get; set; } + [System.Text.Json.Serialization.JsonIgnore] public int MaxAgeMs { get; set; } + + /// + /// MaxAge in nanoseconds for JSON wire compatibility with Go server. + /// Go reference: StreamConfig.MaxAge is a time.Duration (nanoseconds in JSON). + /// + public long MaxAge + { + get => (long)MaxAgeMs * 1_000_000L; + set => MaxAgeMs = (int)(value / 1_000_000); + } public int MaxMsgSize { get; set; } public int MaxConsumers { get; set; } public int DuplicateWindowMs { get; set; } diff --git a/src/NATS.Server/JetStream/Storage/FileStore.cs b/src/NATS.Server/JetStream/Storage/FileStore.cs index ca6e102..be1f047 100644 --- a/src/NATS.Server/JetStream/Storage/FileStore.cs +++ b/src/NATS.Server/JetStream/Storage/FileStore.cs @@ -774,11 +774,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable { if (string.IsNullOrEmpty(filter)) return true; - - if (NATS.Server.Subscriptions.SubjectMatch.IsLiteral(filter)) - return string.Equals(subject, filter, StringComparison.Ordinal); - - return NATS.Server.Subscriptions.SubjectMatch.MatchLiteral(subject, filter); + return NATS.Server.Subscriptions.SubjectMatch.SubjectMatchesFilter(subject, filter); } /// diff --git a/src/NATS.Server/JetStream/Storage/MemStore.cs b/src/NATS.Server/JetStream/Storage/MemStore.cs index 71c8c3f..b141768 100644 --- a/src/NATS.Server/JetStream/Storage/MemStore.cs +++ b/src/NATS.Server/JetStream/Storage/MemStore.cs @@ -1176,9 +1176,7 @@ public sealed class MemStore : IStreamStore { if (string.IsNullOrEmpty(filter) || filter == ">") return true; - if (SubjectMatch.IsLiteral(filter)) - return string.Equals(subject, filter, StringComparison.Ordinal); - return SubjectMatch.MatchLiteral(subject, filter); + return SubjectMatch.SubjectMatchesFilter(subject, filter); } // Fill a StoreMsg from an internal Msg diff --git a/src/NATS.Server/JetStream/StreamManager.cs b/src/NATS.Server/JetStream/StreamManager.cs index 671d5a2..7842a68 100644 --- a/src/NATS.Server/JetStream/StreamManager.cs +++ b/src/NATS.Server/JetStream/StreamManager.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text; using NATS.Server.Auth; using NATS.Server.JetStream.Api; using NATS.Server.JetStream.Cluster; @@ -7,11 +8,12 @@ using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Publish; using NATS.Server.JetStream.Snapshots; using NATS.Server.JetStream.Storage; +using NATS.Server.JetStream.Validation; using NATS.Server.Subscriptions; namespace NATS.Server.JetStream; -public sealed class StreamManager +public sealed class StreamManager : IDisposable { private readonly Account? _account; private readonly ConsumerManager? _consumerManager; @@ -25,12 +27,52 @@ public sealed class StreamManager private readonly ConcurrentDictionary> _sourcesByOrigin = new(StringComparer.Ordinal); private readonly StreamSnapshotService _snapshotService = new(); + private readonly CancellationTokenSource _expiryTimerCts = new(); + private Task? _expiryTimerTask; public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null, ConsumerManager? consumerManager = null) { _metaGroup = metaGroup; _account = account; _consumerManager = consumerManager; + _expiryTimerTask = RunExpiryTimerAsync(_expiryTimerCts.Token); + } + + public void Dispose() + { + _expiryTimerCts.Cancel(); + _expiryTimerCts.Dispose(); + } + + /// + /// Periodically prunes expired messages from streams with MaxAge configured. + /// Go reference: stream.go — expireMsgs runs on a timer (checkMaxAge interval). + /// + private async Task RunExpiryTimerAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (!ct.IsCancellationRequested) + { + var ticked = false; + try + { + ticked = await timer.WaitForNextTickAsync(ct); + } + catch (OperationCanceledException) + { + return; // Shutdown requested via Dispose — exit the timer loop + } + + if (!ticked) + return; + + var nowUtc = DateTime.UtcNow; + foreach (var stream in _streams.Values) + { + if (stream.Config.MaxAgeMs > 0) + PruneExpiredMessages(stream, nowUtc); + } + } } public IReadOnlyCollection StreamNames => _streams.Keys.ToArray(); @@ -39,10 +81,31 @@ public sealed class StreamManager public IReadOnlyList ListNames() => [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)]; + public IReadOnlyList ListStreamInfos() + { + return _streams.OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => + { + var state = kv.Value.Store.GetStateAsync(default).GetAwaiter().GetResult(); + return new JetStreamStreamInfo + { + Config = kv.Value.Config, + State = state, + }; + }) + .ToList(); + } + public JetStreamApiResponse CreateOrUpdate(StreamConfig config) { - if (string.IsNullOrWhiteSpace(config.Name)) - return JetStreamApiResponse.ErrorResponse(400, "stream name required"); + if (!JetStreamConfigValidator.IsValidName(config.Name)) + return JetStreamApiResponse.ErrorResponse(400, "invalid stream name"); + + if (Encoding.UTF8.GetByteCount(config.Description) > JetStreamApiLimits.JSMaxDescriptionLen) + return JetStreamApiResponse.ErrorResponse(400, "stream description is too long"); + + if (!JetStreamConfigValidator.IsMetadataWithinLimit(config.Metadata)) + return JetStreamApiResponse.ErrorResponse(400, "stream metadata exceeds maximum size"); var normalized = NormalizeConfig(config); @@ -302,6 +365,8 @@ public sealed class StreamManager if (stream == null) return null; + + if (stream.Config.MaxMsgSize > 0 && payload.Length > stream.Config.MaxMsgSize) { return new PubAck diff --git a/src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs b/src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs index b371e5c..0a5177a 100644 --- a/src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs +++ b/src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs @@ -1,10 +1,48 @@ +using System.Text; using NATS.Server.Configuration; +using NATS.Server.JetStream.Api; using NATS.Server.JetStream.Models; namespace NATS.Server.JetStream.Validation; public static class JetStreamConfigValidator { + public static bool IsValidName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + // Go len(name) checks byte length, not character length. + if (Encoding.UTF8.GetByteCount(name) > JetStreamApiLimits.JSMaxNameLen) + return false; + + foreach (var ch in name) + { + if (char.IsWhiteSpace(ch) || ch is '*' or '>') + return false; + } + + return true; + } + + public static bool IsMetadataWithinLimit(Dictionary? metadata) + => MetadataByteSize(metadata) <= JetStreamApiLimits.JSMaxMetadataLen; + + public static int MetadataByteSize(Dictionary? metadata) + { + if (metadata is null || metadata.Count == 0) + return 0; + + var size = 0; + foreach (var (key, value) in metadata) + { + size += Encoding.UTF8.GetByteCount(key); + size += Encoding.UTF8.GetByteCount(value); + } + + return size; + } + public static ValidationResult Validate(StreamConfig config) { if (string.IsNullOrWhiteSpace(config.Name) || config.Subjects.Count == 0) diff --git a/src/NATS.Server/LeafNodes/LeafConnectInfo.cs b/src/NATS.Server/LeafNodes/LeafConnectInfo.cs new file mode 100644 index 0000000..bf510ad --- /dev/null +++ b/src/NATS.Server/LeafNodes/LeafConnectInfo.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace NATS.Server.LeafNodes; + +/// +/// CONNECT payload sent on solicited leaf connections. +/// Go reference: leafnode.go leafConnectInfo. +/// +public sealed class LeafConnectInfo +{ + [JsonPropertyName("jwt")] + public string? Jwt { get; init; } + + [JsonPropertyName("nkey")] + public string? Nkey { get; init; } + + [JsonPropertyName("sig")] + public string? Sig { get; init; } + + [JsonPropertyName("hub")] + public bool Hub { get; init; } + + [JsonPropertyName("cluster")] + public string? Cluster { get; init; } + + [JsonPropertyName("headers")] + public bool Headers { get; init; } + + [JsonPropertyName("jetstream")] + public bool JetStream { get; init; } + + [JsonPropertyName("compression")] + public string? Compression { get; init; } + + [JsonPropertyName("remote_account")] + public string? RemoteAccount { get; init; } + + [JsonPropertyName("proto")] + public int Proto { get; init; } +} diff --git a/src/NATS.Server/LeafNodes/LeafConnection.cs b/src/NATS.Server/LeafNodes/LeafConnection.cs index 32470c6..c94c2c0 100644 --- a/src/NATS.Server/LeafNodes/LeafConnection.cs +++ b/src/NATS.Server/LeafNodes/LeafConnection.cs @@ -1,5 +1,6 @@ using System.Net.Sockets; using System.Text; +using System.Text.Json; using NATS.Server.Subscriptions; namespace NATS.Server.LeafNodes; @@ -15,6 +16,8 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable private readonly NetworkStream _stream = new(socket, ownsSocket: true); private readonly SemaphoreSlim _writeGate = new(1, 1); private readonly CancellationTokenSource _closedCts = new(); + private TimeSpan _connectDelay; + private string? _remoteCluster; private Task? _loopTask; public string? RemoteId { get; internal set; } @@ -22,6 +25,24 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable public Func? RemoteSubscriptionReceived { get; set; } public Func? MessageReceived { get; set; } + /// + /// True when this connection was solicited (outbound dial) rather than accepted inbound. + /// Go reference: isSolicitedLeafNode. + /// + public bool IsSolicited { get; internal set; } + + /// + /// True when this connection is a spoke-side leaf connection. + /// Go reference: isSpokeLeafNode / isHubLeafNode. + /// + public bool IsSpoke { get; set; } + + /// + /// True when this leaf connection is isolated from hub propagation. + /// Go reference: isIsolatedLeafNode. + /// + public bool Isolated { get; set; } + /// /// JetStream domain for this leaf connection. When set, the domain is propagated /// in the LEAF handshake and included in LMSG frames for domain-aware routing. @@ -58,6 +79,12 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable /// public bool PermsSynced { get; private set; } + /// + /// Returns the currently configured reconnect delay for this connection. + /// Go reference: leafnode.go setLeafConnectDelayIfSoliciting. + /// + public TimeSpan GetConnectDelay() => _connectDelay; + /// /// Sets the allowed publish and subscribe subjects for this connection and marks /// permissions as synced. Passing null for either list clears that list. @@ -106,11 +133,35 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable => _loopTask?.WaitAsync(ct) ?? Task.CompletedTask; public Task SendLsPlusAsync(string account, string subject, string? queue, CancellationToken ct) - => WriteLineAsync(queue is { Length: > 0 } ? $"LS+ {account} {subject} {queue}" : $"LS+ {account} {subject}", ct); + => SendLsPlusAsync(account, subject, queue, queueWeight: 0, ct); + + public Task SendLsPlusAsync(string account, string subject, string? queue, int queueWeight, CancellationToken ct) + { + string frame; + if (queue is { Length: > 0 } && queueWeight > 0) + frame = $"LS+ {account} {subject} {queue} {queueWeight}"; + else if (queue is { Length: > 0 }) + frame = $"LS+ {account} {subject} {queue}"; + else + frame = $"LS+ {account} {subject}"; + + return WriteLineAsync(frame, ct); + } public Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct) => WriteLineAsync(queue is { Length: > 0 } ? $"LS- {account} {subject} {queue}" : $"LS- {account} {subject}", ct); + /// + /// Sends a CONNECT protocol line with JSON payload for solicited leaf links. + /// Go reference: leafnode.go sendLeafConnect. + /// + public Task SendLeafConnectAsync(LeafConnectInfo connectInfo, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connectInfo); + var json = JsonSerializer.Serialize(connectInfo); + return WriteLineAsync($"CONNECT {json}", ct); + } + public async Task SendMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory payload, CancellationToken ct) { var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo; @@ -148,6 +199,63 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable return $"LEAF {serverId}"; } + public bool IsSolicitedLeafNode() => IsSolicited; + public bool IsSpokeLeafNode() => IsSpoke; + public bool IsHubLeafNode() => !IsSpoke; + public bool IsIsolatedLeafNode() => Isolated; + public string? RemoteCluster() => _remoteCluster; + + /// + /// Applies connect delay only when this is a solicited leaf connection. + /// Go reference: leafnode.go setLeafConnectDelayIfSoliciting. + /// + public void SetLeafConnectDelayIfSoliciting(TimeSpan delay) + { + if (IsSolicited) + _connectDelay = delay; + } + + /// + /// Handles remote ERR protocol for leaf links and applies reconnect delay hints. + /// Go reference: leafnode.go leafProcessErr. + /// + public void LeafProcessErr(string errStr) + { + if (string.IsNullOrWhiteSpace(errStr)) + return; + + if (errStr.Contains("permission", StringComparison.OrdinalIgnoreCase)) + { + SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectAfterPermViolation); + return; + } + + if (errStr.Contains("loop", StringComparison.OrdinalIgnoreCase)) + { + SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected); + return; + } + + if (errStr.Contains("cluster name", StringComparison.OrdinalIgnoreCase) + && errStr.Contains("same", StringComparison.OrdinalIgnoreCase)) + { + SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame); + } + } + + /// + /// Handles subscription permission violations. + /// Go reference: leafnode.go leafSubPermViolation. + /// + public void LeafSubPermViolation(string subj) => LeafPermViolation(pub: false, subj); + + /// + /// Handles publish/subscribe permission violations. + /// Go reference: leafnode.go leafPermViolation. + /// + public void LeafPermViolation(bool pub, string subj) + => SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectAfterPermViolation); + private void ParseHandshakeResponse(string line) { if (!line.StartsWith("LEAF ", StringComparison.OrdinalIgnoreCase)) @@ -163,9 +271,19 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable { RemoteId = rest[..spaceIdx]; var attrs = rest[(spaceIdx + 1)..]; - const string domainPrefix = "domain="; - if (attrs.StartsWith(domainPrefix, StringComparison.OrdinalIgnoreCase)) - RemoteJetStreamDomain = attrs[domainPrefix.Length..].Trim(); + foreach (var token in attrs.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + const string domainPrefix = "domain="; + if (token.StartsWith(domainPrefix, StringComparison.OrdinalIgnoreCase)) + { + RemoteJetStreamDomain = token[domainPrefix.Length..].Trim(); + continue; + } + + const string clusterPrefix = "cluster="; + if (token.StartsWith(clusterPrefix, StringComparison.OrdinalIgnoreCase)) + _remoteCluster = token[clusterPrefix.Length..].Trim(); + } } else { @@ -190,9 +308,10 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable if (line.StartsWith("LS+ ", StringComparison.Ordinal)) { var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue)) + if (RemoteSubscriptionReceived != null && + TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue, out var queueWeight)) { - await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount)); + await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount, QueueWeight: queueWeight)); } continue; } @@ -200,7 +319,8 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable if (line.StartsWith("LS- ", StringComparison.Ordinal)) { var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue)) + if (RemoteSubscriptionReceived != null && + TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue, out _)) { await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount)); } @@ -294,11 +414,12 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable return Encoding.ASCII.GetString([.. bytes]); } - private static bool TryParseAccountScopedInterest(string[] parts, out string account, out string subject, out string? queue) + private static bool TryParseAccountScopedInterest(string[] parts, out string account, out string subject, out string? queue, out int queueWeight) { account = "$G"; subject = string.Empty; queue = null; + queueWeight = 1; if (parts.Length < 2) return false; @@ -310,11 +431,15 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable account = parts[1]; subject = parts[2]; queue = parts.Length >= 4 ? parts[3] : null; + if (queue is { Length: > 0 } && parts.Length >= 5) + queueWeight = ParseQueueWeight(parts[4]); return true; } subject = parts[1]; queue = parts.Length >= 3 ? parts[2] : null; + if (queue is { Length: > 0 } && parts.Length >= 4) + queueWeight = ParseQueueWeight(parts[3]); return true; } @@ -322,6 +447,9 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable => token.Contains('.', StringComparison.Ordinal) || token.Contains('*', StringComparison.Ordinal) || token.Contains('>', StringComparison.Ordinal); + + private static int ParseQueueWeight(string token) + => int.TryParse(token, out var parsed) && parsed > 0 ? parsed : 1; } public sealed record LeafMessage(string Subject, string? ReplyTo, ReadOnlyMemory Payload, string Account = "$G"); diff --git a/src/NATS.Server/LeafNodes/LeafNodeManager.cs b/src/NATS.Server/LeafNodes/LeafNodeManager.cs index dc5db07..8ae9784 100644 --- a/src/NATS.Server/LeafNodes/LeafNodeManager.cs +++ b/src/NATS.Server/LeafNodes/LeafNodeManager.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Sockets; using Microsoft.Extensions.Logging; using NATS.Server.Configuration; +using NATS.Server.Gateways; using NATS.Server.Subscriptions; namespace NATS.Server.LeafNodes; @@ -16,6 +17,11 @@ namespace NATS.Server.LeafNodes; /// public sealed class LeafNodeManager : IAsyncDisposable { + public static readonly TimeSpan LeafNodeReconnectDelayAfterLoopDetected = TimeSpan.FromSeconds(30); + public static readonly TimeSpan LeafNodeReconnectAfterPermViolation = TimeSpan.FromSeconds(30); + public static readonly TimeSpan LeafNodeReconnectDelayAfterClusterNameSame = TimeSpan.FromSeconds(30); + public static readonly TimeSpan LeafNodeWaitBeforeClose = TimeSpan.FromSeconds(5); + private readonly LeafNodeOptions _options; private readonly ServerStats _stats; private readonly string _serverId; @@ -90,6 +96,27 @@ public sealed class LeafNodeManager : IAsyncDisposable public bool IsLeafConnectDisabled(string remoteUrl) => IsGloballyDisabled || _disabledRemotes.ContainsKey(remoteUrl); + /// + /// Returns true when the remote URL is still configured and not disabled. + /// Go reference: leafnode.go remoteLeafNodeStillValid. + /// + internal bool RemoteLeafNodeStillValid(string remoteUrl) + { + if (IsLeafConnectDisabled(remoteUrl)) + return false; + + if (_options.Remotes.Any(r => string.Equals(r, remoteUrl, StringComparison.OrdinalIgnoreCase))) + return true; + + foreach (var remote in _options.RemoteLeaves) + { + if (remote.Urls.Any(u => string.Equals(u, remoteUrl, StringComparison.OrdinalIgnoreCase))) + return true; + } + + return false; + } + /// /// Disables outbound leaf connections to the specified remote URL. /// Has no effect if the remote is already disabled. @@ -232,6 +259,8 @@ public sealed class LeafNodeManager : IAsyncDisposable var connection = new LeafConnection(socket) { JetStreamDomain = _options.JetStreamDomain, + IsSolicited = true, + IsSpoke = true, }; await connection.PerformOutboundHandshakeAsync(_serverId, ct); Register(connection); @@ -263,6 +292,9 @@ public sealed class LeafNodeManager : IAsyncDisposable } public void PropagateLocalSubscription(string account, string subject, string? queue) + => PropagateLocalSubscription(account, subject, queue, queueWeight: 0); + + public void PropagateLocalSubscription(string account, string subject, string? queue, int queueWeight) { // Subscription propagation is also subject to export filtering: // we don't propagate subscriptions for subjects that are denied. @@ -273,7 +305,18 @@ public sealed class LeafNodeManager : IAsyncDisposable } foreach (var connection in _connections.Values) - _ = connection.SendLsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None); + { + if (!CanSpokeSendSubscription(connection, subject)) + { + _logger.LogDebug( + "Leaf subscription propagation denied for spoke connection {RemoteId} and subject {Subject} (subscribe permissions)", + connection.RemoteId ?? "", + subject); + continue; + } + + _ = connection.SendLsPlusAsync(account, subject, queue, queueWeight, _cts?.Token ?? CancellationToken.None); + } } public void PropagateLocalUnsubscription(string account, string subject, string? queue) @@ -585,6 +628,9 @@ public sealed class LeafNodeManager : IAsyncDisposable var attempt = 0; while (!ct.IsCancellationRequested) { + if (!RemoteLeafNodeStillValid(remote)) + return; + try { var endPoint = ParseEndpoint(remote); @@ -595,6 +641,8 @@ public sealed class LeafNodeManager : IAsyncDisposable var connection = new LeafConnection(socket) { JetStreamDomain = jetStreamDomain, + IsSolicited = true, + IsSpoke = true, }; await connection.PerformOutboundHandshakeAsync(_serverId, ct); Register(connection); @@ -736,6 +784,39 @@ public sealed class LeafNodeManager : IAsyncDisposable return null; } + private static bool CanSpokeSendSubscription(LeafConnection connection, string subject) + { + if (!connection.IsSpokeLeafNode()) + return true; + + if (ShouldBypassSpokeSubscribePermission(subject)) + return true; + + if (!connection.PermsSynced || connection.AllowedSubscribeSubjects.Count == 0) + return true; + + for (var i = 0; i < connection.AllowedSubscribeSubjects.Count; i++) + { + if (SubjectMatch.MatchLiteral(subject, connection.AllowedSubscribeSubjects[i])) + return true; + } + + return false; + } + + private static bool ShouldBypassSpokeSubscribePermission(string subject) + { + if (string.IsNullOrEmpty(subject)) + return false; + + if (subject[0] != '$' && subject[0] != '_') + return false; + + return subject.StartsWith("$LDS.", StringComparison.Ordinal) + || subject.StartsWith(ReplyMapper.GatewayReplyPrefix, StringComparison.Ordinal) + || subject.StartsWith(ReplyMapper.OldGatewayReplyPrefix, StringComparison.Ordinal); + } + private static IPEndPoint ParseEndpoint(string endpoint) { var parts = endpoint.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); diff --git a/src/NATS.Server/LeafNodes/LeafSubKey.cs b/src/NATS.Server/LeafNodes/LeafSubKey.cs new file mode 100644 index 0000000..489cbdc --- /dev/null +++ b/src/NATS.Server/LeafNodes/LeafSubKey.cs @@ -0,0 +1,46 @@ +using NATS.Server.Subscriptions; + +namespace NATS.Server.LeafNodes; + +/// +/// Helpers for building leaf-node subscription map keys. +/// Go reference: server/leafnode.go keyFromSub / keyFromSubWithOrigin. +/// +public static class LeafSubKey +{ + public const string KeyRoutedSub = "R"; + public const byte KeyRoutedSubByte = (byte)'R'; + public const string KeyRoutedLeafSub = "L"; + public const byte KeyRoutedLeafSubByte = (byte)'L'; + + public static readonly TimeSpan SharedSysAccDelay = TimeSpan.FromMilliseconds(250); + public static readonly TimeSpan ConnectProcessTimeout = TimeSpan.FromSeconds(2); + + public static string KeyFromSub(Subscription sub) + { + ArgumentNullException.ThrowIfNull(sub); + return sub.Queue is { Length: > 0 } + ? $"{sub.Subject} {sub.Queue}" + : sub.Subject; + } + + public static string KeyFromSubWithOrigin(Subscription sub, string? origin = null) + { + ArgumentNullException.ThrowIfNull(sub); + var hasOrigin = !string.IsNullOrEmpty(origin); + var prefix = hasOrigin ? KeyRoutedLeafSub : KeyRoutedSub; + + if (sub.Queue is { Length: > 0 }) + { + if (hasOrigin) + return $"{prefix} {sub.Subject} {sub.Queue} {origin}"; + + return $"{prefix} {sub.Subject} {sub.Queue}"; + } + + if (hasOrigin) + return $"{prefix} {sub.Subject} {origin}"; + + return $"{prefix} {sub.Subject}"; + } +} diff --git a/src/NATS.Server/Monitoring/ClosedClient.cs b/src/NATS.Server/Monitoring/ClosedClient.cs index 986ebf7..b69b937 100644 --- a/src/NATS.Server/Monitoring/ClosedClient.cs +++ b/src/NATS.Server/Monitoring/ClosedClient.cs @@ -25,8 +25,13 @@ public sealed record ClosedClient public string TlsVersion { get; init; } = ""; public string TlsCipherSuite { get; init; } = ""; public string TlsPeerCertSubject { get; init; } = ""; + public string TlsPeerCertSubjectPkSha256 { get; init; } = ""; + public string TlsPeerCertSha256 { get; init; } = ""; public string MqttClient { get; init; } = ""; - public string JwtIssuerKey { get; init; } = ""; - public string JwtTags { get; init; } = ""; - public string Proxy { get; init; } = ""; + public long Stalls { get; init; } + public string Jwt { get; init; } = ""; + public string IssuerKey { get; init; } = ""; + public string NameTag { get; init; } = ""; + public string[] Tags { get; init; } = []; + public string ProxyKey { get; init; } = ""; } diff --git a/src/NATS.Server/Monitoring/Connz.cs b/src/NATS.Server/Monitoring/Connz.cs index 912d041..af9ee67 100644 --- a/src/NATS.Server/Monitoring/Connz.cs +++ b/src/NATS.Server/Monitoring/Connz.cs @@ -86,6 +86,9 @@ public sealed class ConnInfo [JsonPropertyName("out_bytes")] public long OutBytes { get; set; } + [JsonPropertyName("stalls")] + public long Stalls { get; set; } + [JsonPropertyName("subscriptions")] public uint NumSubs { get; set; } @@ -119,20 +122,54 @@ public sealed class ConnInfo [JsonPropertyName("tls_peer_cert_subject")] public string TlsPeerCertSubject { get; set; } = ""; + [JsonPropertyName("tls_peer_certs")] + public TLSPeerCert[] TlsPeerCerts { get; set; } = []; + [JsonPropertyName("tls_first")] public bool TlsFirst { get; set; } [JsonPropertyName("mqtt_client")] public string MqttClient { get; set; } = ""; - [JsonPropertyName("jwt_issuer_key")] - public string JwtIssuerKey { get; set; } = ""; + [JsonPropertyName("jwt")] + public string Jwt { get; set; } = ""; - [JsonPropertyName("jwt_tags")] - public string JwtTags { get; set; } = ""; + [JsonPropertyName("issuer_key")] + public string IssuerKey { get; set; } = ""; + + [JsonPropertyName("name_tag")] + public string NameTag { get; set; } = ""; + + [JsonPropertyName("tags")] + public string[] Tags { get; set; } = []; [JsonPropertyName("proxy")] - public string Proxy { get; set; } = ""; + public ProxyInfo? Proxy { get; set; } +} + +/// +/// Proxy metadata for proxied client connections. +/// Corresponds to Go server/monitor.go ProxyInfo. +/// +public sealed class ProxyInfo +{ + [JsonPropertyName("key")] + public string Key { get; set; } = ""; +} + +/// +/// TLS peer certificate detail for /connz parity with Go monitor.go. +/// +public sealed class TLSPeerCert +{ + [JsonPropertyName("subject")] + public string Subject { get; set; } = ""; + + [JsonPropertyName("subject_pk_sha256")] + public string SubjectPKISha256 { get; set; } = ""; + + [JsonPropertyName("cert_sha256")] + public string CertSha256 { get; set; } = ""; } /// @@ -610,6 +647,28 @@ public enum SortOpt ByReason, } +public static class SortOptExtensions +{ + /// + /// Go parity for SortOpt.IsValid(). + /// + public static bool IsValid(this SortOpt sort) => + sort is SortOpt.ByCid + or SortOpt.ByStart + or SortOpt.BySubs + or SortOpt.ByPending + or SortOpt.ByMsgsTo + or SortOpt.ByMsgsFrom + or SortOpt.ByBytesTo + or SortOpt.ByBytesFrom + or SortOpt.ByLast + or SortOpt.ByIdle + or SortOpt.ByUptime + or SortOpt.ByRtt + or SortOpt.ByStop + or SortOpt.ByReason; +} + /// /// Connection state filter. /// Corresponds to Go server/monitor.go ConnState type. diff --git a/src/NATS.Server/Monitoring/ConnzHandler.cs b/src/NATS.Server/Monitoring/ConnzHandler.cs index 4ad791e..fb8d7c8 100644 --- a/src/NATS.Server/Monitoring/ConnzHandler.cs +++ b/src/NATS.Server/Monitoring/ConnzHandler.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using NATS.Server.Auth.Jwt; using NATS.Server.Subscriptions; namespace NATS.Server.Monitoring; @@ -143,6 +144,12 @@ public sealed class ConnzHandler(NatsServer server) private static ConnInfo BuildConnInfo(NatsClient client, DateTime now, ConnzOptions opts) { + var tlsPeerCerts = TlsPeerCertMapper.FromCertificate(client.TlsState?.PeerCert); + var (jwt, issuerKey, tags) = opts.Auth + ? ExtractJwtMetadata(client.ClientOpts?.JWT) + : ("", "", Array.Empty()); + var proxyKey = ExtractProxyKey(client.ClientOpts?.Username); + var info = new ConnInfo { Cid = client.Id, @@ -158,6 +165,7 @@ public sealed class ConnzHandler(NatsServer server) OutMsgs = Interlocked.Read(ref client.OutMsgs), InBytes = Interlocked.Read(ref client.InBytes), OutBytes = Interlocked.Read(ref client.OutBytes), + Stalls = 0, NumSubs = (uint)client.Subscriptions.Count, Name = client.ClientOpts?.Name ?? "", Lang = client.ClientOpts?.Lang ?? "", @@ -168,10 +176,13 @@ public sealed class ConnzHandler(NatsServer server) Reason = client.CloseReason.ToReasonString(), TlsVersion = client.TlsState?.TlsVersion ?? "", TlsCipherSuite = client.TlsState?.CipherSuite ?? "", - TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "", - JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present", - JwtTags = "", - Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "", + TlsPeerCertSubject = tlsPeerCerts.FirstOrDefault()?.Subject ?? "", + TlsPeerCerts = tlsPeerCerts, + Jwt = jwt, + IssuerKey = issuerKey, + NameTag = "", + Tags = tags, + Proxy = string.IsNullOrEmpty(proxyKey) ? null : new ProxyInfo { Key = proxyKey }, Rtt = FormatRtt(client.Rtt), }; @@ -202,6 +213,8 @@ public sealed class ConnzHandler(NatsServer server) private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts) { + var tlsPeerCerts = TlsPeerCertMapper.FromClosedClient(closed); + return new ConnInfo { Cid = closed.Cid, @@ -229,10 +242,14 @@ public sealed class ConnzHandler(NatsServer server) TlsVersion = closed.TlsVersion, TlsCipherSuite = closed.TlsCipherSuite, TlsPeerCertSubject = closed.TlsPeerCertSubject, + TlsPeerCerts = tlsPeerCerts, MqttClient = closed.MqttClient, - JwtIssuerKey = closed.JwtIssuerKey, - JwtTags = closed.JwtTags, - Proxy = closed.Proxy, + Stalls = closed.Stalls, + Jwt = closed.Jwt, + IssuerKey = closed.IssuerKey, + NameTag = closed.NameTag, + Tags = closed.Tags, + Proxy = string.IsNullOrEmpty(closed.ProxyKey) ? null : new ProxyInfo { Key = closed.ProxyKey }, }; } @@ -243,7 +260,7 @@ public sealed class ConnzHandler(NatsServer server) if (q.TryGetValue("sort", out var sort)) { - opts.Sort = sort.ToString().ToLowerInvariant() switch + var parsedSort = sort.ToString().ToLowerInvariant() switch { "cid" => SortOpt.ByCid, "start" => SortOpt.ByStart, @@ -261,6 +278,8 @@ public sealed class ConnzHandler(NatsServer server) "reason" => SortOpt.ByReason, _ => SortOpt.ByCid, }; + + opts.Sort = parsedSort.IsValid() ? parsedSort : SortOpt.ByCid; } if (q.TryGetValue("subs", out var subs)) @@ -338,4 +357,32 @@ public sealed class ConnzHandler(NatsServer server) return $"{(int)ts.TotalMinutes}m{ts.Seconds}s"; return $"{(int)ts.TotalSeconds}s"; } + + private static (string Jwt, string IssuerKey, string[] Tags) ExtractJwtMetadata(string? jwt) + { + if (string.IsNullOrWhiteSpace(jwt)) + return ("", "", []); + + var issuerKey = ""; + var tags = Array.Empty(); + var claims = NatsJwt.DecodeUserClaims(jwt); + if (claims != null) + { + issuerKey = claims.Issuer ?? ""; + tags = claims.Nats?.Tags ?? Array.Empty(); + } + + return (jwt, issuerKey, tags); + } + + private static string ExtractProxyKey(string? username) + { + if (string.IsNullOrWhiteSpace(username)) + return ""; + + const string prefix = "proxy:"; + return username.StartsWith(prefix, StringComparison.Ordinal) + ? username[prefix.Length..] + : ""; + } } diff --git a/src/NATS.Server/Monitoring/Healthz.cs b/src/NATS.Server/Monitoring/Healthz.cs new file mode 100644 index 0000000..b2aabc1 --- /dev/null +++ b/src/NATS.Server/Monitoring/Healthz.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace NATS.Server.Monitoring; + +/// +/// Structured health response shape for /healthz. +/// Go reference: monitor.go HealthStatus. +/// +public sealed class HealthStatus +{ + [JsonPropertyName("status")] + public string Status { get; init; } = "ok"; + + [JsonPropertyName("status_code")] + public int StatusCode { get; init; } = 200; + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("errors")] + public HealthzError[] Errors { get; init; } = []; + + public static HealthStatus Ok() => new(); +} + +/// +/// Individual health check failure record. +/// Go reference: monitor.go HealthzError. +/// +public sealed class HealthzError +{ + [JsonPropertyName("type")] + public HealthzErrorType Type { get; init; } = HealthzErrorType.Unknown; + + [JsonPropertyName("error")] + public string Error { get; init; } = string.Empty; +} + +/// +/// Health error classification. +/// Go reference: monitor.go HealthZErrorType. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum HealthzErrorType +{ + Unknown, + JetStream, + Account, + Cluster, +} diff --git a/src/NATS.Server/Monitoring/MonitorServer.cs b/src/NATS.Server/Monitoring/MonitorServer.cs index 840139e..de86481 100644 --- a/src/NATS.Server/Monitoring/MonitorServer.cs +++ b/src/NATS.Server/Monitoring/MonitorServer.cs @@ -34,7 +34,7 @@ public sealed class MonitorServer : IAsyncDisposable _app = builder.Build(); var basePath = options.MonitorBasePath ?? ""; - _varzHandler = new VarzHandler(server, options); + _varzHandler = new VarzHandler(server, options, loggerFactory); _connzHandler = new ConnzHandler(server); _subszHandler = new SubszHandler(server); _jszHandler = new JszHandler(server, options); @@ -59,7 +59,7 @@ public sealed class MonitorServer : IAsyncDisposable _app.MapGet(basePath + "/healthz", () => { stats.HttpReqStats.AddOrUpdate("/healthz", 1, (_, v) => v + 1); - return Results.Ok("ok"); + return Results.Ok(HealthStatus.Ok()); }); _app.MapGet(basePath + "/varz", async (HttpContext ctx) => { diff --git a/src/NATS.Server/Monitoring/TlsPeerCertMapper.cs b/src/NATS.Server/Monitoring/TlsPeerCertMapper.cs new file mode 100644 index 0000000..f532a79 --- /dev/null +++ b/src/NATS.Server/Monitoring/TlsPeerCertMapper.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace NATS.Server.Monitoring; + +internal static class TlsPeerCertMapper +{ + public static TLSPeerCert[] FromCertificate(X509Certificate2? cert) + { + if (cert == null) + return []; + + return + [ + new TLSPeerCert + { + Subject = cert.Subject ?? string.Empty, + SubjectPKISha256 = ComputeSubjectPkSha256(cert), + CertSha256 = ComputeCertSha256(cert), + }, + ]; + } + + public static TLSPeerCert[] FromClosedClient(ClosedClient closed) + { + if (string.IsNullOrEmpty(closed.TlsPeerCertSubject)) + return []; + + return + [ + new TLSPeerCert + { + Subject = closed.TlsPeerCertSubject, + SubjectPKISha256 = closed.TlsPeerCertSubjectPkSha256, + CertSha256 = closed.TlsPeerCertSha256, + }, + ]; + } + + public static (string Subject, string SubjectPkSha256, string CertSha256) ToClosedFields(X509Certificate2? cert) + { + if (cert == null) + return (string.Empty, string.Empty, string.Empty); + + return ( + cert.Subject ?? string.Empty, + ComputeSubjectPkSha256(cert), + ComputeCertSha256(cert) + ); + } + + private static string ComputeSubjectPkSha256(X509Certificate2 cert) + => ToHexLower(SHA256.HashData(cert.GetPublicKey())); + + private static string ComputeCertSha256(X509Certificate2 cert) + => ToHexLower(SHA256.HashData(cert.RawData)); + + private static string ToHexLower(ReadOnlySpan bytes) + => Convert.ToHexString(bytes).ToLowerInvariant(); +} diff --git a/src/NATS.Server/Monitoring/VarzHandler.cs b/src/NATS.Server/Monitoring/VarzHandler.cs index 9aea46d..9719b4a 100644 --- a/src/NATS.Server/Monitoring/VarzHandler.cs +++ b/src/NATS.Server/Monitoring/VarzHandler.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; using NATS.Server.Protocol; namespace NATS.Server.Monitoring; @@ -13,18 +14,23 @@ public sealed class VarzHandler : IDisposable { private readonly NatsServer _server; private readonly NatsOptions _options; + private readonly ILogger _logger; private readonly SemaphoreSlim _varzMu = new(1, 1); + private readonly object _cpuSampleSync = new(); + private readonly Timer _cpuSampleTimer; private DateTime _lastCpuSampleTime; private TimeSpan _lastCpuUsage; private double _cachedCpuPercent; - public VarzHandler(NatsServer server, NatsOptions options) + public VarzHandler(NatsServer server, NatsOptions options, ILoggerFactory loggerFactory) { _server = server; _options = options; + _logger = loggerFactory.CreateLogger(); using var proc = Process.GetCurrentProcess(); _lastCpuSampleTime = DateTime.UtcNow; _lastCpuUsage = proc.TotalProcessorTime; + _cpuSampleTimer = new Timer(_ => SampleCpuUsage(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); } public async Task HandleVarzAsync(CancellationToken ct = default) @@ -37,16 +43,7 @@ public sealed class VarzHandler : IDisposable var uptime = now - _server.StartTime; var stats = _server.Stats; - // CPU sampling with 1-second cache to avoid excessive sampling - if ((now - _lastCpuSampleTime).TotalSeconds >= 1.0) - { - var currentCpu = proc.TotalProcessorTime; - var elapsed = now - _lastCpuSampleTime; - _cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds - / elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0; - _lastCpuSampleTime = now; - _lastCpuUsage = currentCpu; - } + var cachedCpuPercent = GetCachedCpuPercent(); // Load the TLS certificate to report its expiry date in /varz. // Corresponds to Go server/monitor.go handleVarz populating TLSCertExpiry. @@ -93,7 +90,7 @@ public sealed class VarzHandler : IDisposable Now = now, Uptime = FormatUptime(uptime), Mem = proc.WorkingSet64, - Cpu = Math.Round(_cachedCpuPercent, 2), + Cpu = Math.Round(cachedCpuPercent, 2), Cores = Environment.ProcessorCount, MaxProcs = ThreadPool.ThreadCount, Connections = _server.ClientCount, @@ -153,9 +150,43 @@ public sealed class VarzHandler : IDisposable public void Dispose() { + _cpuSampleTimer.Dispose(); _varzMu.Dispose(); } + private void SampleCpuUsage() + { + try + { + using var proc = Process.GetCurrentProcess(); + var now = DateTime.UtcNow; + lock (_cpuSampleSync) + { + var currentCpu = proc.TotalProcessorTime; + var elapsed = now - _lastCpuSampleTime; + if (elapsed.TotalMilliseconds <= 0) + return; + + _cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds + / elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0; + _lastCpuSampleTime = now; + _lastCpuUsage = currentCpu; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "CPU usage sampling failed; retaining last cached value"); + } + } + + private double GetCachedCpuPercent() + { + lock (_cpuSampleSync) + { + return _cachedCpuPercent; + } + } + private MqttOptsVarz BuildMqttVarz() { var mqtt = _options.Mqtt; diff --git a/src/NATS.Server/Mqtt/MqttBinaryDecoder.cs b/src/NATS.Server/Mqtt/MqttBinaryDecoder.cs index 8136618..ec326be 100644 --- a/src/NATS.Server/Mqtt/MqttBinaryDecoder.cs +++ b/src/NATS.Server/Mqtt/MqttBinaryDecoder.cs @@ -193,9 +193,15 @@ public static class MqttBinaryDecoder /// Parses the payload bytes of an MQTT SUBSCRIBE packet. /// /// The payload bytes from . + /// + /// Optional fixed-header flags nibble. When provided, must match SUBSCRIBE flags (0x02). + /// /// A populated . - public static MqttSubscribeInfo ParseSubscribe(ReadOnlySpan payload) + public static MqttSubscribeInfo ParseSubscribe(ReadOnlySpan payload, byte? flags = null) { + if (flags.HasValue && flags.Value != MqttProtocolConstants.SubscribeFlags) + throw new FormatException("MQTT SUBSCRIBE packet has invalid fixed-header flags."); + // Variable header: packet identifier (2 bytes, big-endian) // Payload: one or more topic-filter entries, each: // 2-byte length prefix + UTF-8 filter string + 1-byte requested QoS diff --git a/src/NATS.Server/Mqtt/MqttPacketReader.cs b/src/NATS.Server/Mqtt/MqttPacketReader.cs index e188097..78d7ad8 100644 --- a/src/NATS.Server/Mqtt/MqttPacketReader.cs +++ b/src/NATS.Server/Mqtt/MqttPacketReader.cs @@ -31,6 +31,8 @@ public static class MqttPacketReader var type = (MqttControlPacketType)(first >> 4); var flags = (byte)(first & 0x0F); var remainingLength = DecodeRemainingLength(buffer[1..], out var consumed); + if (remainingLength > MqttProtocolConstants.MaxPayloadSize) + throw new FormatException("MQTT packet remaining length exceeds protocol maximum."); var payloadStart = 1 + consumed; var totalLength = payloadStart + remainingLength; if (remainingLength < 0 || totalLength > buffer.Length) diff --git a/src/NATS.Server/Mqtt/MqttPacketWriter.cs b/src/NATS.Server/Mqtt/MqttPacketWriter.cs index e459010..ad9802b 100644 --- a/src/NATS.Server/Mqtt/MqttPacketWriter.cs +++ b/src/NATS.Server/Mqtt/MqttPacketWriter.cs @@ -1,7 +1,24 @@ +using System.Buffers.Binary; +using System.Text; + namespace NATS.Server.Mqtt; public static class MqttPacketWriter { + public static byte[] WriteString(string value) + => WriteBytes(Encoding.UTF8.GetBytes(value)); + + public static byte[] WriteBytes(ReadOnlySpan bytes) + { + if (bytes.Length > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(bytes), "MQTT length-prefixed field cannot exceed 65535 bytes."); + + var buffer = new byte[2 + bytes.Length]; + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, 2), (ushort)bytes.Length); + bytes.CopyTo(buffer.AsSpan(2)); + return buffer; + } + public static byte[] Write(MqttControlPacketType type, ReadOnlySpan payload, byte flags = 0) { if (type == MqttControlPacketType.Reserved) @@ -18,8 +35,10 @@ public static class MqttPacketWriter internal static byte[] EncodeRemainingLength(int value) { - if (value < 0 || value > 268_435_455) - throw new ArgumentOutOfRangeException(nameof(value), "MQTT remaining length must be between 0 and 268435455."); + if (value < 0 || value > MqttProtocolConstants.MaxPayloadSize) + throw new ArgumentOutOfRangeException( + nameof(value), + $"MQTT remaining length must be between 0 and {MqttProtocolConstants.MaxPayloadSize}."); Span scratch = stackalloc byte[4]; var index = 0; diff --git a/src/NATS.Server/Mqtt/MqttParityModels.cs b/src/NATS.Server/Mqtt/MqttParityModels.cs new file mode 100644 index 0000000..82c95ae --- /dev/null +++ b/src/NATS.Server/Mqtt/MqttParityModels.cs @@ -0,0 +1,90 @@ +namespace NATS.Server.Mqtt; + +/// +/// JetStream API helper context for MQTT account/session operations. +/// Go reference: mqtt.go mqttJSA. +/// +public sealed class MqttJsa +{ + public string AccountName { get; set; } = string.Empty; + public string ReplyPrefix { get; set; } = string.Empty; + public string? Domain { get; set; } +} + +/// +/// MQTT JetStream publish request shape. +/// Go reference: mqtt.go mqttJSPubMsg. +/// +public sealed class MqttJsPubMsg +{ + public string Subject { get; set; } = string.Empty; + public byte[] Payload { get; set; } = []; + public string? ReplyTo { get; set; } +} + +/// +/// Retained-message delete notification payload. +/// Go reference: mqtt.go mqttRetMsgDel. +/// +public sealed class MqttRetMsgDel +{ + public string Topic { get; set; } = string.Empty; + public ulong Sequence { get; set; } +} + +/// +/// Persisted MQTT session metadata. +/// Go reference: mqtt.go mqttPersistedSession. +/// +public sealed class MqttPersistedSession +{ + public string ClientId { get; set; } = string.Empty; + public int LastPacketId { get; set; } + public int MaxAckPending { get; set; } +} + +/// +/// Reference to a retained message in storage. +/// Go reference: mqtt.go mqttRetainedMsgRef. +/// +public sealed class MqttRetainedMessageRef +{ + public ulong StreamSequence { get; set; } + public string Subject { get; set; } = string.Empty; +} + +/// +/// MQTT subscription metadata. +/// Go reference: mqtt.go mqttSub. +/// +public sealed class MqttSub +{ + public string Filter { get; set; } = string.Empty; + public byte Qos { get; set; } + public string? JsDur { get; set; } + public bool Prm { get; set; } + public bool Reserved { get; set; } +} + +/// +/// Parsed MQTT filter metadata. +/// Go reference: mqtt.go mqttFilter. +/// +public sealed class MqttFilter +{ + public string Filter { get; set; } = string.Empty; + public byte Qos { get; set; } + public string? TopicToken { get; set; } +} + +/// +/// Parsed NATS headers associated with an MQTT publish flow. +/// Go reference: mqtt.go mqttParsedPublishNATSHeader. +/// +public sealed class MqttParsedPublishNatsHeader +{ + public string? Subject { get; set; } + public string? Mapped { get; set; } + public bool IsPublish { get; set; } + public bool IsPubRel { get; set; } +} diff --git a/src/NATS.Server/Mqtt/MqttProtocolConstants.cs b/src/NATS.Server/Mqtt/MqttProtocolConstants.cs new file mode 100644 index 0000000..6bfad1a --- /dev/null +++ b/src/NATS.Server/Mqtt/MqttProtocolConstants.cs @@ -0,0 +1,91 @@ +namespace NATS.Server.Mqtt; + +/// +/// MQTT protocol and server integration constants aligned with Go mqtt.go. +/// +public static class MqttProtocolConstants +{ + // MQTT fixed-header flags for SUBSCRIBE packets (MQTT 3.1.1 section 3.8.1). + public const byte SubscribeFlags = 0x02; + + // MQTT 3.1.1 CONNACK return codes. + public const byte ConnAckAccepted = 0x00; + public const byte ConnAckUnacceptableProtocolVersion = 0x01; + public const byte ConnAckIdentifierRejected = 0x02; + public const byte ConnAckServerUnavailable = 0x03; + public const byte ConnAckBadUserNameOrPassword = 0x04; + public const byte ConnAckNotAuthorized = 0x05; + + // MQTT Remaining Length upper bound (max packet payload envelope). + public const int MaxPayloadSize = 268_435_455; + + // Go mqttDefaultAckWait: 30 seconds. + public static readonly TimeSpan DefaultAckWait = TimeSpan.FromSeconds(30); + + // Go mqttMaxAckTotalLimit. + public const int MaxAckTotalLimit = 0xFFFF; + + // MQTT wildcard helper suffix attached to sid for synthetic "level-up" subscriptions. + public const string MultiLevelSidSuffix = " fwc"; + + // Internal MQTT subject prefixes. + public const string Prefix = "$MQTT."; + public const string SubPrefix = Prefix + "sub."; + + // Per-account stream names and subject prefixes. + public const string StreamName = "$MQTT_msgs"; + public const string StreamSubjectPrefix = Prefix + "msgs."; + public const string RetainedMsgsStreamName = "$MQTT_rmsgs"; + public const string RetainedMsgsStreamSubject = Prefix + "rmsgs."; + public const string SessStreamName = "$MQTT_sess"; + public const string SessStreamSubjectPrefix = Prefix + "sess."; + public const string SessionsStreamNamePrefix = "$MQTT_sess_"; + public const string QoS2IncomingMsgsStreamName = "$MQTT_qos2in"; + public const string QoS2IncomingMsgsStreamSubjectPrefix = Prefix + "qos2.in."; + + // Outbound/PUBREL stream and subject identifiers. + public const string OutStreamName = "$MQTT_out"; + public const string OutSubjectPrefix = Prefix + "out."; + public const string PubRelSubjectPrefix = Prefix + "out.pubrel."; + public const string PubRelDeliverySubjectPrefix = Prefix + "deliver.pubrel."; + public const string PubRelConsumerDurablePrefix = "$MQTT_PUBREL_"; + + // JS API reply subject prefix and token positions. + public const string JSARepliesPrefix = Prefix + "JSA."; + public const int JSAIdTokenPos = 3; + public const int JSATokenPos = 4; + public const int JSAClientIDPos = 5; + + // JS API token discriminators. + 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"; + + // Sparkplug B constants. + public const string SparkbNBirth = "NBIRTH"; + public const string SparkbDBirth = "DBIRTH"; + public const string SparkbNDeath = "NDEATH"; + public const string SparkbDDeath = "DDEATH"; + public static readonly byte[] SparkbNamespaceTopicPrefix = "spBv1.0/"u8.ToArray(); + public static readonly byte[] SparkbCertificatesTopicPrefix = "$sparkplug/certificates/"u8.ToArray(); + + // NATS headers used when re-encoding MQTT messages. + public const string NatsHeaderPublish = "Nmqtt-Pub"; + public const string NatsRetainedMessageTopic = "Nmqtt-RTopic"; + public const string NatsRetainedMessageOrigin = "Nmqtt-ROrigin"; + public const string NatsRetainedMessageFlags = "Nmqtt-RFlags"; + public const string NatsRetainedMessageSource = "Nmqtt-RSource"; + public const string NatsPubRelHeader = "Nmqtt-PubRel"; + public const string NatsHeaderSubject = "Nmqtt-Subject"; + public const string NatsHeaderMapped = "Nmqtt-Mapped"; +} diff --git a/src/NATS.Server/Mqtt/MqttRetainedStore.cs b/src/NATS.Server/Mqtt/MqttRetainedStore.cs index 7ae70d4..70caab1 100644 --- a/src/NATS.Server/Mqtt/MqttRetainedStore.cs +++ b/src/NATS.Server/Mqtt/MqttRetainedStore.cs @@ -11,7 +11,12 @@ namespace NATS.Server.Mqtt; /// /// A retained message stored for a topic. /// -public sealed record MqttRetainedMessage(string Topic, ReadOnlyMemory Payload); +public sealed record MqttRetainedMessage( + string Topic, + ReadOnlyMemory Payload, + string? Origin = null, + byte Flags = 0, + string? Source = null); /// /// In-memory store for MQTT retained messages. diff --git a/src/NATS.Server/NatsClient.cs b/src/NATS.Server/NatsClient.cs index 440112f..1793e58 100644 --- a/src/NATS.Server/NatsClient.cs +++ b/src/NATS.Server/NatsClient.cs @@ -104,6 +104,7 @@ public sealed class NatsClient : INatsClient, IDisposable private long _rtt; public TimeSpan Rtt => new(Interlocked.Read(ref _rtt)); + public bool IsMqtt { get; set; } public bool IsWebSocket { get; set; } public WsUpgradeResult? WsInfo { get; set; } @@ -137,6 +138,27 @@ public sealed class NatsClient : INatsClient, IDisposable } } + public byte[]? GetNonce() => _nonce?.ToArray(); + + public string GetName() => ClientOpts?.Name ?? string.Empty; + + public ClientConnectionType ClientType() + { + if (Kind != ClientKind.Client) + return ClientConnectionType.NonClient; + if (IsMqtt) + return ClientConnectionType.Mqtt; + if (IsWebSocket) + return ClientConnectionType.WebSocket; + return ClientConnectionType.Nats; + } + + public override string ToString() + { + var endpoint = RemoteIp is null ? "unknown" : $"{RemoteIp}:{RemotePort}"; + return $"{Kind} cid={Id} endpoint={endpoint}"; + } + public bool QueueOutbound(ReadOnlyMemory data) { if (_flags.HasFlag(ClientFlags.CloseConnection)) diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index 362e2ce..89a4b2b 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -1,22 +1,29 @@ +using System.Security.Cryptography; using System.Security.Authentication; +using System.Text; +using System.Text.Json; using NATS.Server.Auth; using NATS.Server.Configuration; +using NATS.Server.Protocol; using NATS.Server.Tls; namespace NATS.Server; public sealed class NatsOptions { - public string Host { get; set; } = "0.0.0.0"; - public int Port { get; set; } = 4222; + private static bool _allowUnknownTopLevelFields; + private string _configDigest = string.Empty; + + public string Host { get; set; } = NatsProtocol.DefaultHost; + public int Port { get; set; } = NatsProtocol.DefaultPort; public string? ServerName { get; set; } public int MaxPayload { get; set; } = 1024 * 1024; public int MaxControlLine { get; set; } = 4096; - public int MaxConnections { get; set; } = 65536; + public int MaxConnections { get; set; } = NatsProtocol.DefaultMaxConnections; public long MaxPending { get; set; } = 64 * 1024 * 1024; // 64MB, matching Go MAX_PENDING_SIZE - public TimeSpan WriteDeadline { get; set; } = TimeSpan.FromSeconds(10); - public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2); - public int MaxPingsOut { get; set; } = 2; + public TimeSpan WriteDeadline { get; set; } = NatsProtocol.DefaultFlushDeadline; + public TimeSpan PingInterval { get; set; } = NatsProtocol.DefaultPingInterval; + public int MaxPingsOut { get; set; } = NatsProtocol.DefaultPingMaxOut; // Subscription limits public int MaxSubs { get; set; } // 0 = unlimited (per-connection) @@ -45,7 +52,7 @@ public sealed class NatsOptions public Auth.ProxyAuthOptions? ProxyAuth { get; set; } // Auth timing - public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2); + public TimeSpan AuthTimeout { get; set; } = NatsProtocol.AuthTimeout; // Monitoring (0 = disabled; standard port is 8222) public int MonitorPort { get; set; } @@ -55,8 +62,8 @@ public sealed class NatsOptions public int MonitorHttpsPort { get; set; } // Lifecycle / lame-duck mode - public TimeSpan LameDuckDuration { get; set; } = TimeSpan.FromMinutes(2); - public TimeSpan LameDuckGracePeriod { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan LameDuckDuration { get; set; } = NatsProtocol.DefaultLameDuckDuration; + public TimeSpan LameDuckGracePeriod { get; set; } = NatsProtocol.DefaultLameDuckGracePeriod; // File paths public string? PidFile { get; set; } @@ -82,10 +89,10 @@ public sealed class NatsOptions public bool TraceVerbose { get; set; } public int MaxTracedMsgLen { get; set; } public bool DisableSublistCache { get; set; } - public int ConnectErrorReports { get; set; } = 3600; - public int ReconnectErrorReports { get; set; } = 1; + public int ConnectErrorReports { get; set; } = NatsProtocol.DefaultConnectErrorReports; + public int ReconnectErrorReports { get; set; } = NatsProtocol.DefaultReconnectErrorReports; public bool NoHeaderSupport { get; set; } - public int MaxClosedClients { get; set; } = 10_000; + public int MaxClosedClients { get; set; } = NatsProtocol.DefaultMaxClosedClients; public bool NoSystemAccount { get; set; } public string? SystemAccount { get; set; } @@ -98,9 +105,9 @@ public sealed class NatsOptions public string? TlsCaCert { get; set; } public bool TlsVerify { get; set; } public bool TlsMap { get; set; } - public TimeSpan TlsTimeout { get; set; } = TimeSpan.FromSeconds(2); + public TimeSpan TlsTimeout { get; set; } = NatsProtocol.TlsTimeout; public bool TlsHandshakeFirst { get; set; } - public TimeSpan TlsHandshakeFirstFallback { get; set; } = TimeSpan.FromMilliseconds(50); + public TimeSpan TlsHandshakeFirstFallback { get; set; } = NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay; public bool AllowNonTls { get; set; } public long TlsRateLimit { get; set; } public HashSet? TlsPinnedCerts { get; set; } @@ -133,6 +140,141 @@ public sealed class NatsOptions // WebSocket public WebSocketOptions WebSocket { get; set; } = new(); + + public static void NoErrOnUnknownFields(bool noError) + { + _allowUnknownTopLevelFields = noError; + } + + internal static bool AllowUnknownTopLevelFields => _allowUnknownTopLevelFields; + + public static List RoutesFromStr(string routesStr) + { + if (string.IsNullOrWhiteSpace(routesStr)) + return []; + + var routes = new List(); + foreach (var route in routesStr.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (Uri.TryCreate(route, UriKind.Absolute, out var uri)) + routes.Add(uri); + } + + return routes; + } + + public NatsOptions Clone() + { + try + { + var json = JsonSerializer.Serialize(this); + var clone = JsonSerializer.Deserialize(json) ?? new NatsOptions(); + clone.InCmdLine.Clear(); + foreach (var flag in InCmdLine) + clone.InCmdLine.Add(flag); + if (TlsPinnedCerts != null) + clone.TlsPinnedCerts = [.. TlsPinnedCerts]; + return clone; + } + catch + { + var clone = new NatsOptions(); + CopyFrom(clone, this); + clone.InCmdLine.Clear(); + foreach (var flag in InCmdLine) + clone.InCmdLine.Add(flag); + if (Tags != null) + clone.Tags = new Dictionary(Tags); + if (SubjectMappings != null) + clone.SubjectMappings = new Dictionary(SubjectMappings); + if (TlsPinnedCerts != null) + clone.TlsPinnedCerts = [.. TlsPinnedCerts]; + return clone; + } + } + + public void ProcessConfigString(string data) + { + var parsed = ConfigProcessor.ProcessConfig(data); + CopyFrom(this, parsed); + _configDigest = ComputeDigest(data); + } + + public string ConfigDigest() => _configDigest; + + private static void CopyFrom(NatsOptions destination, NatsOptions source) + { + foreach (var prop in typeof(NatsOptions).GetProperties()) + { + if (!prop.CanRead || !prop.CanWrite) + continue; + prop.SetValue(destination, prop.GetValue(source)); + } + } + + private static string ComputeDigest(string text) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} + +public sealed class JSLimitOpts +{ + public int MaxRequestBatch { get; set; } + public int MaxAckPending { get; set; } + public int MaxHAAssets { get; set; } + public TimeSpan Duplicates { get; set; } + public int MaxBatchInflightPerStream { get; set; } + public int MaxBatchInflightTotal { get; set; } + public int MaxBatchSize { get; set; } + public TimeSpan MaxBatchTimeout { get; set; } +} + +public sealed class AuthCallout +{ + public string Issuer { get; set; } = string.Empty; + public string Account { get; set; } = string.Empty; + public List AuthUsers { get; set; } = []; + public string XKey { get; set; } = string.Empty; + public List AllowedAccounts { get; set; } = []; +} + +public sealed class ProxiesConfig +{ + public List Trusted { get; set; } = []; +} + +public sealed class ProxyConfig +{ + public string Key { get; set; } = string.Empty; +} + +public sealed class Ports +{ + public List Nats { get; set; } = []; + public List Monitoring { get; set; } = []; + public List Cluster { get; set; } = []; + public List Profile { get; set; } = []; + public List WebSocket { get; set; } = []; + public List LeafNodes { get; set; } = []; +} + +public static class CompressionModes +{ + public const string Off = "off"; + public const string Accept = "accept"; + public const string S2Fast = "s2_fast"; + public const string S2Better = "s2_better"; + public const string S2Best = "s2_best"; + public const string S2Uncompressed = "s2_uncompressed"; + public const string S2Auto = "s2_auto"; +} + +public sealed class CompressionOpts +{ + public string Mode { get; set; } = CompressionModes.Off; + public List RTTThresholds { get; set; } = [10, 50, 100, 250]; } public sealed class WebSocketOptions @@ -158,4 +300,8 @@ public sealed class WebSocketOptions public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan? PingInterval { get; set; } public Dictionary? Headers { get; set; } + + // Go websocket.go srvWebsocket.authOverride parity bit: + // true when websocket auth options override top-level auth config. + public bool AuthOverride { get; internal set; } } diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index 5ecedfd..0dfe642 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.NetworkInformation; using System.Net.Security; using System.Net.Sockets; using System.Runtime.InteropServices; @@ -9,6 +10,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using NATS.NKeys; using NATS.Server.Auth; +using NATS.Server.Auth.Jwt; using NATS.Server.Configuration; using NATS.Server.Events; using NATS.Server.Gateways; @@ -61,6 +63,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable /// via InternalsVisibleTo. /// internal RouteManager? RouteManager => _routeManager; + internal GatewayManager? GatewayManager => _gatewayManager; private readonly GatewayManager? _gatewayManager; private readonly LeafNodeManager? _leafNodeManager; private readonly InternalClient? _jetStreamInternalClient; @@ -90,9 +93,15 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable private readonly List _signalRegistrations = []; private string? _portsFilePath; + private DateTime _configTime = DateTime.UtcNow; - private static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10); - private static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1); + private static readonly TimeSpan AcceptMinSleep = NatsProtocol.AcceptMinSleep; + private static readonly TimeSpan AcceptMaxSleep = NatsProtocol.AcceptMaxSleep; + private static readonly JsonSerializerOptions s_jetStreamJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; public SubList SubList => _globalAccount.SubList; public byte[] CachedInfoLine => _cachedInfoLine; @@ -117,6 +126,152 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable public int JetStreamConsumers => _jetStreamConsumerManager?.ConsumerCount ?? 0; public Action? ReOpenLogFile { get; set; } public IEnumerable GetClients() => _clients.Values; + public string? ClusterName() => _options.Cluster?.Name; + public IReadOnlyList ActivePeers() + => _routeManager?.BuildTopologySnapshot().ConnectedServerIds ?? []; + + public bool StartProfiler() + { + if (_options.ProfPort <= 0) + return false; + + _logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort); + return true; + } + + public bool DisconnectClientByID(ulong clientId) + => CloseClientById(clientId, minimalFlush: true); + + public bool LDMClientByID(ulong clientId) + => CloseClientById(clientId, minimalFlush: false); + + public Ports PortsInfo() + { + var ports = new Ports(); + + AddEndpoint(ports.Nats, _options.Host, _options.Port); + AddEndpoint(ports.Monitoring, _options.MonitorHost, _options.MonitorPort); + + if (_routeManager != null) + AddEndpoint(ports.Cluster, _routeManager.ListenEndpoint); + else if (_options.Cluster != null) + AddEndpoint(ports.Cluster, _options.Cluster.Host, _options.Cluster.Port); + + AddEndpoint(ports.Profile, _options.Host, _options.ProfPort); + + if (_options.WebSocket.Port >= 0) + AddEndpoint(ports.WebSocket, _options.WebSocket.Host, _options.WebSocket.Port); + + if (_leafNodeManager != null) + AddEndpoint(ports.LeafNodes, _leafNodeManager.ListenEndpoint); + else if (_options.LeafNode != null) + AddEndpoint(ports.LeafNodes, _options.LeafNode.Host, _options.LeafNode.Port); + + return ports; + } + + public IReadOnlyList GetConnectURLs() + { + if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise)) + return [NormalizeAdvertiseUrl(_options.ClientAdvertise!, "nats")]; + + var hosts = GetNonLocalIPsIfHostIsIPAny(_options.Host); + var result = new List(hosts.Count); + foreach (var host in hosts) + result.Add($"nats://{host}:{_options.Port}"); + + return result; + } + + public void UpdateServerINFOAndSendINFOToClients() + { + _serverInfo.ConnectUrls = [.. GetConnectURLs()]; + BuildCachedInfo(); + + foreach (var client in _clients.Values) + { + if (client.ConnectReceived) + client.QueueOutbound(_cachedInfoLine); + } + } + + public string ClientURL() + { + if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise)) + return NormalizeAdvertiseUrl(_options.ClientAdvertise!, "nats"); + + var host = IsWildcardHost(_options.Host) ? "127.0.0.1" : _options.Host; + return $"nats://{host}:{_options.Port}"; + } + + public string? WebsocketURL() + { + if (_options.WebSocket.Port < 0) + return null; + + if (!string.IsNullOrWhiteSpace(_options.WebSocket.Advertise)) + { + var scheme = _options.WebSocket.NoTls ? "ws" : "wss"; + return NormalizeAdvertiseUrl(_options.WebSocket.Advertise!, scheme); + } + + var wsHost = IsWildcardHost(_options.WebSocket.Host) ? "127.0.0.1" : _options.WebSocket.Host; + var wsScheme = _options.WebSocket.NoTls ? "ws" : "wss"; + return $"{wsScheme}://{wsHost}:{_options.WebSocket.Port}"; + } + + public int NumRoutes() => (int)Interlocked.Read(ref _stats.Routes); + + public int NumRemotes() + => (int)(Interlocked.Read(ref _stats.Routes) + Interlocked.Read(ref _stats.Gateways) + Interlocked.Read(ref _stats.Leafs)); + + public int NumLeafNodes() => (int)Interlocked.Read(ref _stats.Leafs); + public int NumOutboundGateways() => _gatewayManager?.NumOutboundGateways() ?? 0; + public int NumInboundGateways() => _gatewayManager?.NumInboundGateways() ?? 0; + + public int NumSubscriptions() => _accounts.Values.Sum(acc => acc.SubscriptionCount); + public bool JetStreamEnabled() => _jetStreamService?.IsRunning ?? false; + + public JetStreamOptions? JetStreamConfig() + { + if (_options.JetStream is null) + return null; + + return new JetStreamOptions + { + StoreDir = _options.JetStream.StoreDir, + MaxMemoryStore = _options.JetStream.MaxMemoryStore, + MaxFileStore = _options.JetStream.MaxFileStore, + MaxStreams = _options.JetStream.MaxStreams, + MaxConsumers = _options.JetStream.MaxConsumers, + Domain = _options.JetStream.Domain, + }; + } + + public string StoreDir() => _options.JetStream?.StoreDir ?? string.Empty; + + public DateTime ConfigTime() => _configTime; + + public string Addr() => $"{_options.Host}:{_options.Port}"; + + public string? MonitorAddr() + => _options.MonitorPort > 0 + ? $"{_options.MonitorHost}:{_options.MonitorPort}" + : null; + + public string? ClusterAddr() => _routeManager?.ListenEndpoint; + public string? GatewayAddr() => _gatewayManager?.ListenEndpoint; + public string? GetGatewayURL() => _gatewayManager?.ListenEndpoint; + public string? GetGatewayName() => _options.Gateway?.Name; + + public string? ProfilerAddr() + => _options.ProfPort > 0 + ? $"{_options.Host}:{_options.ProfPort}" + : null; + + public int NumActiveAccounts() => _accounts.Values.Count(acc => acc.ClientCount > 0); + + public int NumLoadedAccounts() => _accounts.Count; public IReadOnlyList GetClosedClients() => _closedClients.GetAll(); @@ -402,6 +557,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _routeManager = new RouteManager(options.Cluster, _stats, _serverInfo.ServerId, ApplyRemoteSubscription, ProcessRoutedMessage, _loggerFactory.CreateLogger()); + _routeManager.OnRouteRemoved += RemoveRemoteSubscriptionsForRoute; + _routeManager.OnRouteAccountRemoved += RemoveRemoteSubscriptionsForRouteAccount; } if (options.Gateway != null) @@ -485,6 +642,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable { var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile); _configDigest = digest; + _configTime = DateTime.UtcNow; } catch (Exception ex) { @@ -499,6 +657,79 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _cachedInfoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n"); } + private static string NormalizeAdvertiseUrl(string advertise, string defaultScheme) + { + if (advertise.Contains("://", StringComparison.Ordinal)) + return advertise; + + return $"{defaultScheme}://{advertise}"; + } + + private static bool IsWildcardHost(string host) + => host == "0.0.0.0" || host == "::"; + + internal static IReadOnlyList GetNonLocalIPsIfHostIsIPAny(string host) + { + if (!IsWildcardHost(host)) + return [host]; + + var addresses = new HashSet(StringComparer.Ordinal); + foreach (var netIf in NetworkInterface.GetAllNetworkInterfaces()) + { + if (netIf.OperationalStatus != OperationalStatus.Up) + continue; + + IPInterfaceProperties? props; + try + { + props = netIf.GetIPProperties(); + } + catch + { + continue; + } + + foreach (var uni in props.UnicastAddresses) + { + var addr = uni.Address; + if (IPAddress.IsLoopback(addr) || addr.IsIPv6LinkLocal || addr.IsIPv6Multicast) + continue; + if (addr.AddressFamily is not (AddressFamily.InterNetwork or AddressFamily.InterNetworkV6)) + continue; + addresses.Add(addr.ToString()); + } + } + + if (addresses.Count == 0) + addresses.Add("127.0.0.1"); + + return [.. addresses.OrderBy(static a => a, StringComparer.Ordinal)]; + } + + private bool CloseClientById(ulong clientId, bool minimalFlush) + { + if (!_clients.TryGetValue(clientId, out var client)) + return false; + + client.MarkClosed(ClientClosedReason.ServerShutdown); + _ = client.FlushAndCloseAsync(minimalFlush); + return true; + } + + private static void AddEndpoint(List targets, string? host, int port) + { + if (string.IsNullOrWhiteSpace(host) || port <= 0) + return; + + targets.Add($"{host}:{port}"); + } + + private static void AddEndpoint(List targets, string? endpoint) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + targets.Add(endpoint); + } + public async Task StartAsync(CancellationToken ct) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _quitCts.Token); @@ -523,8 +754,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port); // Warn about stub features - if (_options.ProfPort > 0) - _logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort); + StartProfiler(); if (_options.MonitorPort > 0) { @@ -535,6 +765,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable WritePidFile(); WritePortsFile(); + WsAuthConfig.Apply(_options.WebSocket); + var wsValidation = WebSocketOptionsValidator.Validate(_options); + if (!wsValidation.IsValid) + throw new InvalidOperationException($"Invalid websocket options: {string.Join("; ", wsValidation.Errors)}"); + if (_options.WebSocket.Port >= 0) { _wsListener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); @@ -728,6 +963,14 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable } catch (Exception ex) { + if (client is null) + { + var earlyReason = _options.HasTls + ? ClientClosedReason.TlsHandshakeError + : ClientClosedReason.ReadError; + TrackEarlyClosedClient(socket, clientId, earlyReason); + } + _logger.LogDebug(ex, "Failed to accept client {ClientId}", clientId); try { socket.Shutdown(SocketShutdown.Both); } catch { } socket.Dispose(); @@ -887,6 +1130,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable account.SubList.ApplyRemoteSub(sub); } + private void RemoveRemoteSubscriptionsForRoute(string routeId) + { + foreach (var account in _accounts.Values) + account.SubList.RemoveRemoteSubs(routeId); + } + + private void RemoveRemoteSubscriptionsForRouteAccount(string routeId, string accountName) + { + if (_accounts.TryGetValue(accountName, out var account)) + account.SubList.RemoveRemoteSubsForAccount(routeId, accountName); + } + private void ProcessRoutedMessage(RouteMessage message) { DeliverRemoteMessage(message.Account, message.Subject, message.ReplyTo, message.Payload); @@ -942,19 +1197,42 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable && subject.StartsWith("$JS.API", StringComparison.Ordinal) && _jetStreamApiRouter != null) { + // Pull consumer MSG.NEXT requires special handling: deliver individual + // HMSG messages to the client's reply inbox instead of a single JSON blob. + // Go reference: consumer.go:4276 processNextMsgRequest + if (subject.StartsWith(JetStream.Api.JetStreamApiSubjects.ConsumerNext, StringComparison.Ordinal) + && _jetStreamConsumerManager != null + && _jetStreamStreamManager != null) + { + Interlocked.Increment(ref _stats.JetStreamApiTotal); + DeliverPullFetchMessages(subject, replyTo, payload, sender); + return; + } + var response = _jetStreamApiRouter.Route(subject, payload.Span); Interlocked.Increment(ref _stats.JetStreamApiTotal); if (response.Error != null) Interlocked.Increment(ref _stats.JetStreamApiErrors); - var data = JsonSerializer.SerializeToUtf8Bytes(response); + var data = JsonSerializer.SerializeToUtf8Bytes(response.ToWireFormat(), s_jetStreamJsonOptions); ProcessMessage(replyTo, null, default, data, sender); return; } if (TryCaptureJetStreamPublish(subject, payload, out var pubAck)) + { sender.RecordJetStreamPubAck(pubAck); + // Send pub ack response to the reply subject (request-reply pattern). + // Go reference: server/jetstream.go — jsPubAckResponse sent to reply. + if (replyTo != null) + { + var ackData = JsonSerializer.SerializeToUtf8Bytes(pubAck, s_jetStreamJsonOptions); + ProcessMessage(replyTo, null, default, ackData, sender); + return; + } + } + // Apply subject transforms if (_subjectTransforms.Length > 0) { @@ -1049,6 +1327,94 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable } } + /// + /// Handles $JS.API.CONSUMER.MSG.NEXT by delivering individual HMSG messages + /// to the client's reply inbox. Go reference: consumer.go:4276 processNextMsgRequest. + /// + private void DeliverPullFetchMessages(string subject, string replyTo, ReadOnlyMemory payload, NatsClient sender) + { + var prefix = JetStream.Api.JetStreamApiSubjects.ConsumerNext; + var remainder = subject[prefix.Length..]; + var split = remainder.Split('.', 2, StringSplitOptions.RemoveEmptyEntries); + if (split.Length != 2) + { + var notFoundHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n"); + ProcessMessage(replyTo, null, (ReadOnlyMemory)notFoundHeader, default, sender); + return; + } + + var (streamName, consumerName) = (split[0], split[1]); + + // Parse batch request + int batch = 1; + int expiresMs = 0; + bool noWait = false; + if (payload.Length > 0) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("batch", out var batchEl) && batchEl.TryGetInt32(out var b)) + batch = Math.Max(b, 1); + if (doc.RootElement.TryGetProperty("no_wait", out var nwEl) && nwEl.ValueKind == System.Text.Json.JsonValueKind.True) + noWait = true; + if (doc.RootElement.TryGetProperty("expires", out var expEl) && expEl.TryGetInt64(out var expNs)) + expiresMs = (int)(expNs / 1_000_000); + } + catch (System.Text.Json.JsonException ex) + { + _logger.LogDebug(ex, "Malformed JSON in pull request payload, using defaults"); + } + } + + var fetchResult = _jetStreamConsumerManager!.FetchAsync( + streamName, consumerName, new JetStream.Consumers.PullFetchRequest { Batch = batch, NoWait = noWait, ExpiresMs = expiresMs }, + _jetStreamStreamManager!, default).GetAwaiter().GetResult(); + + // Find the sender's inbox subscription so we can deliver directly. + // Go reference: consumer.go deliverMsg — delivers directly to the client, bypassing pub/sub echo checks. + var subList = sender.Account?.SubList ?? _globalAccount.SubList; + var matchResult = subList.Match(replyTo); + Subscription? inboxSub = null; + foreach (var sub in matchResult.PlainSubs) + { + if (sub.Client == sender) + { + inboxSub = sub; + break; + } + } + + if (inboxSub == null) + return; + + ReadOnlyMemory minHeaders = "NATS/1.0\r\n\r\n"u8.ToArray(); + int deliverySeq = 0; + int numPending = fetchResult.Messages.Count; + + foreach (var msg in fetchResult.Messages) + { + deliverySeq++; + numPending--; + + var tsNanos = new DateTimeOffset(msg.TimestampUtc).ToUnixTimeMilliseconds() * 1_000_000L; + var ackReply = $"$JS.ACK.{streamName}.{consumerName}.1.{msg.Sequence}.{deliverySeq}.{tsNanos}.{numPending}"; + + // Send with the ORIGINAL stream subject (not the inbox) so the NATS client + // can distinguish data messages from control/status messages. + // Go reference: consumer.go deliverMsg — uses original subject on wire, inbox SID. + DeliverMessage(inboxSub, msg.Subject, ackReply, minHeaders, msg.Payload); + } + + // Send terminal status to end the fetch + ReadOnlyMemory statusHeader; + if (fetchResult.Messages.Count == 0 || noWait) + statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n"); + else + statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 408 Request Timeout\r\n\r\n"); + DeliverMessage(inboxSub, replyTo, null, statusHeader, default); + } + private void DeliverMessage(Subscription sub, string subject, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { @@ -1510,6 +1876,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _clients.TryRemove(client.Id, out _); _logger.LogDebug("Removed client {ClientId}", client.Id); + var (tlsPeerCertSubject, tlsPeerCertSubjectPkSha256, tlsPeerCertSha256) = + TlsPeerCertMapper.ToClosedFields(client.TlsState?.PeerCert); + var (jwt, issuerKey, tags) = ExtractJwtMetadata(client.ClientOpts?.JWT); + var proxyKey = ExtractProxyKey(client.ClientOpts?.Username); + // Snapshot for closed-connections tracking (ring buffer auto-overwrites oldest when full) _closedClients.Add(new ClosedClient { @@ -1532,11 +1903,16 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable Rtt = client.Rtt, TlsVersion = client.TlsState?.TlsVersion ?? "", TlsCipherSuite = client.TlsState?.CipherSuite ?? "", - TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "", + TlsPeerCertSubject = tlsPeerCertSubject, + TlsPeerCertSubjectPkSha256 = tlsPeerCertSubjectPkSha256, + TlsPeerCertSha256 = tlsPeerCertSha256, MqttClient = "", // populated when MQTT transport is implemented - JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present", - JwtTags = "", - Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "", + Stalls = 0, + Jwt = jwt, + IssuerKey = issuerKey, + NameTag = "", + Tags = tags, + ProxyKey = proxyKey, }); var subList = client.Account?.SubList ?? _globalAccount.SubList; @@ -1544,6 +1920,58 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable client.Account?.RemoveClient(client.Id); } + private void TrackEarlyClosedClient(Socket socket, ulong clientId, ClientClosedReason reason) + { + string ip = ""; + int port = 0; + + if (socket.RemoteEndPoint is IPEndPoint endpoint) + { + ip = endpoint.Address.ToString(); + port = endpoint.Port; + } + + var now = DateTime.UtcNow; + _closedClients.Add(new ClosedClient + { + Cid = clientId, + Ip = ip, + Port = port, + Start = now, + Stop = now, + Reason = reason.ToReasonString(), + }); + } + + private static (string Jwt, string IssuerKey, string[] Tags) ExtractJwtMetadata(string? jwt) + { + if (string.IsNullOrWhiteSpace(jwt)) + return ("", "", []); + + var issuerKey = ""; + var tags = Array.Empty(); + + var claims = NatsJwt.DecodeUserClaims(jwt); + if (claims != null) + { + issuerKey = claims.Issuer ?? ""; + tags = claims.Nats?.Tags ?? Array.Empty(); + } + + return (jwt, issuerKey, tags); + } + + private static string ExtractProxyKey(string? username) + { + if (string.IsNullOrWhiteSpace(username)) + return ""; + + const string prefix = "proxy:"; + return username.StartsWith(prefix, StringComparison.Ordinal) + ? username[prefix.Length..] + : ""; + } + private void WritePidFile() { if (string.IsNullOrEmpty(_options.PidFile)) return; @@ -1670,6 +2098,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable // Apply changes to running options ApplyConfigChanges(changes, newOpts); _configDigest = digest; + _configTime = DateTime.UtcNow; _logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count); } catch (Exception ex) @@ -1859,6 +2288,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _options.SystemAccount = newOpts.SystemAccount; } + public override string ToString() + => $"NatsServer(ServerId={ServerId}, Name={ServerName}, Addr={Addr()}, Clients={ClientCount})"; + public void Dispose() { if (!IsShuttingDown) diff --git a/src/NATS.Server/Protocol/NatsParser.cs b/src/NATS.Server/Protocol/NatsParser.cs index b3f0a11..55fbf40 100644 --- a/src/NATS.Server/Protocol/NatsParser.cs +++ b/src/NATS.Server/Protocol/NatsParser.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Logging; namespace NATS.Server.Protocol; @@ -80,7 +81,14 @@ public sealed class NatsParser // Control line size check if (line.Length > NatsProtocol.MaxControlLineSize) - throw new ProtocolViolationException("Maximum control line exceeded"); + { + var snippetLength = (int)Math.Min(line.Length, NatsProtocol.MaxControlLineSnippetSize); + var snippetBytes = new byte[snippetLength]; + line.Slice(0, snippetLength).CopyTo(snippetBytes); + var snippet = ProtoSnippet(0, NatsProtocol.MaxControlLineSnippetSize, snippetBytes); + throw new ProtocolViolationException( + $"Maximum control line exceeded (max={NatsProtocol.MaxControlLineSize}, len={line.Length}, snip={snippet}...)"); + } // Get line as contiguous span Span lineSpan = stackalloc byte[(int)line.Length]; @@ -95,7 +103,7 @@ public sealed class NatsParser return false; } - throw new ProtocolViolationException("Unknown protocol operation"); + throw new ProtocolViolationException($"Unknown protocol operation: {ProtoSnippet(lineSpan)}"); } byte b0 = (byte)(lineSpan[0] | 0x20); // lowercase @@ -192,9 +200,29 @@ public sealed class NatsParser return true; } - throw new ProtocolViolationException("Unknown protocol operation"); + throw new ProtocolViolationException($"Unknown protocol operation: {ProtoSnippet(lineSpan)}"); } + // Go reference: parser.go protoSnippet(start, max, buf). + internal static string ProtoSnippet(int start, int max, ReadOnlySpan buffer) + { + if (start >= buffer.Length) + return "\"\""; + + var stop = start + max; + if (stop > buffer.Length) + stop = buffer.Length - 1; + + if (stop <= start) + return "\"\""; + + var slice = buffer[start..stop]; + return JsonSerializer.Serialize(Encoding.ASCII.GetString(slice)); + } + + internal static string ProtoSnippet(ReadOnlySpan buffer) => + ProtoSnippet(0, NatsProtocol.ProtoSnippetSize, buffer); + private bool ParsePub( Span line, ref ReadOnlySequence buffer, diff --git a/src/NATS.Server/Protocol/NatsProtocol.cs b/src/NATS.Server/Protocol/NatsProtocol.cs index ca952da..c92f3db 100644 --- a/src/NATS.Server/Protocol/NatsProtocol.cs +++ b/src/NATS.Server/Protocol/NatsProtocol.cs @@ -5,9 +5,46 @@ namespace NATS.Server.Protocol; public static class NatsProtocol { public const int MaxControlLineSize = 4096; + public const int MaxControlLineSnippetSize = 128; + public const int ProtoSnippetSize = 32; public const int MaxPayloadSize = 1024 * 1024; // 1MB + public const int MaxPayloadMaxSize = 8 * 1024 * 1024; // 8MB public const long MaxPendingSize = 64 * 1024 * 1024; // 64MB default max pending + public const string DefaultHost = "0.0.0.0"; public const int DefaultPort = 4222; + public const int DefaultHttpPort = 8222; + public const string DefaultHttpBasePath = "/"; + public const int DefaultRoutePoolSize = 3; + public const int DefaultLeafNodePort = 7422; + public const int DefaultMaxConnections = 64 * 1024; + public const int DefaultPingMaxOut = 2; + public const int DefaultMaxClosedClients = 10_000; + public const int DefaultConnectErrorReports = 3600; + public const int DefaultReconnectErrorReports = 1; + public const int DefaultAllowResponseMaxMsgs = 1; + public const int DefaultServiceLatencySampling = 100; + public const string DefaultSystemAccount = "$SYS"; + public const string DefaultGlobalAccount = "$G"; + public static readonly TimeSpan TlsTimeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan DefaultTlsHandshakeFirstFallbackDelay = TimeSpan.FromMilliseconds(50); + public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan DefaultRouteConnect = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultRouteConnectMax = TimeSpan.FromSeconds(30); + public static readonly TimeSpan DefaultRouteReconnect = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultRouteDial = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultLeafNodeReconnect = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultLeafTlsTimeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan DefaultLeafNodeInfoWait = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultRttMeasurementInterval = TimeSpan.FromHours(1); + public static readonly TimeSpan DefaultAllowResponseExpiration = TimeSpan.FromMinutes(2); + public static readonly TimeSpan DefaultServiceExportResponseThreshold = TimeSpan.FromMinutes(2); + public static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromMilliseconds(1900); + public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2); + public static readonly TimeSpan DefaultFlushDeadline = TimeSpan.FromSeconds(10); + public static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10); + public static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultLameDuckDuration = TimeSpan.FromMinutes(2); + public static readonly TimeSpan DefaultLameDuckGracePeriod = TimeSpan.FromSeconds(10); public const string Version = "0.1.0"; public const int ProtoVersion = 1; diff --git a/src/NATS.Server/Protocol/ProtoWire.cs b/src/NATS.Server/Protocol/ProtoWire.cs new file mode 100644 index 0000000..284d681 --- /dev/null +++ b/src/NATS.Server/Protocol/ProtoWire.cs @@ -0,0 +1,89 @@ +namespace NATS.Server.Protocol; + +public static class ProtoWire +{ + public const string ErrProtoInsufficient = "insufficient data to read a value"; + public const string ErrProtoOverflow = "too much data for a value"; + public const string ErrProtoInvalidFieldNumber = "invalid field number"; + + public static (int Number, int WireType, int Size) ScanField(ReadOnlySpan buffer) + { + var (number, wireType, tagSize) = ScanTag(buffer); + var valueSize = ScanFieldValue(wireType, buffer[tagSize..]); + return (number, wireType, tagSize + valueSize); + } + + public static (int Number, int WireType, int Size) ScanTag(ReadOnlySpan buffer) + { + var (tag, size) = ScanVarint(buffer); + var fieldNumber = tag >> 3; + if (fieldNumber > int.MaxValue || fieldNumber < 1) + throw new ProtoWireException(ErrProtoInvalidFieldNumber); + + return ((int)fieldNumber, (int)(tag & 0x7), size); + } + + public static int ScanFieldValue(int wireType, ReadOnlySpan buffer) + { + return wireType switch + { + 0 => ScanVarint(buffer).Size, + 5 => 4, + 1 => 8, + 2 => ScanBytes(buffer), + _ => throw new ProtoWireException($"unsupported type: {wireType}"), + }; + } + + public static (ulong Value, int Size) ScanVarint(ReadOnlySpan buffer) + { + ulong value = 0; + + for (var i = 0; i < 10; i++) + { + if (i >= buffer.Length) + throw new ProtoWireException(ErrProtoInsufficient); + + var b = buffer[i]; + if (i == 9) + { + if (b > 1) + throw new ProtoWireException(ErrProtoOverflow); + + value |= (ulong)b << 63; + return (value, 10); + } + + value |= (ulong)(b & 0x7F) << (i * 7); + if ((b & 0x80) == 0) + return (value, i + 1); + } + + throw new ProtoWireException(ErrProtoOverflow); + } + + public static int ScanBytes(ReadOnlySpan buffer) + { + var (length, lenSize) = ScanVarint(buffer); + if (length > (ulong)buffer[lenSize..].Length) + throw new ProtoWireException(ErrProtoInsufficient); + + return lenSize + (int)length; + } + + public static byte[] EncodeVarint(ulong value) + { + Span scratch = stackalloc byte[10]; + var i = 0; + while (value >= 0x80) + { + scratch[i++] = (byte)((value & 0x7F) | 0x80); + value >>= 7; + } + + scratch[i++] = (byte)value; + return scratch[..i].ToArray(); + } +} + +public sealed class ProtoWireException(string message) : Exception(message); diff --git a/src/NATS.Server/Raft/CommitQueue.cs b/src/NATS.Server/Raft/CommitQueue.cs index 5b5f440..1a17c76 100644 --- a/src/NATS.Server/Raft/CommitQueue.cs +++ b/src/NATS.Server/Raft/CommitQueue.cs @@ -41,3 +41,9 @@ public sealed class CommitQueue public void Complete() => _channel.Writer.Complete(); } + +/// +/// Committed raft entries at a specific commit index. +/// Go reference: raft.go CommittedEntry. +/// +public sealed record CommittedEntry(long Index, IReadOnlyList Entries); diff --git a/src/NATS.Server/Raft/RaftConfig.cs b/src/NATS.Server/Raft/RaftConfig.cs new file mode 100644 index 0000000..193c1ff --- /dev/null +++ b/src/NATS.Server/Raft/RaftConfig.cs @@ -0,0 +1,20 @@ +namespace NATS.Server.Raft; + +/// +/// RAFT runtime configuration model aligned with Go's raftConfig shape. +/// Go reference: server/raft.go raftConfig (Name, Store, Log, Track, Observer, +/// Recovering, ScaleUp). +/// +public sealed class RaftConfig +{ + public string Name { get; set; } = string.Empty; + + // Store/log abstractions are intentionally loose until full WAL/store parity is wired. + public object? Store { get; set; } + public object? Log { get; set; } + + public bool Track { get; set; } + public bool Observer { get; set; } + public bool Recovering { get; set; } + public bool ScaleUp { get; set; } +} diff --git a/src/NATS.Server/Raft/RaftEntry.cs b/src/NATS.Server/Raft/RaftEntry.cs new file mode 100644 index 0000000..e8ac92d --- /dev/null +++ b/src/NATS.Server/Raft/RaftEntry.cs @@ -0,0 +1,12 @@ +namespace NATS.Server.Raft; + +/// +/// General raft entry shape (type + payload) used by proposal/state paths. +/// Go reference: raft.go Entry. +/// +public readonly record struct RaftEntry(RaftEntryType Type, byte[] Data) +{ + public RaftEntryWire ToWire() => new(Type, Data); + + public static RaftEntry FromWire(RaftEntryWire wire) => new(wire.Type, wire.Data); +} diff --git a/src/NATS.Server/Raft/RaftNode.cs b/src/NATS.Server/Raft/RaftNode.cs index 3476c19..ba4d3f4 100644 --- a/src/NATS.Server/Raft/RaftNode.cs +++ b/src/NATS.Server/Raft/RaftNode.cs @@ -2,13 +2,31 @@ namespace NATS.Server.Raft; public sealed class RaftNode : IDisposable { + public const string NoLeader = ""; + public const string NoVote = ""; + + public static readonly TimeSpan MinCampaignTimeoutDefault = TimeSpan.FromMilliseconds(100); + public static readonly TimeSpan MaxCampaignTimeoutDefault = TimeSpan.FromMilliseconds(800); + public static readonly TimeSpan HbIntervalDefault = TimeSpan.FromSeconds(1); + public static readonly TimeSpan LostQuorumIntervalDefault = TimeSpan.FromSeconds(10); + public static readonly TimeSpan ObserverModeIntervalDefault = TimeSpan.FromHours(48); + public static readonly TimeSpan PeerRemoveTimeoutDefault = TimeSpan.FromMinutes(5); + private int _votesReceived; private readonly List _cluster = []; private readonly RaftReplicator _replicator = new(); private readonly RaftSnapshotStore _snapshotStore = new(); private readonly IRaftTransport? _transport; private readonly string? _persistDirectory; + private readonly DateTime _createdUtc; private readonly HashSet _members = new(StringComparer.Ordinal); + private int _clusterSize; + private bool _observerMode; + private string _groupLeader = NoLeader; + private DateTime? _leaderSinceUtc; + private bool _hadPreviousLeader; + private bool _isDeleted; + private readonly TaskCompletionSource _stopSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); // B2: Election timer fields // Go reference: raft.go:1400-1450 (resetElectionTimeout), raft.go:1500-1550 (campaign logic) @@ -38,9 +56,17 @@ public sealed class RaftNode : IDisposable // Pre-vote: Go NATS server does not implement pre-vote (RFC 5849 §9.6). Skipped for parity. public string Id { get; } + public string GroupName { get; } + public DateTime CreatedUtc => _createdUtc; public int Term => TermState.CurrentTerm; public bool IsLeader => Role == RaftRole.Leader; + public DateTime? LeaderSince => _leaderSinceUtc; + public string GroupLeader => _groupLeader; + public bool Leaderless => string.IsNullOrEmpty(_groupLeader); + public bool HadPreviousLeader => _hadPreviousLeader; public RaftRole Role { get; private set; } = RaftRole.Follower; + public bool IsObserver => _observerMode; + public bool IsDeleted => _isDeleted; public IReadOnlyCollection Members => _members; public RaftTermState TermState { get; } = new(); public long AppliedIndex { get; set; } @@ -99,31 +125,42 @@ public sealed class RaftNode : IDisposable private Random _random; public RaftNode(string id, IRaftTransport? transport = null, string? persistDirectory = null, - CompactionOptions? compactionOptions = null, Random? random = null) + CompactionOptions? compactionOptions = null, Random? random = null, string? group = null) { Id = id; + GroupName = string.IsNullOrWhiteSpace(group) ? id : group; + _createdUtc = DateTime.UtcNow; _transport = transport; _persistDirectory = persistDirectory; _members.Add(id); + _clusterSize = 1; CompactionOptions = compactionOptions; _random = random ?? Random.Shared; } public void ConfigureCluster(IEnumerable peers) { + var configuredPeers = peers as ICollection ?? peers.ToList(); _cluster.Clear(); - _cluster.AddRange(peers); + _cluster.AddRange(configuredPeers); _members.Clear(); _peerStates.Clear(); - foreach (var peer in peers) + foreach (var peer in configuredPeers) { _members.Add(peer.Id); // B3: Initialize peer state for all peers except self if (!string.Equals(peer.Id, Id, StringComparison.Ordinal)) { - _peerStates[peer.Id] = new RaftPeerState { PeerId = peer.Id }; + _peerStates[peer.Id] = new RaftPeerState + { + PeerId = peer.Id, + Current = true, + }; + _peerStates[peer.Id].RecalculateLag(); } } + + _clusterSize = Math.Max(configuredPeers.Count, 1); } public void AddMember(string memberId) => _members.Add(memberId); @@ -132,6 +169,8 @@ public sealed class RaftNode : IDisposable public void StartElection(int clusterSize) { + _groupLeader = NoLeader; + _leaderSinceUtc = null; Role = RaftRole.Candidate; TermState.CurrentTerm++; TermState.VotedFor = Id; @@ -167,6 +206,12 @@ public sealed class RaftNode : IDisposable TermState.CurrentTerm = term; Role = RaftRole.Follower; + if (!string.IsNullOrEmpty(fromPeerId)) + { + _groupLeader = fromPeerId; + _hadPreviousLeader = true; + _leaderSinceUtc = null; + } // B2: Reset election timer on valid heartbeat ResetElectionTimeout(); @@ -175,6 +220,7 @@ public sealed class RaftNode : IDisposable if (fromPeerId != null && _peerStates.TryGetValue(fromPeerId, out var peerState)) { peerState.LastContact = DateTime.UtcNow; + peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs)); } } @@ -275,7 +321,10 @@ public sealed class RaftNode : IDisposable ackPeerId => { if (_peerStates.TryGetValue(ackPeerId, out var state)) + { state.LastContact = DateTime.UtcNow; + state.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs)); + } }, ct); } @@ -347,6 +396,8 @@ public sealed class RaftNode : IDisposable peerState.MatchIndex = Math.Max(peerState.MatchIndex, entry.Index); peerState.NextIndex = entry.Index + 1; peerState.LastContact = DateTime.UtcNow; + peerState.RecalculateLag(); + peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs)); } } @@ -360,6 +411,34 @@ public sealed class RaftNode : IDisposable return entry.Index; } + /// + /// Proposes a batch of commands in order and returns their resulting indexes. + /// Go reference: raft.go ProposeMulti. + /// + public async ValueTask> ProposeMultiAsync(IEnumerable commands, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(commands); + + var indexes = new List(); + foreach (var command in commands) + { + ct.ThrowIfCancellationRequested(); + indexes.Add(await ProposeAsync(command, ct)); + } + + return indexes; + } + + public (long Entries, long Bytes) Applied(long index) + { + MarkProcessed(index); + var entries = Math.Max(0, index - Log.BaseIndex); + var bytes = Log.Entries + .Where(e => e.Index <= index) + .Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty)); + return (entries, bytes); + } + // B4: Membership change proposals // Go reference: raft.go:961-1019 (proposeAddPeer, proposeRemovePeer) @@ -397,7 +476,12 @@ public sealed class RaftNode : IDisposable if (!string.Equals(peerId, Id, StringComparison.Ordinal) && !_peerStates.ContainsKey(peerId)) { - _peerStates[peerId] = new RaftPeerState { PeerId = peerId }; + _peerStates[peerId] = new RaftPeerState + { + PeerId = peerId, + Current = true, + }; + _peerStates[peerId].RecalculateLag(); } } @@ -708,8 +792,47 @@ public sealed class RaftNode : IDisposable Role = RaftRole.Follower; _votesReceived = 0; TermState.VotedFor = null; + _groupLeader = NoLeader; + _leaderSinceUtc = null; } + public (long Index, long Commit, long Applied) Progress() + { + var index = Log.Entries.Count > 0 ? Log.Entries[^1].Index : Log.BaseIndex; + return (index, CommitIndex, AppliedIndex); + } + + public (long Entries, long Bytes) Size() + { + var entries = (long)Log.Entries.Count; + var bytes = Log.Entries.Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty)); + return (entries, bytes); + } + + public int ClusterSize() + => _clusterSize > 0 ? _clusterSize : Math.Max(_members.Count, 1); + + public bool AdjustBootClusterSize(int clusterSize) + { + if (!Leaderless || HadPreviousLeader) + return false; + + _clusterSize = Math.Max(2, clusterSize); + return true; + } + + public bool AdjustClusterSize(int clusterSize) + { + if (!IsLeader) + return false; + + _clusterSize = Math.Max(2, clusterSize); + return true; + } + + public void SetObserver(bool enabled) + => _observerMode = enabled; + // B2: Election timer management // Go reference: raft.go:1400-1450 (resetElectionTimeout) @@ -730,6 +853,14 @@ public sealed class RaftNode : IDisposable return TimeSpan.FromMilliseconds(ms); } + public TimeSpan RandomizedCampaignTimeout() + { + var min = (int)MinCampaignTimeoutDefault.TotalMilliseconds; + var max = (int)MaxCampaignTimeoutDefault.TotalMilliseconds; + var ms = min + _random.Next(0, Math.Max(1, max - min)); + return TimeSpan.FromMilliseconds(ms); + } + /// /// Resets the election timeout timer with a new randomized interval. /// Called on heartbeat receipt and append entries from leader. @@ -987,7 +1118,38 @@ public sealed class RaftNode : IDisposable { var quorum = (clusterSize / 2) + 1; if (_votesReceived >= quorum) + { Role = RaftRole.Leader; + _groupLeader = Id; + _leaderSinceUtc = DateTime.UtcNow; + _hadPreviousLeader = true; + } + } + + public void Stop() + { + Role = RaftRole.Follower; + _groupLeader = NoLeader; + _leaderSinceUtc = null; + StopElectionTimer(); + _stopSignal.TrySetResult(); + } + + public void WaitForStop() + { + _stopSignal.Task.GetAwaiter().GetResult(); + } + + public void Delete() + { + Stop(); + _isDeleted = true; + + if (string.IsNullOrWhiteSpace(_persistDirectory)) + return; + + if (Directory.Exists(_persistDirectory)) + Directory.Delete(_persistDirectory, recursive: true); } public async Task PersistAsync(CancellationToken ct) @@ -1051,6 +1213,6 @@ public sealed class RaftNode : IDisposable public void Dispose() { - StopElectionTimer(); + Stop(); } } diff --git a/src/NATS.Server/Raft/RaftPeerState.cs b/src/NATS.Server/Raft/RaftPeerState.cs index a0a32b8..c93894c 100644 --- a/src/NATS.Server/Raft/RaftPeerState.cs +++ b/src/NATS.Server/Raft/RaftPeerState.cs @@ -31,16 +31,46 @@ public sealed class RaftPeerState /// public bool Active { get; set; } = true; + /// + /// Distance between next index and acknowledged match index. + /// Go reference: raft.go Peer.Lag. + /// + public long Lag { get; set; } + + /// + /// Cached "current" flag based on last contact freshness checks. + /// Go reference: raft.go Peer.Current. + /// + public bool Current { get; set; } + + /// + /// Recomputes lag from current next/match indices. + /// + public void RecalculateLag() + => Lag = Math.Max(0, NextIndex - (MatchIndex + 1)); + + /// + /// Refreshes the cached Current flag using the provided freshness window. + /// + public void RefreshCurrent(TimeSpan window) + => Current = DateTime.UtcNow - LastContact < window; + /// /// Returns true if this peer has been contacted within the election timeout window. /// Go reference: raft.go isCurrent check. /// public bool IsCurrent(TimeSpan electionTimeout) - => DateTime.UtcNow - LastContact < electionTimeout; + { + RefreshCurrent(electionTimeout); + return Current; + } /// /// Returns true if this peer is both active and has been contacted within the health threshold. /// public bool IsHealthy(TimeSpan healthThreshold) - => Active && DateTime.UtcNow - LastContact < healthThreshold; + { + RefreshCurrent(healthThreshold); + return Active && Current; + } } diff --git a/src/NATS.Server/Raft/RaftStateExtensions.cs b/src/NATS.Server/Raft/RaftStateExtensions.cs new file mode 100644 index 0000000..db2d186 --- /dev/null +++ b/src/NATS.Server/Raft/RaftStateExtensions.cs @@ -0,0 +1,18 @@ +namespace NATS.Server.Raft; + +/// +/// Go-style state string rendering for . +/// Go reference: server/raft.go RaftState.String(). +/// +public static class RaftStateExtensions +{ + public static string String(this RaftState state) => + state switch + { + RaftState.Follower => "Follower", + RaftState.Leader => "Leader", + RaftState.Candidate => "Candidate", + RaftState.Closed => "Closed", + _ => "Unknown", + }; +} diff --git a/src/NATS.Server/Routes/RouteConnection.cs b/src/NATS.Server/Routes/RouteConnection.cs index d97478b..1be280b 100644 --- a/src/NATS.Server/Routes/RouteConnection.cs +++ b/src/NATS.Server/Routes/RouteConnection.cs @@ -7,6 +7,10 @@ namespace NATS.Server.Routes; public sealed class RouteConnection(Socket socket) : IAsyncDisposable { + // Go route protocol control lines. + public const string ConnectProto = "CONNECT {0}"; + public const string InfoProto = "INFO {0}"; + private readonly Socket _socket = socket; private readonly NetworkStream _stream = new(socket, ownsSocket: true); private readonly SemaphoreSlim _writeGate = new(1, 1); @@ -23,6 +27,13 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable /// public int PoolIndex { get; set; } + /// + /// True when this route was solicited by this server (outbound dial). + /// False for inbound/accepted routes. + /// Go reference: server/route.go isSolicitedRoute. + /// + public bool IsSolicited { get; internal set; } + /// /// The pool size agreed upon during handshake negotiation with the remote peer. /// Defaults to 0 (no pooling / pre-negotiation state). Set after handshake completes. @@ -93,10 +104,18 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable } public async Task SendRsPlusAsync(string account, string subject, string? queue, CancellationToken ct) + => await SendRsPlusAsync(account, subject, queue, queueWeight: 0, ct); + + public async Task SendRsPlusAsync(string account, string subject, string? queue, int queueWeight, CancellationToken ct) { - var frame = queue is { Length: > 0 } - ? $"RS+ {account} {subject} {queue}" - : $"RS+ {account} {subject}"; + string frame; + if (queue is { Length: > 0 } && queueWeight > 0) + frame = $"RS+ {account} {subject} {queue} {queueWeight}"; + else if (queue is { Length: > 0 }) + frame = $"RS+ {account} {subject} {queue}"; + else + frame = $"RS+ {account} {subject}"; + await WriteLineAsync(frame, ct); } @@ -108,6 +127,81 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable await WriteLineAsync(frame, ct); } + public async Task SendLsPlusAsync(string account, string subject, string? queue, CancellationToken ct) + { + var frame = queue is { Length: > 0 } + ? $"LS+ {account} {subject} {queue}" + : $"LS+ {account} {subject}"; + await WriteLineAsync(frame, ct); + } + + public async Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct) + { + var frame = queue is { Length: > 0 } + ? $"LS- {account} {subject} {queue}" + : $"LS- {account} {subject}"; + await WriteLineAsync(frame, ct); + } + + public async Task SendRouteSubProtosAsync(IEnumerable subscriptions, CancellationToken ct) + { + var protos = new List(); + foreach (var sub in subscriptions) + { + if (sub.IsRemoval) + continue; + + if (sub.Queue is { Length: > 0 } && sub.QueueWeight > 0) + protos.Add($"RS+ {sub.Account} {sub.Subject} {sub.Queue} {sub.QueueWeight}"); + else if (sub.Queue is { Length: > 0 }) + protos.Add($"RS+ {sub.Account} {sub.Subject} {sub.Queue}"); + else + protos.Add($"RS+ {sub.Account} {sub.Subject}"); + } + + await SendRouteSubOrUnSubProtosAsync(protos, ct); + } + + public async Task SendRouteUnSubProtosAsync(IEnumerable subscriptions, CancellationToken ct) + { + var protos = new List(); + foreach (var sub in subscriptions) + { + if (sub.Queue is { Length: > 0 }) + protos.Add($"RS- {sub.Account} {sub.Subject} {sub.Queue}"); + else + protos.Add($"RS- {sub.Account} {sub.Subject}"); + } + + await SendRouteSubOrUnSubProtosAsync(protos, ct); + } + + public async Task SendRouteSubOrUnSubProtosAsync(IEnumerable protocols, CancellationToken ct) + { + var sb = new StringBuilder(); + foreach (var proto in protocols) + { + if (string.IsNullOrWhiteSpace(proto)) + continue; + sb.Append(proto).Append("\r\n"); + } + + if (sb.Length == 0) + return; + + await _writeGate.WaitAsync(ct); + try + { + var bytes = Encoding.ASCII.GetBytes(sb.ToString()); + await _stream.WriteAsync(bytes, ct); + await _stream.FlushAsync(ct); + } + finally + { + _writeGate.Release(); + } + } + public async Task SendRmsgAsync(string account, string subject, string? replyTo, ReadOnlyMemory payload, CancellationToken ct) { var replyToken = string.IsNullOrEmpty(replyTo) ? "-" : replyTo; @@ -164,7 +258,7 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable break; } - if (line.StartsWith("RS+ ", StringComparison.Ordinal)) + if (line.StartsWith("RS+ ", StringComparison.Ordinal) || line.StartsWith("LS+ ", StringComparison.Ordinal)) { var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue)) @@ -174,10 +268,9 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable continue; } - if (line.StartsWith("RS- ", StringComparison.Ordinal)) + if (line.StartsWith("RS- ", StringComparison.Ordinal) || line.StartsWith("LS- ", StringComparison.Ordinal)) { - var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue)) + if (RemoteSubscriptionReceived != null && TryParseRemoteUnsub(line, out var parsedAccount, out var parsedSubject, out var queue)) { await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteServerId ?? string.Empty, parsedAccount)); } @@ -320,6 +413,22 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable return true; } + internal static bool TryParseRemoteUnsub(string line, out string account, out string subject, out string? queue) + { + account = "$G"; + subject = string.Empty; + queue = null; + + if (!line.StartsWith("RS- ", StringComparison.Ordinal) && !line.StartsWith("LS- ", StringComparison.Ordinal)) + return false; + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return TryParseAccountScopedInterest(parts, out account, out subject, out queue); + } + + public bool IsSolicitedRoute() + => IsSolicited; + private static bool LooksLikeSubject(string token) => token.Contains('.', StringComparison.Ordinal) || token.Contains('*', StringComparison.Ordinal) @@ -329,6 +438,16 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable { var payload = new { + verbose = false, + pedantic = false, + echo = false, + tls_required = false, + headers = true, + name = serverId, + cluster = string.Empty, + @dynamic = false, + lnoc = false, + lnocu = false, server_id = serverId, accounts = (accounts ?? []).ToArray(), topology = topologySnapshot ?? string.Empty, diff --git a/src/NATS.Server/Routes/RouteManager.cs b/src/NATS.Server/Routes/RouteManager.cs index a483a5c..938649d 100644 --- a/src/NATS.Server/Routes/RouteManager.cs +++ b/src/NATS.Server/Routes/RouteManager.cs @@ -11,6 +11,14 @@ namespace NATS.Server.Routes; public sealed class RouteManager : IAsyncDisposable { private static readonly ConcurrentDictionary Managers = new(StringComparer.Ordinal); + private static readonly TimeSpan RouteConnectDelay = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan RouteConnectMaxDelay = TimeSpan.FromSeconds(2); + + public const byte GossipDefault = 0; + public const byte GossipDisabled = 1; + public const byte GossipOverride = 2; + public static readonly TimeSpan DefaultRouteMaxPingInterval = TimeSpan.FromMinutes(2); + private readonly ClusterOptions _options; private readonly ServerStats _stats; private readonly string _serverId; @@ -98,6 +106,8 @@ public sealed class RouteManager : IAsyncDisposable /// Go reference: server/route.go forwardNewRouteInfoToKnownServers. /// public event Action>? OnForwardInfo; + public event Action? OnRouteRemoved; + public event Action? OnRouteAccountRemoved; /// /// Processes connect_urls from a peer's INFO message. Any URLs not already @@ -113,10 +123,17 @@ public sealed class RouteManager : IAsyncDisposable { foreach (var url in serverInfo.ConnectUrls) { - if (!_knownRouteUrls.Contains(url)) + if (HasThisRouteConfigured(url)) + continue; + + var normalized = NormalizeRouteUrl(url); + if (_discoveredRoutes.Any(existing => + string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase))) { - _discoveredRoutes.Add(url); + continue; } + + _discoveredRoutes.Add(url); } } } @@ -137,7 +154,41 @@ public sealed class RouteManager : IAsyncDisposable { lock (_discoveredRoutes) { - _knownRouteUrls.Add(url); + _knownRouteUrls.Add(NormalizeRouteUrl(url)); + } + } + + /// + /// Returns true when the URL is already an explicit configured route or already + /// known from startup/config processing. + /// Go reference: server/route.go hasThisRouteConfigured. + /// + internal bool HasThisRouteConfigured(string routeUrl) + { + var normalized = NormalizeRouteUrl(routeUrl); + lock (_discoveredRoutes) + { + if (_knownRouteUrls.Contains(normalized)) + return true; + } + + return _options.Routes.Any(r => string.Equals(NormalizeRouteUrl(r), normalized, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Returns true if the route URL is still valid for reconnect attempts. + /// Go reference: server/route.go routeStillValid. + /// + internal bool RouteStillValid(string routeUrl) + { + var normalized = NormalizeRouteUrl(routeUrl); + if (HasThisRouteConfigured(normalized)) + return true; + + lock (_discoveredRoutes) + { + return _discoveredRoutes.Any(existing => + string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase)); } } @@ -234,7 +285,11 @@ public sealed class RouteManager : IAsyncDisposable /// public void UnregisterAccountRoute(string account) { - _accountRoutes.TryRemove(account, out _); + if (!_accountRoutes.TryRemove(account, out var route)) + return; + + if (route.RemoteServerId is { Length: > 0 } remoteServerId) + OnRouteAccountRemoved?.Invoke(remoteServerId, account); } /// @@ -346,6 +401,7 @@ public sealed class RouteManager : IAsyncDisposable var poolSize = Math.Max(_options.PoolSize, 1); foreach (var route in _options.Routes.Distinct(StringComparer.OrdinalIgnoreCase)) { + AddKnownRoute(route); for (var i = 0; i < poolSize; i++) { var poolIndex = i; @@ -461,14 +517,18 @@ public sealed class RouteManager : IAsyncDisposable private async Task ConnectToRouteWithRetryAsync(string route, int poolIndex, CancellationToken ct) { + var attempt = 0; while (!ct.IsCancellationRequested) { + if (!RouteStillValid(route)) + return; + try { var endPoint = ParseRouteEndpoint(route); - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + var socket = CreateRouteDialSocket(); await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct); - var connection = new RouteConnection(socket) { PoolIndex = poolIndex }; + var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true }; await connection.PerformOutboundHandshakeAsync(_serverId, ct); Register(connection); return; @@ -484,7 +544,8 @@ public sealed class RouteManager : IAsyncDisposable try { - await Task.Delay(250, ct); + attempt++; + await Task.Delay(ComputeRetryDelay(attempt), ct); } catch (OperationCanceledException) { @@ -493,6 +554,14 @@ public sealed class RouteManager : IAsyncDisposable } } + internal static Socket CreateRouteDialSocket() + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + // Go natsDialTimeout sets KeepAlive to -1 (disabled) on outbound route dials. + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false); + return socket; + } + private void Register(RouteConnection route) { var key = $"{route.RemoteServerId}:{route.RemoteEndpoint}:{Guid.NewGuid():N}"; @@ -538,15 +607,27 @@ public sealed class RouteManager : IAsyncDisposable finally { if (_routes.TryRemove(key, out _)) + { Interlocked.Decrement(ref _stats.Routes); + if (route.RemoteServerId is { Length: > 0 } remoteServerId && !HasConnectedRouteForServerId(remoteServerId)) + OnRouteRemoved?.Invoke(remoteServerId); + } await route.DisposeAsync(); } } + private bool HasConnectedRouteForServerId(string remoteServerId) + { + var prefix = remoteServerId + ":"; + return _routes.Any(kvp => + kvp.Key.StartsWith(prefix, StringComparison.Ordinal) + || string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal)); + } + private static IPEndPoint ParseRouteEndpoint(string route) { - var trimmed = route.Trim(); + var trimmed = NormalizeRouteUrl(route); var parts = trimmed.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) throw new FormatException($"Invalid route endpoint: '{route}'"); @@ -578,18 +659,38 @@ public sealed class RouteManager : IAsyncDisposable { var found = false; var prefix = serverId + ":"; + var removedRoutes = new List(); foreach (var key in _routes.Keys.ToArray()) { if (!key.StartsWith(prefix, StringComparison.Ordinal)) continue; - if (_routes.TryRemove(key, out _)) + if (_routes.TryRemove(key, out var removed)) + { found = true; + removedRoutes.Add(removed); + Interlocked.Decrement(ref _stats.Routes); + _ = removed.DisposeAsync(); + } } if (found) + { _connectedServerIds.TryRemove(serverId, out _); + UnregisterRouteByHash(serverId); + + foreach (var kvp in _accountRoutes.ToArray()) + { + if (removedRoutes.Contains(kvp.Value) + || string.Equals(kvp.Value.RemoteServerId, serverId, StringComparison.Ordinal)) + { + _accountRoutes.TryRemove(kvp.Key, out _); + } + } + + OnRouteRemoved?.Invoke(serverId); + } return found; } @@ -644,6 +745,58 @@ public sealed class RouteManager : IAsyncDisposable return new ClusterSplitResult(missing, unexpected, missing.Count > 0); } + + internal bool HasSolicitedRoute(string remoteServerId) + { + var prefix = remoteServerId + ":"; + return _routes.Any(kvp => + kvp.Value.IsSolicited && + (kvp.Key.StartsWith(prefix, StringComparison.Ordinal) + || string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal))); + } + + internal bool UpgradeRouteToSolicited(string remoteServerId) + { + var prefix = remoteServerId + ":"; + foreach (var kvp in _routes) + { + var route = kvp.Value; + var matches = kvp.Key.StartsWith(prefix, StringComparison.Ordinal) + || string.Equals(route.RemoteServerId, remoteServerId, StringComparison.Ordinal); + if (!matches) + continue; + + route.IsSolicited = true; + return true; + } + + return false; + } + + internal bool IsDuplicateServerName(string remoteServerId) + => _connectedServerIds.ContainsKey(remoteServerId); + + private static TimeSpan ComputeRetryDelay(int attempt) + { + var factor = Math.Pow(2, Math.Clamp(attempt, 0, 8)); + var delayMs = RouteConnectDelay.TotalMilliseconds * factor; + var boundedMs = Math.Min(delayMs, RouteConnectMaxDelay.TotalMilliseconds); + return TimeSpan.FromMilliseconds(boundedMs); + } + + private static string NormalizeRouteUrl(string routeUrl) + { + var value = routeUrl.Trim(); + if (value.StartsWith("nats-route://", StringComparison.OrdinalIgnoreCase)) + value = value["nats-route://".Length..]; + else if (value.StartsWith("nats://", StringComparison.OrdinalIgnoreCase)) + value = value["nats://".Length..]; + + if (Uri.TryCreate($"nats://{value}", UriKind.Absolute, out var uri)) + return $"{uri.Host}:{uri.Port}"; + + return value; + } } public sealed record RouteTopologySnapshot( diff --git a/src/NATS.Server/Server/RateCounter.cs b/src/NATS.Server/Server/RateCounter.cs new file mode 100644 index 0000000..b3712b0 --- /dev/null +++ b/src/NATS.Server/Server/RateCounter.cs @@ -0,0 +1,54 @@ +namespace NATS.Server.Server; + +/// +/// Port of Go's rateCounter utility used for non-blocking allow/deny checks +/// over a fixed one-second interval. +/// Go reference: server/rate_counter.go +/// +public sealed class RateCounter +{ + private readonly long _limit; + private long _count; + private ulong _blocked; + private DateTime _end; + private readonly TimeSpan _interval = TimeSpan.FromSeconds(1); + private readonly Lock _mu = new(); + + public RateCounter(long limit) + { + _limit = Math.Max(1, limit); + } + + public bool Allow() + { + var now = DateTime.UtcNow; + lock (_mu) + { + if (now > _end) + { + _count = 0; + _end = now + _interval; + } + else + { + _count++; + } + + var allow = _count < _limit; + if (!allow) + _blocked++; + + return allow; + } + } + + public ulong CountBlocked() + { + lock (_mu) + { + var blocked = _blocked; + _blocked = 0; + return blocked; + } + } +} diff --git a/src/NATS.Server/Server/ServerErrorConstants.cs b/src/NATS.Server/Server/ServerErrorConstants.cs new file mode 100644 index 0000000..72ccb36 --- /dev/null +++ b/src/NATS.Server/Server/ServerErrorConstants.cs @@ -0,0 +1,27 @@ +namespace NATS.Server.Server; + +/// +/// Error string constants mirrored from Go server/errors.go. +/// These are kept as literals for parity and can be used by validation +/// paths that currently surface generic exceptions. +/// +public static class ServerErrorConstants +{ + public const string ErrBadQualifier = "bad qualifier"; + public const string ErrTooManyAccountConnections = "maximum account active connections exceeded"; + public const string ErrTooManySubs = "maximum subscriptions exceeded"; + public const string ErrTooManySubTokens = "subject has exceeded number of tokens limit"; + public const string ErrReservedAccount = "reserved account"; + public const string ErrMissingService = "service missing"; + public const string ErrBadServiceType = "bad service response type"; + public const string ErrBadSampling = "bad sampling percentage, should be 1-100"; + public const string ErrAccountResolverUpdateTooSoon = "account resolver update too soon"; + public const string ErrAccountResolverSameClaims = "account resolver no new claims"; + public const string ErrStreamImportAuthorization = "stream import not authorized"; + public const string ErrStreamImportBadPrefix = "stream import prefix can not contain wildcard tokens"; + public const string ErrStreamImportDuplicate = "stream import already exists"; + public const string ErrServiceImportAuthorization = "service import not authorized"; + public const string ErrImportFormsCycle = "import forms a cycle"; + public const string ErrCycleSearchDepth = "search cycle depth exhausted"; + public const string ErrNoTransforms = "no matching transforms available"; +} diff --git a/src/NATS.Server/Server/ServerUtilities.cs b/src/NATS.Server/Server/ServerUtilities.cs new file mode 100644 index 0000000..315468e --- /dev/null +++ b/src/NATS.Server/Server/ServerUtilities.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace NATS.Server.Server; + +/// +/// Misc utility helpers ported from Go's server/util.go. +/// +public static class ServerUtilities +{ + private static readonly Regex UrlAuthRegex = new( + @"^(?[a-zA-Z][a-zA-Z0-9+\-.]*://)(?[^:@/]+):(?[^@/]+)@(?.+)$", + RegexOptions.Compiled); + + /// + /// Parse a host/port string with a default port fallback. + /// Mirrors util.go parseHostPort behavior where 0/-1 port values fall back. + /// + public static (string Host, int Port) ParseHostPort(string hostPort, int defaultPort) + { + if (string.IsNullOrWhiteSpace(hostPort)) + throw new ArgumentException("no hostport specified", nameof(hostPort)); + + var input = hostPort.Trim(); + + if (input.StartsWith('[')) + { + var endBracket = input.IndexOf(']'); + if (endBracket < 0) + throw new FormatException($"Invalid host:port '{hostPort}'"); + + var host = input[1..endBracket].Trim(); + if (endBracket + 1 >= input.Length || input[endBracket + 1] != ':') + return (host, defaultPort); + + var portText = input[(endBracket + 2)..].Trim(); + if (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ipv6Port)) + throw new FormatException($"Invalid host:port '{hostPort}'"); + if (ipv6Port == 0 || ipv6Port == -1) + ipv6Port = defaultPort; + return (host, ipv6Port); + } + + var colonIdx = input.LastIndexOf(':'); + if (colonIdx < 0) + return (input, defaultPort); + + var parsedHost = input[..colonIdx].Trim(); + var parsedPortText = input[(colonIdx + 1)..].Trim(); + + if (parsedPortText.Length == 0) + return (parsedHost, defaultPort); + + if (!int.TryParse(parsedPortText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedPort)) + throw new FormatException($"Invalid host:port '{hostPort}'"); + + if (parsedPort == 0 || parsedPort == -1) + parsedPort = defaultPort; + + return (parsedHost, parsedPort); + } + + /// + /// Redacts password in a single URL user-info section. + /// + public static string RedactUrlString(string url) + { + var match = UrlAuthRegex.Match(url); + if (!match.Success) + return url; + + return $"{match.Groups["scheme"].Value}{match.Groups["user"].Value}:xxxxx@{match.Groups["rest"].Value}"; + } + + /// + /// Redacts URL credentials for a URL list. + /// + public static IReadOnlyList RedactUrlList(IEnumerable urls) + { + return urls.Select(RedactUrlString).ToArray(); + } +} diff --git a/src/NATS.Server/SlopwatchSuppressAttribute.cs b/src/NATS.Server/SlopwatchSuppressAttribute.cs new file mode 100644 index 0000000..91e70f0 --- /dev/null +++ b/src/NATS.Server/SlopwatchSuppressAttribute.cs @@ -0,0 +1,12 @@ +// Marker attribute recognised by the slopwatch static-analysis tool. +// Apply to a method to suppress a specific slopwatch rule violation. +// The justification must be 20+ characters explaining why the suppression is intentional. + +namespace NATS.Server; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class SlopwatchSuppressAttribute(string ruleId, string justification) : Attribute +{ + public string RuleId { get; } = ruleId; + public string Justification { get; } = justification; +} diff --git a/src/NATS.Server/Subscriptions/SubList.cs b/src/NATS.Server/Subscriptions/SubList.cs index 9a83f0f..ae5af7e 100644 --- a/src/NATS.Server/Subscriptions/SubList.cs +++ b/src/NATS.Server/Subscriptions/SubList.cs @@ -26,11 +26,77 @@ public sealed class SubList : IDisposable private ulong _inserts; private ulong _removes; private int _highFanoutNodes; + private Action? _interestStateNotification; + private readonly Dictionary>> _queueInsertNotifications = new(StringComparer.Ordinal); + private readonly Dictionary>> _queueRemoveNotifications = new(StringComparer.Ordinal); private readonly record struct CachedResult(SubListResult Result, long Generation); + internal readonly record struct RoutedSubKeyInfo(string RouteId, string Account, string Subject, string? Queue); public event Action? InterestChanged; + public SubList() + : this(enableCache: true) + { + } + + public SubList(bool enableCache) + { + if (!enableCache) + _cache = null; + } + + public static SubList NewSublistNoCache() => new(enableCache: false); + + public bool CacheEnabled() => _cache != null; + + public void RegisterNotification(Action callback) => _interestStateNotification = callback; + + public void ClearNotification() => _interestStateNotification = null; + + public bool RegisterQueueNotification(string subject, string queue, Action callback) + { + if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue)) + return false; + if (SubjectMatch.SubjectHasWildcard(subject) || SubjectMatch.SubjectHasWildcard(queue)) + return false; + + bool hasInterest; + var key = QueueNotifyKey(subject, queue); + _lock.EnterWriteLock(); + try + { + hasInterest = HasExactQueueInterestNoLock(subject, queue); + var map = hasInterest ? _queueRemoveNotifications : _queueInsertNotifications; + if (!AddQueueNotifyNoLock(map, key, callback)) + return false; + } + finally + { + _lock.ExitWriteLock(); + } + + callback(hasInterest); + return true; + } + + public bool ClearQueueNotification(string subject, string queue, Action callback) + { + var key = QueueNotifyKey(subject, queue); + + _lock.EnterWriteLock(); + try + { + var removed = RemoveQueueNotifyNoLock(_queueRemoveNotifications, key, callback); + removed |= RemoveQueueNotifyNoLock(_queueInsertNotifications, key, callback); + return removed; + } + finally + { + _lock.ExitWriteLock(); + } + } + public void Dispose() { _disposed = true; @@ -112,7 +178,7 @@ public sealed class SubList : IDisposable _lock.EnterWriteLock(); try { - var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}"; + var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue); var changed = false; if (sub.IsRemoval) { @@ -149,6 +215,127 @@ public sealed class SubList : IDisposable } } + public void UpdateRemoteQSub(RemoteSubscription sub) + { + if (sub.Queue == null) + return; + + _lock.EnterWriteLock(); + try + { + var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue); + if (!_remoteSubs.TryGetValue(key, out var existing)) + return; + + var nextWeight = Math.Max(1, sub.QueueWeight); + if (existing.QueueWeight == nextWeight) + return; + + _remoteSubs[key] = existing with { QueueWeight = nextWeight }; + Interlocked.Increment(ref _generation); + } + finally + { + _lock.ExitWriteLock(); + } + } + + internal static string BuildRoutedSubKey(string routeId, string account, string subject, string? queue) + => $"{routeId}|{account}|{subject}|{queue}"; + + internal static string? GetAccNameFromRoutedSubKey(string routedSubKey) + => GetRoutedSubKeyInfo(routedSubKey)?.Account; + + internal static RoutedSubKeyInfo? GetRoutedSubKeyInfo(string routedSubKey) + { + if (string.IsNullOrWhiteSpace(routedSubKey)) + return null; + + var parts = routedSubKey.Split('|'); + if (parts.Length != 4) + return null; + + if (parts[0].Length == 0 || parts[1].Length == 0 || parts[2].Length == 0) + return null; + + var queue = parts[3].Length == 0 ? null : parts[3]; + return new RoutedSubKeyInfo(parts[0], parts[1], parts[2], queue); + } + + public int RemoveRemoteSubs(string routeId) + { + _lock.EnterWriteLock(); + try + { + var removed = 0; + foreach (var kvp in _remoteSubs.ToArray()) + { + var info = GetRoutedSubKeyInfo(kvp.Key); + if (info == null || !string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal)) + continue; + + if (_remoteSubs.Remove(kvp.Key)) + { + removed++; + InterestChanged?.Invoke(new InterestChange( + InterestChangeKind.RemoteRemoved, + kvp.Value.Subject, + kvp.Value.Queue, + kvp.Value.Account)); + } + } + + if (removed > 0) + Interlocked.Increment(ref _generation); + + return removed; + } + finally + { + _lock.ExitWriteLock(); + } + } + + public int RemoveRemoteSubsForAccount(string routeId, string account) + { + _lock.EnterWriteLock(); + try + { + var removed = 0; + foreach (var kvp in _remoteSubs.ToArray()) + { + var info = GetRoutedSubKeyInfo(kvp.Key); + if (info == null) + continue; + + if (!string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal) + || !string.Equals(info.Value.Account, account, StringComparison.Ordinal)) + { + continue; + } + + if (_remoteSubs.Remove(kvp.Key)) + { + removed++; + InterestChanged?.Invoke(new InterestChange( + InterestChangeKind.RemoteRemoved, + kvp.Value.Subject, + kvp.Value.Queue, + kvp.Value.Account)); + } + } + + if (removed > 0) + Interlocked.Increment(ref _generation); + + return removed; + } + finally + { + _lock.ExitWriteLock(); + } + } + public bool HasRemoteInterest(string subject) => HasRemoteInterest("$G", subject); @@ -183,6 +370,7 @@ public sealed class SubList : IDisposable _lock.EnterWriteLock(); try { + var hadInterest = _count > 0; var level = _root; TrieNode? node = null; bool sawFwc = false; @@ -240,6 +428,10 @@ public sealed class SubList : IDisposable _count++; _inserts++; Interlocked.Increment(ref _generation); + if (sub.Queue != null && _queueInsertNotifications.Count > 0) + CheckForQueueInsertNotificationNoLock(sub.Subject, sub.Queue); + if (!hadInterest && _count > 0) + _interestStateNotification?.Invoke(true); InterestChanged?.Invoke(new InterestChange( InterestChangeKind.LocalAdded, sub.Subject, @@ -258,10 +450,15 @@ public sealed class SubList : IDisposable _lock.EnterWriteLock(); try { + var hadInterest = _count > 0; if (RemoveInternal(sub)) { _removes++; Interlocked.Increment(ref _generation); + if (sub.Queue != null && _queueRemoveNotifications.Count > 0) + CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue); + if (hadInterest && _count == 0) + _interestStateNotification?.Invoke(false); InterestChanged?.Invoke(new InterestChange( InterestChangeKind.LocalRemoved, sub.Subject, @@ -455,6 +652,90 @@ public sealed class SubList : IDisposable } } + private static string QueueNotifyKey(string subject, string queue) => $"{subject} {queue}"; + + private static bool AddQueueNotifyNoLock(Dictionary>> map, string key, Action callback) + { + if (!map.TryGetValue(key, out var callbacks)) + { + callbacks = []; + map[key] = callbacks; + } + else if (callbacks.Contains(callback)) + { + return false; + } + + callbacks.Add(callback); + return true; + } + + private static bool RemoveQueueNotifyNoLock(Dictionary>> map, string key, Action callback) + { + if (!map.TryGetValue(key, out var callbacks)) + return false; + + var removed = callbacks.Remove(callback); + if (callbacks.Count == 0) + map.Remove(key); + return removed; + } + + private bool HasExactQueueInterestNoLock(string subject, string queue) + { + var subs = new List(); + CollectAll(_root, subs); + foreach (var sub in subs) + { + if (sub.Queue != null + && string.Equals(sub.Subject, subject, StringComparison.Ordinal) + && string.Equals(sub.Queue, queue, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private void CheckForQueueInsertNotificationNoLock(string subject, string queue) + { + var key = QueueNotifyKey(subject, queue); + if (!_queueInsertNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0) + return; + + foreach (var callback in callbacks) + callback(true); + + if (!_queueRemoveNotifications.TryGetValue(key, out var removeCallbacks)) + { + removeCallbacks = []; + _queueRemoveNotifications[key] = removeCallbacks; + } + removeCallbacks.AddRange(callbacks); + _queueInsertNotifications.Remove(key); + } + + private void CheckForQueueRemoveNotificationNoLock(string subject, string queue) + { + var key = QueueNotifyKey(subject, queue); + if (!_queueRemoveNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0) + return; + if (HasExactQueueInterestNoLock(subject, queue)) + return; + + foreach (var callback in callbacks) + callback(false); + + if (!_queueInsertNotifications.TryGetValue(key, out var insertCallbacks)) + { + insertCallbacks = []; + _queueInsertNotifications[key] = insertCallbacks; + } + insertCallbacks.AddRange(callbacks); + _queueRemoveNotifications.Remove(key); + } + private void SweepCache() { _lock.EnterWriteLock(); @@ -632,6 +913,9 @@ public sealed class SubList : IDisposable CacheHitRate = hitRate, MaxFanout = maxFanout, AvgFanout = cacheEntries > 0 ? (double)totalFanout / cacheEntries : 0.0, + TotalFanout = (int)totalFanout, + CacheEntries = cacheEntries, + CacheHits = cacheHits, }; } @@ -695,7 +979,11 @@ public sealed class SubList : IDisposable foreach (var sub in subs) { if (RemoveInternal(sub)) + { _removes++; + if (sub.Queue != null && _queueRemoveNotifications.Count > 0) + CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue); + } } Interlocked.Increment(ref _generation); @@ -724,6 +1012,34 @@ public sealed class SubList : IDisposable return subs; } + public IReadOnlyList LocalSubs(bool includeLeafHubs = false) + { + var subs = new List(); + _lock.EnterReadLock(); + try + { + CollectLocalSubs(_root, subs, includeLeafHubs); + } + finally + { + _lock.ExitReadLock(); + } + return subs; + } + + internal int NumLevels() + { + _lock.EnterReadLock(); + try + { + return VisitLevel(_root, 0); + } + finally + { + _lock.ExitReadLock(); + } + } + public SubListResult ReverseMatch(string subject) { var tokens = Tokenize(subject); @@ -857,6 +1173,82 @@ public sealed class SubList : IDisposable } } + private static void CollectLocalSubs(TrieLevel level, List subs, bool includeLeafHubs) + { + foreach (var (_, node) in level.Nodes) + { + AddNodeLocalSubs(node, subs, includeLeafHubs); + if (node.Next != null) + CollectLocalSubs(node.Next, subs, includeLeafHubs); + } + if (level.Pwc != null) + { + AddNodeLocalSubs(level.Pwc, subs, includeLeafHubs); + if (level.Pwc.Next != null) + CollectLocalSubs(level.Pwc.Next, subs, includeLeafHubs); + } + if (level.Fwc != null) + { + AddNodeLocalSubs(level.Fwc, subs, includeLeafHubs); + if (level.Fwc.Next != null) + CollectLocalSubs(level.Fwc.Next, subs, includeLeafHubs); + } + } + + private static void AddNodeLocalSubs(TrieNode node, List subs, bool includeLeafHubs) + { + foreach (var sub in node.PlainSubs) + AddLocalSub(sub, subs, includeLeafHubs); + foreach (var (_, qset) in node.QueueSubs) + foreach (var sub in qset) + AddLocalSub(sub, subs, includeLeafHubs); + } + + private static void AddLocalSub(Subscription sub, List subs, bool includeLeafHubs) + { + if (sub.Client == null) + return; + + var kind = sub.Client.Kind; + if (kind is global::NATS.Server.ClientKind.Client + or global::NATS.Server.ClientKind.System + or global::NATS.Server.ClientKind.JetStream + or global::NATS.Server.ClientKind.Account + || (includeLeafHubs && kind == global::NATS.Server.ClientKind.Leaf)) + { + subs.Add(sub); + } + } + + private static int VisitLevel(TrieLevel? level, int depth) + { + if (level == null || (level.Nodes.Count == 0 && level.Pwc == null && level.Fwc == null)) + return depth; + + depth++; + var maxDepth = depth; + foreach (var (_, node) in level.Nodes) + { + var childDepth = VisitLevel(node.Next, depth); + if (childDepth > maxDepth) + maxDepth = childDepth; + } + if (level.Pwc != null) + { + var pwcDepth = VisitLevel(level.Pwc.Next, depth); + if (pwcDepth > maxDepth) + maxDepth = pwcDepth; + } + if (level.Fwc != null) + { + var fwcDepth = VisitLevel(level.Fwc.Next, depth); + if (fwcDepth > maxDepth) + maxDepth = fwcDepth; + } + + return maxDepth; + } + private static void ReverseMatchLevel(TrieLevel? level, string[] tokens, int tokenIndex, List plainSubs, List> queueSubs) { diff --git a/src/NATS.Server/Subscriptions/SubListStats.cs b/src/NATS.Server/Subscriptions/SubListStats.cs index 76e31eb..35a899a 100644 --- a/src/NATS.Server/Subscriptions/SubListStats.cs +++ b/src/NATS.Server/Subscriptions/SubListStats.cs @@ -2,12 +2,36 @@ namespace NATS.Server.Subscriptions; public sealed class SubListStats { - public uint NumSubs { get; init; } - public uint NumCache { get; init; } - public ulong NumInserts { get; init; } - public ulong NumRemoves { get; init; } - public ulong NumMatches { get; init; } - public double CacheHitRate { get; init; } - public uint MaxFanout { get; init; } - public double AvgFanout { get; init; } + public uint NumSubs { get; set; } + public uint NumCache { get; set; } + public ulong NumInserts { get; set; } + public ulong NumRemoves { get; set; } + public ulong NumMatches { get; set; } + public double CacheHitRate { get; set; } + public uint MaxFanout { get; set; } + public double AvgFanout { get; set; } + + internal int TotalFanout { get; set; } + internal int CacheEntries { get; set; } + internal ulong CacheHits { get; set; } + + public void Add(SubListStats stat) + { + NumSubs += stat.NumSubs; + NumCache += stat.NumCache; + NumInserts += stat.NumInserts; + NumRemoves += stat.NumRemoves; + NumMatches += stat.NumMatches; + CacheHits += stat.CacheHits; + if (MaxFanout < stat.MaxFanout) + MaxFanout = stat.MaxFanout; + + TotalFanout += stat.TotalFanout; + CacheEntries += stat.CacheEntries; + if (TotalFanout > 0 && CacheEntries > 0) + AvgFanout = (double)TotalFanout / CacheEntries; + + if (NumMatches > 0) + CacheHitRate = (double)CacheHits / NumMatches; + } } diff --git a/src/NATS.Server/Subscriptions/SubjectMatch.cs b/src/NATS.Server/Subscriptions/SubjectMatch.cs index 40d8b5c..a3df07f 100644 --- a/src/NATS.Server/Subscriptions/SubjectMatch.cs +++ b/src/NATS.Server/Subscriptions/SubjectMatch.cs @@ -69,6 +69,10 @@ public static class SubjectMatch return IsValidSubject(subject) && IsLiteral(subject); } + public static bool SubjectHasWildcard(string subject) => !IsLiteral(subject); + + public static bool IsValidLiteralSubject(string subject) => IsValidPublishSubject(subject); + /// /// Match a literal subject against a pattern that may contain wildcards. /// @@ -196,6 +200,55 @@ public static class SubjectMatch return true; } + // Go reference: sublist.go SubjectMatchesFilter / subjectIsSubsetMatch / isSubsetMatch / isSubsetMatchTokenized. + // This is used by JetStream stores to evaluate subject filters with wildcard semantics. + public static bool SubjectMatchesFilter(string subject, string filter) => SubjectIsSubsetMatch(subject, filter); + + public static bool SubjectIsSubsetMatch(string subject, string test) + { + var subjectTokens = TokenizeSubject(subject); + return IsSubsetMatch(subjectTokens, test); + } + + public static bool IsSubsetMatch(string[] tokens, string test) + { + var testTokens = TokenizeSubject(test); + return IsSubsetMatchTokenized(tokens, testTokens); + } + + public static bool IsSubsetMatchTokenized(IReadOnlyList tokens, IReadOnlyList test) + { + for (var i = 0; i < test.Count; i++) + { + if (i >= tokens.Count) + return false; + + var t2 = test[i]; + if (t2.Length == 0) + return false; + + if (t2.Length == 1 && t2[0] == Fwc) + return true; + + var t1 = tokens[i]; + if (t1.Length == 0 || (t1.Length == 1 && t1[0] == Fwc)) + return false; + + if (t1.Length == 1 && t1[0] == Pwc) + { + var bothPwc = t2.Length == 1 && t2[0] == Pwc; + if (!bothPwc) + return false; + continue; + } + + if (!(t2.Length == 1 && t2[0] == Pwc) && !string.Equals(t1, t2, StringComparison.Ordinal)) + return false; + } + + return tokens.Count == test.Count; + } + private static bool TokensCanMatch(ReadOnlySpan t1, ReadOnlySpan t2) { if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc)) @@ -205,6 +258,8 @@ public static class SubjectMatch return t1.SequenceEqual(t2); } + private static string[] TokenizeSubject(string subject) => (subject ?? string.Empty).Split(Sep); + /// /// Validates subject. When checkRunes is true, also rejects null bytes. /// diff --git a/src/NATS.Server/Subscriptions/SubjectTransform.cs b/src/NATS.Server/Subscriptions/SubjectTransform.cs index cfee5c8..264ddd5 100644 --- a/src/NATS.Server/Subscriptions/SubjectTransform.cs +++ b/src/NATS.Server/Subscriptions/SubjectTransform.cs @@ -108,6 +108,10 @@ public sealed partial class SubjectTransform { ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg); } + else if (parsed.Type == TransformType.Random) + { + ops[i] = new TransformOp(TransformType.Random, [], parsed.IntArg, parsed.StringArg); + } else { // Other functions not allowed without wildcards in source @@ -119,6 +123,109 @@ public sealed partial class SubjectTransform return new SubjectTransform(source, destination, srcTokens, destTokens, ops); } + public static SubjectTransform? NewSubjectTransformWithStrict(string source, string destination, bool strict) + { + var transform = Create(source, destination); + if (transform == null || !strict) + return transform; + + return UsesAllSourceWildcards(source, destination) ? transform : null; + } + + public static SubjectTransform? NewSubjectTransformStrict(string source, string destination) + => NewSubjectTransformWithStrict(source, destination, strict: true); + + public static bool ValidateMapping(string destination) + { + if (string.IsNullOrWhiteSpace(destination)) + return false; + + var (valid, tokens, pwcCount, _) = SubjectInfo(destination); + if (!valid || pwcCount > 0) + return false; + + foreach (var token in tokens) + { + if (ParseDestToken(token) == null) + return false; + } + + return true; + } + + public static string TransformTokenize(string subject) + { + if (string.IsNullOrEmpty(subject)) + return subject; + + var tokens = subject.Split('.'); + var wildcard = 0; + + for (var i = 0; i < tokens.Length; i++) + { + if (tokens[i] == "*") + tokens[i] = $"${++wildcard}"; + } + + return string.Join('.', tokens); + } + + public static string TransformUntokenize(string subject) + { + if (string.IsNullOrEmpty(subject)) + return subject; + + var tokens = subject.Split('.'); + for (var i = 0; i < tokens.Length; i++) + { + if (TryParseWildcardToken(tokens[i], out _)) + tokens[i] = "*"; + } + + return string.Join('.', tokens); + } + + public SubjectTransform? Reverse() + { + var tokenizedSource = TransformTokenize(_source); + var tokenizedDest = TransformTokenize(_dest); + + var sourceTokens = tokenizedSource.Split('.'); + var destTokens = tokenizedDest.Split('.'); + + var oldToNewWildcard = new Dictionary(); + var reverseWildcard = 0; + foreach (var token in destTokens) + { + if (!TryParseWildcardToken(token, out var sourceWildcard)) + continue; + + reverseWildcard++; + if (!oldToNewWildcard.ContainsKey(sourceWildcard)) + oldToNewWildcard[sourceWildcard] = reverseWildcard; + } + + var reverseDestTokens = new string[sourceTokens.Length]; + for (var i = 0; i < sourceTokens.Length; i++) + { + var token = sourceTokens[i]; + if (!TryParseWildcardToken(token, out var sourceWildcard)) + { + reverseDestTokens[i] = token; + continue; + } + + if (!oldToNewWildcard.TryGetValue(sourceWildcard, out var mappedWildcard)) + return null; + + reverseDestTokens[i] = $"${mappedWildcard}"; + } + + var reverseSource = TransformUntokenize(tokenizedDest); + var reverseDest = string.Join('.', reverseDestTokens); + return Create(reverseSource, reverseDest); + } + /// /// Matches subject against source pattern, captures wildcard values, evaluates destination template. /// Returns null if subject doesn't match source. @@ -141,6 +248,14 @@ public sealed partial class SubjectTransform return TransformTokenized(subjectTokens); } + public string TransformSubject(string subject) + { + if (string.IsNullOrEmpty(subject)) + return string.Empty; + + return TransformTokenized(subject.Split('.')); + } + private string TransformTokenized(string[] tokens) { if (_ops.Length == 0) @@ -174,6 +289,10 @@ public sealed partial class SubjectTransform sb.Append(ComputePartition(tokens, op)); break; + case TransformType.Random: + sb.Append(GetRandomPartition(op.IntArg)); + break; + case TransformType.Split: ApplySplit(sb, tokens, op); break; @@ -252,6 +371,14 @@ public sealed partial class SubjectTransform return (hash % (uint)numBuckets).ToString(); } + private static int GetRandomPartition(int numBuckets) + { + if (numBuckets <= 0) + return 0; + + return Random.Shared.Next(numBuckets); + } + /// /// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619. /// @@ -554,6 +681,19 @@ public sealed partial class SubjectTransform return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty); } + // random(numBuckets) + args = GetFunctionArgs(RandomRegex(), token); + if (args != null) + { + if (args.Length != 1) + return null; + + if (!TryParseInt32(args[0].Trim(), out int numBuckets)) + return null; + + return new ParsedToken(TransformType.Random, [], numBuckets, string.Empty); + } + // splitFromLeft(token, position) args = GetFunctionArgs(SplitFromLeftRegex(), token); if (args != null) @@ -623,6 +763,46 @@ public sealed partial class SubjectTransform return new ParsedToken(type, [idx], intArg, string.Empty); } + private static bool UsesAllSourceWildcards(string source, string destination) + { + var (srcValid, _, srcPwcCount, _) = SubjectInfo(source); + if (!srcValid || srcPwcCount == 0) + return true; + + var (_, destTokens, _, _) = SubjectInfo(destination); + var used = new HashSet(); + + foreach (var token in destTokens) + { + var parsed = ParseDestToken(token); + if (parsed == null) + return false; + + foreach (var wildcardIndex in parsed.WildcardIndexes) + { + if (wildcardIndex >= 1 && wildcardIndex <= srcPwcCount) + used.Add(wildcardIndex); + } + } + + for (var i = 1; i <= srcPwcCount; i++) + { + if (!used.Contains(i)) + return false; + } + + return true; + } + + private static bool TryParseWildcardToken(string token, out int wildcardIndex) + { + wildcardIndex = 0; + if (token.Length < 2 || token[0] != '$') + return false; + + return int.TryParse(token.AsSpan(1), out wildcardIndex) && wildcardIndex > 0; + } + private static bool TryParseInt32(string s, out int result) { // Parse as long first to detect overflow @@ -655,6 +835,9 @@ public sealed partial class SubjectTransform [GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")] private static partial Regex PartitionRegex(); + [GeneratedRegex(@"\{\{\s*[rR]andom\s*\((.*)\)\s*\}\}")] + private static partial Regex RandomRegex(); + [GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")] private static partial Regex SplitFromLeftRegex(); @@ -684,6 +867,7 @@ public sealed partial class SubjectTransform None, Wildcard, Partition, + Random, Split, SplitFromLeft, SplitFromRight, diff --git a/src/NATS.Server/Tls/OcspPeerConfig.cs b/src/NATS.Server/Tls/OcspPeerConfig.cs new file mode 100644 index 0000000..c90e838 --- /dev/null +++ b/src/NATS.Server/Tls/OcspPeerConfig.cs @@ -0,0 +1,192 @@ +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Serialization; +using NATS.Server.Configuration; + +namespace NATS.Server.Tls; + +[JsonConverter(typeof(StatusAssertionJsonConverter))] +public enum StatusAssertion +{ + Good = 0, + Revoked = 1, + Unknown = 2, +} + +public static class StatusAssertionMaps +{ + public static readonly IReadOnlyDictionary StatusAssertionStrToVal = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["good"] = StatusAssertion.Good, + ["revoked"] = StatusAssertion.Revoked, + ["unknown"] = StatusAssertion.Unknown, + }; + + public static readonly IReadOnlyDictionary StatusAssertionValToStr = + new Dictionary + { + [StatusAssertion.Good] = "good", + [StatusAssertion.Revoked] = "revoked", + [StatusAssertion.Unknown] = "unknown", + }; + + public static readonly IReadOnlyDictionary StatusAssertionIntToVal = + new Dictionary + { + [0] = StatusAssertion.Good, + [1] = StatusAssertion.Revoked, + [2] = StatusAssertion.Unknown, + }; + + public static string GetStatusAssertionStr(int sa) + { + var value = StatusAssertionIntToVal.TryGetValue(sa, out var mapped) + ? mapped + : StatusAssertion.Unknown; + return StatusAssertionValToStr[value]; + } +} + +public sealed class StatusAssertionJsonConverter : JsonConverter +{ + public override StatusAssertion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (str is not null && StatusAssertionMaps.StatusAssertionStrToVal.TryGetValue(str, out var mapped)) + return mapped; + return StatusAssertion.Unknown; + } + + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var v)) + { + return StatusAssertionMaps.StatusAssertionIntToVal.TryGetValue(v, out var mapped) + ? mapped + : StatusAssertion.Unknown; + } + + return StatusAssertion.Unknown; + } + + public override void Write(Utf8JsonWriter writer, StatusAssertion value, JsonSerializerOptions options) + { + if (!StatusAssertionMaps.StatusAssertionValToStr.TryGetValue(value, out var str)) + str = StatusAssertionMaps.StatusAssertionValToStr[StatusAssertion.Unknown]; + writer.WriteStringValue(str); + } +} + +public sealed class ChainLink +{ + public X509Certificate2? Leaf { get; set; } + public X509Certificate2? Issuer { get; set; } + public IReadOnlyList? OCSPWebEndpoints { get; set; } +} + +public sealed class OcspResponseInfo +{ + public DateTime ThisUpdate { get; init; } + public DateTime? NextUpdate { get; init; } +} + +public sealed class CertInfo +{ + [JsonPropertyName("subject")] + public string Subject { get; init; } = string.Empty; + + [JsonPropertyName("issuer")] + public string Issuer { get; init; } = string.Empty; + + [JsonPropertyName("fingerprint")] + public string Fingerprint { get; init; } = string.Empty; + + [JsonPropertyName("raw")] + public byte[] Raw { get; init; } = []; +} + +public sealed class OCSPPeerConfig +{ + public static readonly TimeSpan DefaultAllowedClockSkew = TimeSpan.FromSeconds(30); + public static readonly TimeSpan DefaultOCSPResponderTimeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan DefaultTTLUnsetNextUpdate = TimeSpan.FromHours(1); + + public bool Verify { get; set; } + public double Timeout { get; set; } = DefaultOCSPResponderTimeout.TotalSeconds; + public double ClockSkew { get; set; } = DefaultAllowedClockSkew.TotalSeconds; + public bool WarnOnly { get; set; } + public bool UnknownIsGood { get; set; } + public bool AllowWhenCAUnreachable { get; set; } + public double TTLUnsetNextUpdate { get; set; } = DefaultTTLUnsetNextUpdate.TotalSeconds; + + public static OCSPPeerConfig NewOCSPPeerConfig() => new(); + + public static OCSPPeerConfig Parse(IReadOnlyDictionary values) + { + var cfg = NewOCSPPeerConfig(); + foreach (var (key, rawValue) in values) + { + switch (key.ToLowerInvariant()) + { + case "verify": + cfg.Verify = ParseBool(rawValue, key); + break; + case "allowed_clockskew": + ApplyIfNonNegative(rawValue, key, v => cfg.ClockSkew = v); + break; + case "ca_timeout": + ApplyIfNonNegative(rawValue, key, v => cfg.Timeout = v); + break; + case "cache_ttl_when_next_update_unset": + ApplyIfNonNegative(rawValue, key, v => cfg.TTLUnsetNextUpdate = v); + break; + case "warn_only": + cfg.WarnOnly = ParseBool(rawValue, key); + break; + case "unknown_is_good": + cfg.UnknownIsGood = ParseBool(rawValue, key); + break; + case "allow_when_ca_unreachable": + cfg.AllowWhenCAUnreachable = ParseBool(rawValue, key); + break; + default: + throw new FormatException($"error parsing tls peer config, unknown field [{key}]"); + } + } + + return cfg; + } + + private static bool ParseBool(object? rawValue, string key) + { + if (rawValue is bool b) + return b; + throw new FormatException($"error parsing tls peer config, unknown field [{key}]"); + } + + private static void ApplyIfNonNegative(object? rawValue, string key, Action apply) + { + var parsed = ParseSeconds(rawValue, key); + if (parsed >= 0) + apply(parsed); + } + + private static double ParseSeconds(object? rawValue, string key) + { + try + { + return rawValue switch + { + long l => l, + double d => d, + string s => ConfigProcessor.ParseDuration(s).TotalSeconds, + _ => throw new FormatException("unexpected type"), + }; + } + catch (Exception ex) + { + throw new FormatException($"error parsing tls peer config, conversion error: {ex.Message}", ex); + } + } +} diff --git a/src/NATS.Server/Tls/OcspPeerMessages.cs b/src/NATS.Server/Tls/OcspPeerMessages.cs new file mode 100644 index 0000000..75beaa9 --- /dev/null +++ b/src/NATS.Server/Tls/OcspPeerMessages.cs @@ -0,0 +1,85 @@ +namespace NATS.Server.Tls; + +public static class OcspPeerMessages +{ + // Returned errors + public const string ErrIllegalPeerOptsConfig = "expected map to define OCSP peer options, got [%T]"; + public const string ErrIllegalCacheOptsConfig = "expected map to define OCSP peer cache options, got [%T]"; + public const string ErrParsingPeerOptFieldGeneric = "error parsing tls peer config, unknown field [%q]"; + public const string ErrParsingPeerOptFieldTypeConversion = "error parsing tls peer config, conversion error: %s"; + public const string ErrParsingCacheOptFieldTypeConversion = "error parsing OCSP peer cache config, conversion error: %s"; + public const string ErrUnableToPlugTLSEmptyConfig = "unable to plug TLS verify connection, config is nil"; + public const string ErrMTLSRequired = "OCSP peer verification for client connections requires TLS verify (mTLS) to be enabled"; + public const string ErrUnableToPlugTLSClient = "unable to register client OCSP verification"; + public const string ErrUnableToPlugTLSServer = "unable to register server OCSP verification"; + public const string ErrCannotWriteCompressed = "error writing to compression writer: %w"; + public const string ErrCannotReadCompressed = "error reading compression reader: %w"; + public const string ErrTruncatedWrite = "short write on body (%d != %d)"; + public const string ErrCannotCloseWriter = "error closing compression writer: %w"; + public const string ErrParsingCacheOptFieldGeneric = "error parsing OCSP peer cache config, unknown field [%q]"; + public const string ErrUnknownCacheType = "error parsing OCSP peer cache config, unknown type [%s]"; + public const string ErrInvalidChainlink = "invalid chain link"; + public const string ErrBadResponderHTTPStatus = "bad OCSP responder http status: [%d]"; + public const string ErrNoAvailOCSPServers = "no available OCSP servers"; + public const string ErrFailedWithAllRequests = "exhausted OCSP responders: %w"; + + // Direct logged errors + public const string ErrLoadCacheFail = "Unable to load OCSP peer cache: %s"; + public const string ErrSaveCacheFail = "Unable to save OCSP peer cache: %s"; + public const string ErrBadCacheTypeConfig = "Unimplemented OCSP peer cache type [%v]"; + public const string ErrResponseCompressFail = "Unable to compress OCSP response for key [%s]: %s"; + public const string ErrResponseDecompressFail = "Unable to decompress OCSP response for key [%s]: %s"; + public const string ErrPeerEmptyNoEvent = "Peer certificate is nil, cannot send OCSP peer reject event"; + public const string ErrPeerEmptyAutoReject = "Peer certificate is nil, rejecting OCSP peer"; + + // Debug information + public const string DbgPlugTLSForKind = "Plugging TLS OCSP peer for [%s]"; + public const string DbgNumServerChains = "Peer OCSP enabled: %d TLS server chain(s) will be evaluated"; + public const string DbgNumClientChains = "Peer OCSP enabled: %d TLS client chain(s) will be evaluated"; + public const string DbgLinksInChain = "Chain [%d]: %d total link(s)"; + public const string DbgSelfSignedValid = "Chain [%d] is self-signed, thus peer is valid"; + public const string DbgValidNonOCSPChain = "Chain [%d] has no OCSP eligible links, thus peer is valid"; + public const string DbgChainIsOCSPEligible = "Chain [%d] has %d OCSP eligible link(s)"; + public const string DbgChainIsOCSPValid = "Chain [%d] is OCSP valid for all eligible links, thus peer is valid"; + public const string DbgNoOCSPValidChains = "No OCSP valid chains, thus peer is invalid"; + public const string DbgCheckingCacheForCert = "Checking OCSP peer cache for [%s], key [%s]"; + public const string DbgCurrentResponseCached = "Cached OCSP response is current, status [%s]"; + public const string DbgExpiredResponseCached = "Cached OCSP response is expired, status [%s]"; + public const string DbgOCSPValidPeerLink = "OCSP verify pass for [%s]"; + public const string DbgCachingResponse = "Caching OCSP response for [%s], key [%s]"; + public const string DbgAchievedCompression = "OCSP response compression ratio: [%f]"; + public const string DbgCacheHit = "OCSP peer cache hit for key [%s]"; + public const string DbgCacheMiss = "OCSP peer cache miss for key [%s]"; + public const string DbgPreservedRevocation = "Revoked OCSP response for key [%s] preserved by cache policy"; + public const string DbgDeletingCacheResponse = "Deleting OCSP peer cached response for key [%s]"; + public const string DbgStartingCache = "Starting OCSP peer cache"; + public const string DbgStoppingCache = "Stopping OCSP peer cache"; + public const string DbgLoadingCache = "Loading OCSP peer cache [%s]"; + public const string DbgNoCacheFound = "No OCSP peer cache found, starting with empty cache"; + public const string DbgSavingCache = "Saving OCSP peer cache [%s]"; + public const string DbgCacheSaved = "Saved OCSP peer cache successfully (%d bytes)"; + public const string DbgMakingCARequest = "Trying OCSP responder url [%s]"; + public const string DbgResponseExpired = "OCSP response NextUpdate [%s] is before now [%s] with clockskew [%s]"; + public const string DbgResponseTTLExpired = "OCSP response cache expiry [%s] is before now [%s] with clockskew [%s]"; + public const string DbgResponseFutureDated = "OCSP response ThisUpdate [%s] is before now [%s] with clockskew [%s]"; + public const string DbgCacheSaveTimerExpired = "OCSP peer cache save timer expired"; + public const string DbgCacheDirtySave = "OCSP peer cache is dirty, saving"; + + public const string MsgTLSClientRejectConnection = "client not OCSP valid"; + public const string MsgTLSServerRejectConnection = "server not OCSP valid"; + public const string ErrCAResponderCalloutFail = "Attempt to obtain OCSP response from CA responder for [%s] failed: %s"; + public const string ErrNewCAResponseNotCurrent = "New OCSP CA response obtained for [%s] but not current"; + public const string ErrCAResponseParseFailed = "Could not parse OCSP CA response for [%s]: %s"; + public const string ErrOCSPInvalidPeerLink = "OCSP verify fail for [%s] with CA status [%s]"; + public const string MsgAllowWhenCAUnreachableOccurred = "Failed to obtain OCSP CA response for [%s] but AllowWhenCAUnreachable set; no cached revocation so allowing"; + public const string MsgAllowWhenCAUnreachableOccurredCachedRevoke = "Failed to obtain OCSP CA response for [%s] but AllowWhenCAUnreachable set; cached revocation exists so rejecting"; + public const string MsgAllowWarnOnlyOccurred = "OCSP verify fail for [%s] but WarnOnly is true so allowing"; + public const string MsgCacheOnline = "OCSP peer cache online, type [%s]"; + public const string MsgCacheOffline = "OCSP peer cache offline, type [%s]"; + public const string MsgFailedOCSPResponseFetch = "Failed OCSP response fetch"; + public const string MsgOCSPResponseNotEffective = "OCSP response not in effectivity window"; + public const string MsgFailedOCSPResponseParse = "Failed OCSP response parse"; + public const string MsgOCSPResponseInvalidStatus = "Invalid OCSP response status: %s"; + public const string MsgOCSPResponseDelegationInvalid = "Invalid OCSP response delegation: %s"; + public const string MsgCachedOCSPResponseInvalid = "Invalid cached OCSP response for [%s] with fingerprint [%s]"; +} diff --git a/src/NATS.Server/Tls/TlsHelper.cs b/src/NATS.Server/Tls/TlsHelper.cs index efddc6e..c6a2655 100644 --- a/src/NATS.Server/Tls/TlsHelper.cs +++ b/src/NATS.Server/Tls/TlsHelper.cs @@ -1,4 +1,6 @@ using System.Net.Security; +using System.Formats.Asn1; +using System.Text.RegularExpressions; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -7,6 +9,10 @@ namespace NATS.Server.Tls; public static class TlsHelper { + private const string AuthorityInfoAccessOid = "1.3.6.1.5.5.7.1.1"; + private const string OcspAccessMethodOid = "1.3.6.1.5.5.7.48.1"; + private const string OcspSigningEkuOid = "1.3.6.1.5.5.7.3.9"; + public static X509Certificate2 LoadCertificate(string certPath, string? keyPath) { if (keyPath != null) @@ -16,9 +22,48 @@ public static class TlsHelper public static X509Certificate2Collection LoadCaCertificates(string caPath) { - var collection = new X509Certificate2Collection(); - collection.ImportFromPemFile(caPath); - return collection; + var pem = File.ReadAllText(caPath); + return ParseCertPem(pem); + } + + /// + /// Parses one or more PEM blocks and requires all blocks to be CERTIFICATE. + /// Mirrors Go parseCertPEM behavior by rejecting unexpected block types. + /// + public static X509Certificate2Collection ParseCertPem(string pemData) + { + if (string.IsNullOrWhiteSpace(pemData)) + throw new InvalidDataException("PEM data is empty."); + + var beginMatches = Regex.Matches(pemData, "-----BEGIN ([^-]+)-----"); + if (beginMatches.Count == 0) + throw new InvalidDataException("No PEM certificate block found."); + + foreach (Match match in beginMatches) + { + var label = match.Groups[1].Value; + if (!string.Equals(label, "CERTIFICATE", StringComparison.Ordinal)) + throw new InvalidDataException($"unexpected PEM certificate type: {label}"); + } + + var certs = new X509Certificate2Collection(); + var certMatches = Regex.Matches( + pemData, + "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", + RegexOptions.Singleline); + + foreach (Match certMatch in certMatches) + { + var body = certMatch.Groups["body"].Value; + var normalized = Regex.Replace(body, "\\s+", "", RegexOptions.Singleline); + var der = Convert.FromBase64String(normalized); + certs.Add(X509CertificateLoader.LoadCertificate(der)); + } + + if (certs.Count == 0) + throw new InvalidDataException("No PEM certificate block found."); + + return certs; } public static SslServerAuthenticationOptions BuildServerAuthOptions(NatsOptions opts) @@ -92,9 +137,198 @@ public static class TlsHelper return Convert.ToHexStringLower(hash); } + public static string GenerateFingerprint(X509Certificate2 cert) + { + var hash = SHA256.HashData(cert.RawData); + return Convert.ToBase64String(hash); + } + + public static IReadOnlyList GetWebEndpoints(IEnumerable uris) + { + var urls = new List(); + foreach (var uri in uris) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out var endpoint)) + continue; + if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + continue; + urls.Add(endpoint); + } + + return urls; + } + + public static string GetSubjectDNForm(X509Certificate2? cert) + { + return cert?.SubjectName.Name ?? string.Empty; + } + + public static string GetIssuerDNForm(X509Certificate2? cert) + { + return cert?.IssuerName.Name ?? string.Empty; + } + public static bool MatchesPinnedCert(X509Certificate2 cert, HashSet pinned) { var hash = GetCertificateHash(cert); return pinned.Contains(hash); } + + /// + /// Checks if a chain link is eligible for OCSP validation by ensuring the leaf + /// certificate includes at least one valid HTTP(S) OCSP AIA endpoint. + /// + public static bool CertOCSPEligible(ChainLink? link) + { + if (link?.Leaf is null) + return false; + + if (link.Leaf.RawData is null || link.Leaf.RawData.Length == 0) + return false; + + var aiaUris = GetOcspResponderUris(link.Leaf); + if (aiaUris.Count == 0) + return false; + + var urls = GetWebEndpoints(aiaUris); + if (urls.Count == 0) + return false; + + link.OCSPWebEndpoints = urls; + return true; + } + + /// + /// Returns the positional issuer certificate for a leaf in a verified chain. + /// + public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList? chain, int leafPos) + { + if (chain is null || chain.Count == 0 || leafPos < 0 || leafPos >= chain.Count - 1) + return null; + return chain[leafPos + 1]; + } + + /// + /// Equivalent to Go certstore.GetLeafIssuer: verifies the leaf against the + /// supplied trust root and returns the first issuer in the verified chain. + /// + public static X509Certificate2? GetLeafIssuer(X509Certificate2 leaf, X509Certificate2 trustedRoot) + { + using var chain = new X509Chain(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(trustedRoot); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + + if (!chain.Build(leaf) || chain.ChainElements.Count < 2) + return null; + + return X509CertificateLoader.LoadCertificate(chain.ChainElements[1].Certificate.RawData); + } + + /// + /// Checks OCSP response currency semantics with clock skew and fallback TTL. + /// + public static bool OcspResponseCurrent(OcspResponseInfo response, OCSPPeerConfig opts) + { + var skew = TimeSpan.FromSeconds(opts.ClockSkew); + if (skew < TimeSpan.Zero) + skew = OCSPPeerConfig.DefaultAllowedClockSkew; + + var now = DateTime.UtcNow; + + if (response.NextUpdate.HasValue && response.NextUpdate.Value < now - skew) + return false; + + if (!response.NextUpdate.HasValue) + { + var ttl = TimeSpan.FromSeconds(opts.TTLUnsetNextUpdate); + if (ttl < TimeSpan.Zero) + ttl = OCSPPeerConfig.DefaultTTLUnsetNextUpdate; + + if (response.ThisUpdate + ttl < now - skew) + return false; + } + + if (response.ThisUpdate > now + skew) + return false; + + return true; + } + + /// + /// Validates OCSP delegated signer semantics. Direct issuer signatures are valid; + /// delegated certificates must include id-kp-OCSPSigning EKU. + /// + public static bool ValidDelegationCheck(X509Certificate2? issuer, X509Certificate2? responderCertificate) + { + if (issuer is null) + return false; + + if (responderCertificate is null) + return true; + + if (responderCertificate.Thumbprint == issuer.Thumbprint) + return true; + + foreach (var extension in responderCertificate.Extensions) + { + if (extension is not X509EnhancedKeyUsageExtension eku) + continue; + foreach (var oid in eku.EnhancedKeyUsages) + { + if (oid.Value == OcspSigningEkuOid) + return true; + } + } + + return false; + } + + [SlopwatchSuppress("SW003", "AsnContentException on a malformed AIA extension is intentionally swallowed; invalid extension shape means no usable OCSP URI")] + private static IReadOnlyList GetOcspResponderUris(X509Certificate2 cert) + { + var uris = new List(); + + foreach (var extension in cert.Extensions) + { + if (!string.Equals(extension.Oid?.Value, AuthorityInfoAccessOid, StringComparison.Ordinal)) + continue; + + try + { + var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER); + var seq = reader.ReadSequence(); + while (seq.HasData) + { + var accessDescription = seq.ReadSequence(); + var accessMethod = accessDescription.ReadObjectIdentifier(); + if (!string.Equals(accessMethod, OcspAccessMethodOid, StringComparison.Ordinal)) + { + accessDescription.ThrowIfNotEmpty(); + continue; + } + + var uri = accessDescription.ReadCharacterString( + UniversalTagNumber.IA5String, + new Asn1Tag(TagClass.ContextSpecific, 6)); + + accessDescription.ThrowIfNotEmpty(); + if (!string.IsNullOrWhiteSpace(uri)) + uris.Add(uri); + } + + seq.ThrowIfNotEmpty(); + reader.ThrowIfNotEmpty(); + } + catch (AsnContentException ex) + { + // Invalid AIA extension shape should behave as "no usable OCSP URI" — swallow is intentional. + _ = ex.Message; + } + } + + return uris; + } } diff --git a/src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs b/src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs new file mode 100644 index 0000000..1fdab2a --- /dev/null +++ b/src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs @@ -0,0 +1,93 @@ +namespace NATS.Server.WebSocket; + +/// +/// Validates websocket options against server-wide auth/TLS/operator settings. +/// Go reference: websocket.go validateWebsocketOptions. +/// +public static class WebSocketOptionsValidator +{ + private static readonly HashSet ReservedResponseHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Upgrade", + "Connection", + "Sec-WebSocket-Accept", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Protocol", + }; + + public static WebSocketOptionsValidationResult Validate(NatsOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var ws = options.WebSocket; + var errors = new List(); + + if (ws.Port < 0) + return new WebSocketOptionsValidationResult(true, errors); + + if (!ws.NoTls) + { + var hasCert = !string.IsNullOrWhiteSpace(ws.TlsCert); + var hasKey = !string.IsNullOrWhiteSpace(ws.TlsKey); + if (!hasCert || !hasKey) + errors.Add("WebSocket TLS listener requires both TlsCert and TlsKey when NoTls is false."); + } + + if (ws.AllowedOrigins is { Count: > 0 }) + { + foreach (var origin in ws.AllowedOrigins) + { + if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + errors.Add($"Invalid websocket allowed origin: '{origin}'."); + } + } + } + + if (!string.IsNullOrWhiteSpace(ws.NoAuthUser) && options.Users is { Count: > 0 }) + { + var match = options.Users.Any(u => string.Equals(u.Username, ws.NoAuthUser, StringComparison.Ordinal)); + if (!match) + errors.Add("WebSocket NoAuthUser must match one of the configured users."); + } + + if ((!string.IsNullOrWhiteSpace(ws.Username) || !string.IsNullOrWhiteSpace(ws.Token)) + && ((options.Users?.Count ?? 0) > 0 || (options.NKeys?.Count ?? 0) > 0)) + { + errors.Add("WebSocket Username/Token cannot be set when users or nkeys are configured."); + } + + if (!string.IsNullOrWhiteSpace(ws.JwtCookie) && (options.TrustedKeys == null || options.TrustedKeys.Length == 0)) + { + errors.Add("WebSocket JwtCookie requires trusted operators (TrustedKeys)."); + } + + if (options.TlsPinnedCerts is { Count: > 0 }) + { + if (ws.NoTls) + errors.Add("WebSocket TLSPinnedCerts require TLS (NoTls must be false)."); + + foreach (var pin in options.TlsPinnedCerts) + { + if (string.IsNullOrWhiteSpace(pin) || !pin.All(Uri.IsHexDigit)) + errors.Add($"Invalid websocket pinned cert hash: '{pin}'."); + } + } + + if (ws.Headers is { Count: > 0 }) + { + foreach (var headerName in ws.Headers.Keys) + { + if (ReservedResponseHeaders.Contains(headerName)) + errors.Add($"WebSocket header '{headerName}' is reserved and cannot be overridden."); + } + } + + return new WebSocketOptionsValidationResult(errors.Count == 0, errors); + } +} + +public sealed record WebSocketOptionsValidationResult( + bool IsValid, + IReadOnlyList Errors); diff --git a/src/NATS.Server/WebSocket/WsAuthConfig.cs b/src/NATS.Server/WebSocket/WsAuthConfig.cs new file mode 100644 index 0000000..fc692b4 --- /dev/null +++ b/src/NATS.Server/WebSocket/WsAuthConfig.cs @@ -0,0 +1,19 @@ +namespace NATS.Server.WebSocket; + +public static class WsAuthConfig +{ + public static bool ComputeAuthOverride(WebSocketOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return !string.IsNullOrWhiteSpace(options.Username) + || !string.IsNullOrWhiteSpace(options.Token) + || !string.IsNullOrWhiteSpace(options.NoAuthUser); + } + + public static void Apply(WebSocketOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.AuthOverride = ComputeAuthOverride(options); + } +} diff --git a/src/NATS.Server/WebSocket/WsUpgrade.cs b/src/NATS.Server/WebSocket/WsUpgrade.cs index 39fa113..180832c 100644 --- a/src/NATS.Server/WebSocket/WsUpgrade.cs +++ b/src/NATS.Server/WebSocket/WsUpgrade.cs @@ -10,6 +10,9 @@ namespace NATS.Server.WebSocket; /// public static class WsUpgrade { + // Go test hook parity: when true, force rejection of no-masking requests. + public static bool RejectNoMaskingForTest { get; set; } + public static async Task TryUpgradeAsync( Stream inputStream, Stream outputStream, WebSocketOptions options, CancellationToken ct = default) @@ -72,6 +75,9 @@ public static class WsUpgrade headers.TryGetValue(WsConstants.NoMaskingHeader, out var nmVal) && string.Equals(nmVal.Trim(), WsConstants.NoMaskingValue, StringComparison.OrdinalIgnoreCase); + if (noMasking && RejectNoMaskingForTest) + return await FailAsync(outputStream, 400, "invalid value for no-masking"); + // Browser detection bool browser = false; bool noCompFrag = false; @@ -179,6 +185,41 @@ public static class WsUpgrade return Convert.ToBase64String(hash); } + /// + /// Generates a random base64-encoded 16-byte websocket challenge key. + /// Go reference: wsMakeChallengeKey(). + /// + public static string MakeChallengeKey() + { + Span nonce = stackalloc byte[16]; + RandomNumberGenerator.Fill(nonce); + return Convert.ToBase64String(nonce); + } + + /// + /// Returns true when the URL uses the ws:// scheme. + /// Go reference: isWSURL(). + /// + public static bool IsWsUrl(string? url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + return string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns true when the URL uses the wss:// scheme. + /// Go reference: isWSSURL(). + /// + public static bool IsWssUrl(string? url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase); + } + /// /// Extracts a bearer token from an Authorization header value. /// Supports both "Bearer {token}" and bare "{token}" formats. diff --git a/tests/NATS.E2E.Tests/AccountIsolationTests.cs b/tests/NATS.E2E.Tests/AccountIsolationTests.cs new file mode 100644 index 0000000..077168e --- /dev/null +++ b/tests/NATS.E2E.Tests/AccountIsolationTests.cs @@ -0,0 +1,98 @@ +using NATS.Client.Core; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E-Accounts")] +public class AccountIsolationTests(AccountServerFixture fixture) +{ + [Fact] + public async Task Accounts_SameAccount_MessageDelivered() + { + await using var pub = fixture.CreateClientA(); + await using var sub = fixture.CreateClientA(); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("acct.test"); + await sub.PingAsync(); + + await pub.PublishAsync("acct.test", "intra-account"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + + msg.Data.ShouldBe("intra-account"); + } + + [Fact] + public async Task Accounts_CrossAccount_MessageNotDelivered() + { + await using var pub = fixture.CreateClientA(); + await using var sub = fixture.CreateClientB(); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("cross.test"); + await sub.PingAsync(); + + await pub.PublishAsync("cross.test", "cross-account"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask(); + var completed = await Task.WhenAny(readTask, Task.Delay(1000)); + + completed.ShouldNotBe(readTask); + } + + [Fact] + public async Task Accounts_EachAccountHasOwnNamespace() + { + await using var pubA = fixture.CreateClientA(); + await using var subA = fixture.CreateClientA(); + await using var pubB = fixture.CreateClientB(); + await using var subB = fixture.CreateClientB(); + + await pubA.ConnectAsync(); + await subA.ConnectAsync(); + await pubB.ConnectAsync(); + await subB.ConnectAsync(); + + await using var subscriptionA = await subA.SubscribeCoreAsync("shared.topic"); + await using var subscriptionB = await subB.SubscribeCoreAsync("shared.topic"); + + await subA.PingAsync(); + await subB.PingAsync(); + + // Publish from ACCT_A — only ACCT_A subscriber should receive + await pubA.PublishAsync("shared.topic", "from-a"); + + using var ctA = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msgA = await subscriptionA.Msgs.ReadAsync(ctA.Token); + msgA.Data.ShouldBe("from-a"); + + using var ctsBNoMsg = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var readBTask = subscriptionB.Msgs.ReadAsync(ctsBNoMsg.Token).AsTask(); + var completedB = await Task.WhenAny(readBTask, Task.Delay(1000)); + completedB.ShouldNotBe(readBTask); + // Cancel the abandoned read so it doesn't consume the next message + await ctsBNoMsg.CancelAsync(); + try { await readBTask; } catch (OperationCanceledException) { } + + // Publish from ACCT_B — only ACCT_B subscriber should receive + await pubB.PublishAsync("shared.topic", "from-b"); + + using var ctB = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msgB = await subscriptionB.Msgs.ReadAsync(ctB.Token); + msgB.Data.ShouldBe("from-b"); + + using var ctsANoMsg = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var readATask2 = subscriptionA.Msgs.ReadAsync(ctsANoMsg.Token).AsTask(); + var completedA2 = await Task.WhenAny(readATask2, Task.Delay(1000)); + completedA2.ShouldNotBe(readATask2); + await ctsANoMsg.CancelAsync(); + try { await readATask2; } catch (OperationCanceledException) { } + } +} diff --git a/tests/NATS.E2E.Tests/AuthTests.cs b/tests/NATS.E2E.Tests/AuthTests.cs new file mode 100644 index 0000000..2123285 --- /dev/null +++ b/tests/NATS.E2E.Tests/AuthTests.cs @@ -0,0 +1,262 @@ +using NATS.Client.Core; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E-Auth")] +public class AuthTests(AuthServerFixture fixture) +{ + [Fact] + public async Task UsernamePassword_ValidCredentials_Connects() + { + await using var client = fixture.CreateClient("testuser", "testpass"); + await client.ConnectAsync(); + await client.PingAsync(); + client.ConnectionState.ShouldBe(NatsConnectionState.Open); + } + + [Fact] + public async Task UsernamePassword_InvalidPassword_Rejected() + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Port}", + AuthOpts = new NatsAuthOpts { Username = "testuser", Password = "wrongpass" }, + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } + + [Fact] + public async Task UsernamePassword_NoCredentials_Rejected() + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } + + [Fact] + public async Task TokenAuth_ValidToken_Connects() + { + var config = """ + authorization { + token: "s3cret" + } + """; + + await using var server = NatsServerProcess.WithConfig(config); + await server.StartAsync(); + + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{server.Port}", + AuthOpts = new NatsAuthOpts { Token = "s3cret" }, + }); + + await client.ConnectAsync(); + await client.PingAsync(); + client.ConnectionState.ShouldBe(NatsConnectionState.Open); + } + + [Fact] + public async Task TokenAuth_InvalidToken_Rejected() + { + var config = """ + authorization { + token: "s3cret" + } + """; + + await using var server = NatsServerProcess.WithConfig(config); + await server.StartAsync(); + + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{server.Port}", + AuthOpts = new NatsAuthOpts { Token = "wrongtoken" }, + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } + + [Fact] + public async Task NKeyAuth_ValidSignature_Connects() + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Port}", + AuthOpts = new NatsAuthOpts + { + NKey = fixture.NKeyPublicKey, + Seed = fixture.NKeySeed, + }, + }); + + await client.ConnectAsync(); + await client.PingAsync(); + client.ConnectionState.ShouldBe(NatsConnectionState.Open); + } + + [Fact] + public async Task NKeyAuth_InvalidSignature_Rejected() + { + // Generate a fresh key pair that is NOT registered with the server + var otherSeed = NATS.Client.Core.NKeys.CreateUserSeed(); + var otherPublicKey = NATS.Client.Core.NKeys.PublicKeyFromSeed(otherSeed); + + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Port}", + AuthOpts = new NatsAuthOpts + { + NKey = otherPublicKey, + Seed = otherSeed, + }, + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } + + [Fact] + public async Task Permission_PublishAllowed_Succeeds() + { + await using var pub = fixture.CreateClient("pubonly", "pubpass"); + await using var sub = fixture.CreateClient("testuser", "testpass"); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("allowed.foo"); + await sub.PingAsync(); + + await pub.PublishAsync("allowed.foo", "hello"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + msg.Data.ShouldBe("hello"); + } + + [Fact] + public async Task Permission_PublishDenied_NoDelivery() + { + await using var pub = fixture.CreateClient("pubonly", "pubpass"); + await using var sub = fixture.CreateClient("testuser", "testpass"); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("denied.foo"); + await sub.PingAsync(); + + await pub.PublishAsync("denied.foo", "should-not-arrive"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask(); + + await Should.ThrowAsync(async () => await readTask); + } + + [Fact] + public async Task Permission_SubscribeDenied_Rejected() + { + await using var pub = fixture.CreateClient("testuser", "testpass"); + await using var sub = fixture.CreateClient("subonly", "subpass"); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + // subonly may not subscribe to denied.topic — the subscription silently + // fails or is dropped by the server; no message should arrive + await using var subscription = await sub.SubscribeCoreAsync("denied.topic"); + await sub.PingAsync(); + + await pub.PublishAsync("denied.topic", "should-not-arrive"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask(); + + await Should.ThrowAsync(async () => await readTask); + } + + [Fact] + public async Task MaxSubscriptions_ExceedsLimit_Rejected() + { + // The server is configured with max_subs: 5 (server-wide). + // Open a single connection and exhaust the limit. + await using var client = fixture.CreateClient("limited", "limpass"); + await client.ConnectAsync(); + + var subscriptions = new List(); + try + { + // Create subscriptions up to the server max + for (var i = 0; i < 5; i++) + subscriptions.Add(await client.SubscribeCoreAsync($"max.subs.test.{i}")); + + // The 6th subscription should cause the server to close the connection + var ex = await Should.ThrowAsync(async () => + { + subscriptions.Add(await client.SubscribeCoreAsync("max.subs.test.6")); + // Force a round-trip so the server error is observed + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } + finally + { + foreach (var s in subscriptions) + await s.DisposeAsync(); + } + } + + [Fact] + public async Task MaxPayload_ExceedsLimit_Disconnected() + { + // The fixture server has max_payload: 512; send > 512 bytes + await using var client = fixture.CreateClient("testuser", "testpass"); + await client.ConnectAsync(); + + var oversized = new string('x', 600); + + var ex = await Should.ThrowAsync(async () => + { + await client.PublishAsync("payload.test", oversized); + // Force a round-trip to observe the server's error response + await client.PingAsync(); + }); + + ex.ShouldNotBeNull(); + } +} diff --git a/tests/NATS.E2E.Tests/BasicTests.cs b/tests/NATS.E2E.Tests/BasicTests.cs new file mode 100644 index 0000000..1ba536a --- /dev/null +++ b/tests/NATS.E2E.Tests/BasicTests.cs @@ -0,0 +1,67 @@ +using NATS.Client.Core; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E")] +public class BasicTests(NatsServerFixture fixture) +{ + [Fact] + public async Task ConnectAndPing() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + + await client.PingAsync(); + + client.ConnectionState.ShouldBe(NatsConnectionState.Open); + } + + [Fact] + public async Task PubSub() + { + await using var pub = fixture.CreateClient(); + await using var sub = fixture.CreateClient(); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("e2e.test.pubsub"); + await sub.PingAsync(); // Flush to ensure subscription is active + + await pub.PublishAsync("e2e.test.pubsub", "hello e2e"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + + msg.Data.ShouldBe("hello e2e"); + } + + [Fact] + public async Task RequestReply() + { + await using var requester = fixture.CreateClient(); + await using var responder = fixture.CreateClient(); + + await requester.ConnectAsync(); + await responder.ConnectAsync(); + + await using var subscription = await responder.SubscribeCoreAsync("e2e.test.rpc"); + await responder.PingAsync(); // Flush to ensure subscription is active + + // Background task to read and reply + var responderTask = Task.Run(async () => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + await responder.PublishAsync(msg.ReplyTo!, $"reply: {msg.Data}"); + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var reply = await requester.RequestAsync("e2e.test.rpc", "ping", cancellationToken: cts.Token); + + reply.Data.ShouldBe("reply: ping"); + + await responderTask; // Ensure no exceptions in the responder + } +} diff --git a/tests/NATS.E2E.Tests/CoreMessagingTests.cs b/tests/NATS.E2E.Tests/CoreMessagingTests.cs new file mode 100644 index 0000000..fe16221 --- /dev/null +++ b/tests/NATS.E2E.Tests/CoreMessagingTests.cs @@ -0,0 +1,378 @@ +using System.Linq; +using System.Net.Sockets; +using System.Text; +using NATS.Client.Core; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E")] +public class CoreMessagingTests(NatsServerFixture fixture) +{ + [Fact] + public async Task WildcardStar_MatchesSingleToken() + { + await using var pub = fixture.CreateClient(); + await using var sub = fixture.CreateClient(); + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("e2e.wc.*"); + await sub.PingAsync(); + + await pub.PublishAsync("e2e.wc.bar", "hello"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + + msg.Data.ShouldBe("hello"); + } + + [Fact] + public async Task WildcardGreaterThan_MatchesMultipleTokens() + { + await using var pub = fixture.CreateClient(); + await using var sub = fixture.CreateClient(); + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("e2e.gt.>"); + await sub.PingAsync(); + + await pub.PublishAsync("e2e.gt.bar.baz", "deep"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + + msg.Data.ShouldBe("deep"); + } + + [Fact] + public async Task WildcardStar_DoesNotMatchMultipleTokens() + { + await using var pub = fixture.CreateClient(); + await using var sub = fixture.CreateClient(); + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("e2e.nomat.*"); + await sub.PingAsync(); + + await pub.PublishAsync("e2e.nomat.bar.baz", "should not arrive"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask(); + var winner = await Task.WhenAny(readTask, Task.Delay(1000)); + + winner.ShouldNotBe(readTask); + } + + [Fact] + public async Task QueueGroup_LoadBalances() + { + await using var c1 = fixture.CreateClient(); + await using var c2 = fixture.CreateClient(); + await using var c3 = fixture.CreateClient(); + await using var pub = fixture.CreateClient(); + + await c1.ConnectAsync(); + await c2.ConnectAsync(); + await c3.ConnectAsync(); + await pub.ConnectAsync(); + + await using var s1 = await c1.SubscribeCoreAsync("e2e.qlb", queueGroup: "workers"); + await using var s2 = await c2.SubscribeCoreAsync("e2e.qlb", queueGroup: "workers"); + await using var s3 = await c3.SubscribeCoreAsync("e2e.qlb", queueGroup: "workers"); + + await c1.PingAsync(); + await c2.PingAsync(); + await c3.PingAsync(); + + using var collectionCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var counts = new int[3]; + + async Task Collect(INatsSub sub, int idx) + { + try + { + await foreach (var _ in sub.Msgs.ReadAllAsync(collectionCts.Token)) + Interlocked.Increment(ref counts[idx]); + } + catch (OperationCanceledException) { } + } + + var tasks = new[] + { + Collect(s1, 0), + Collect(s2, 1), + Collect(s3, 2), + }; + + for (var i = 0; i < 30; i++) + await pub.PublishAsync("e2e.qlb", i); + + await pub.PingAsync(); + + // Wait until all 30 messages have been received + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (counts[0] + counts[1] + counts[2] < 30 && !deadline.IsCancellationRequested) + await Task.Delay(10, deadline.Token).ContinueWith(_ => { }); + + collectionCts.Cancel(); + await Task.WhenAll(tasks); + + var total = counts[0] + counts[1] + counts[2]; + total.ShouldBe(30); + + // Verify at least one queue member received messages (distribution + // is implementation-defined and may heavily favor one member when + // messages are published in a tight loop). + counts.Max().ShouldBeGreaterThan(0); + } + + [Fact] + public async Task QueueGroup_MixedWithPlainSub() + { + await using var plainClient = fixture.CreateClient(); + await using var q1Client = fixture.CreateClient(); + await using var q2Client = fixture.CreateClient(); + await using var pub = fixture.CreateClient(); + + await plainClient.ConnectAsync(); + await q1Client.ConnectAsync(); + await q2Client.ConnectAsync(); + await pub.ConnectAsync(); + + await using var plainSub = await plainClient.SubscribeCoreAsync("e2e.qmix"); + await using var qSub1 = await q1Client.SubscribeCoreAsync("e2e.qmix", queueGroup: "qmix"); + await using var qSub2 = await q2Client.SubscribeCoreAsync("e2e.qmix", queueGroup: "qmix"); + + await plainClient.PingAsync(); + await q1Client.PingAsync(); + await q2Client.PingAsync(); + + using var collectionCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var plainCount = 0; + var q1Count = 0; + var q2Count = 0; + + async Task Collect(INatsSub sub, Action increment) + { + try + { + await foreach (var _ in sub.Msgs.ReadAllAsync(collectionCts.Token)) + increment(); + } + catch (OperationCanceledException) { } + } + + var tasks = new[] + { + Collect(plainSub, () => Interlocked.Increment(ref plainCount)), + Collect(qSub1, () => Interlocked.Increment(ref q1Count)), + Collect(qSub2, () => Interlocked.Increment(ref q2Count)), + }; + + for (var i = 0; i < 10; i++) + await pub.PublishAsync("e2e.qmix", i); + + await pub.PingAsync(); + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (plainCount < 10 && !deadline.IsCancellationRequested) + await Task.Delay(10, deadline.Token).ContinueWith(_ => { }); + + collectionCts.Cancel(); + await Task.WhenAll(tasks); + + plainCount.ShouldBe(10); + (q1Count + q2Count).ShouldBe(10); + } + + [Fact] + public async Task Unsub_StopsDelivery() + { + await using var pub = fixture.CreateClient(); + await using var subClient = fixture.CreateClient(); + await pub.ConnectAsync(); + await subClient.ConnectAsync(); + + var subscription = await subClient.SubscribeCoreAsync("e2e.unsub"); + await subClient.PingAsync(); + + await subscription.DisposeAsync(); + await subClient.PingAsync(); + + // Subscribe a fresh listener on the same subject to verify the unsubscribed + // client does NOT receive messages, while the fresh one does. + await using var verifyClient = fixture.CreateClient(); + await verifyClient.ConnectAsync(); + await using var verifySub = await verifyClient.SubscribeCoreAsync("e2e.unsub"); + await verifyClient.PingAsync(); + + await pub.PublishAsync("e2e.unsub", "after-unsub"); + await pub.PingAsync(); + + // The fresh subscriber should receive the message + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await verifySub.Msgs.ReadAsync(cts.Token); + msg.Data.ShouldBe("after-unsub"); + + // The original (disposed) subscription's channel should be completed — + // reading from it should NOT yield "after-unsub" + var received = new List(); + try + { + await foreach (var m in subscription.Msgs.ReadAllAsync(default)) + received.Add(m.Data); + } + catch { /* channel completed or cancelled — expected */ } + + received.ShouldNotContain("after-unsub"); + } + + [Fact] + public async Task Unsub_WithMaxMessages() + { + using var tcp = new TcpClient(); + await tcp.ConnectAsync("127.0.0.1", fixture.Port); + await using var ns = tcp.GetStream(); + using var reader = new StreamReader(ns, Encoding.ASCII, leaveOpen: true); + var writer = new StreamWriter(ns, Encoding.ASCII, leaveOpen: true) { AutoFlush = true, NewLine = "\r\n" }; + + // Read INFO + var info = await reader.ReadLineAsync(); + info.ShouldNotBeNull(); + info.ShouldStartWith("INFO"); + + await writer.WriteLineAsync("CONNECT {\"verbose\":false,\"protocol\":1}"); + await writer.WriteLineAsync("SUB e2e.unsub.max 1"); + await writer.WriteLineAsync("UNSUB 1 3"); + await writer.WriteLineAsync("PING"); + + // Wait for PONG to know server processed commands + string? line; + do { line = await reader.ReadLineAsync(); } while (line != null && !line.StartsWith("PONG")); + + for (var i = 0; i < 5; i++) + await writer.WriteLineAsync($"PUB e2e.unsub.max 1\r\nx"); + + await writer.WriteLineAsync("PING"); + + var msgCount = 0; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!cts.IsCancellationRequested) + { + line = await reader.ReadLineAsync(); + if (line == null) break; + if (line.StartsWith("MSG")) msgCount++; + if (line.StartsWith("PONG")) break; + } + + msgCount.ShouldBe(3); + } + + [Fact] + public async Task FanOut_MultipleSubscribers() + { + await using var pub = fixture.CreateClient(); + await using var sub1 = fixture.CreateClient(); + await using var sub2 = fixture.CreateClient(); + await using var sub3 = fixture.CreateClient(); + + await pub.ConnectAsync(); + await sub1.ConnectAsync(); + await sub2.ConnectAsync(); + await sub3.ConnectAsync(); + + await using var s1 = await sub1.SubscribeCoreAsync("e2e.fanout"); + await using var s2 = await sub2.SubscribeCoreAsync("e2e.fanout"); + await using var s3 = await sub3.SubscribeCoreAsync("e2e.fanout"); + + await sub1.PingAsync(); + await sub2.PingAsync(); + await sub3.PingAsync(); + + await pub.PublishAsync("e2e.fanout", "broadcast"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var m1 = await s1.Msgs.ReadAsync(cts.Token); + var m2 = await s2.Msgs.ReadAsync(cts.Token); + var m3 = await s3.Msgs.ReadAsync(cts.Token); + + m1.Data.ShouldBe("broadcast"); + m2.Data.ShouldBe("broadcast"); + m3.Data.ShouldBe("broadcast"); + } + + [Fact] + public async Task EchoOff_PublisherDoesNotReceiveSelf() + { + var opts = new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Port}", + Echo = false, + }; + + await using var client = new NatsConnection(opts); + await client.ConnectAsync(); + + await using var subscription = await client.SubscribeCoreAsync("e2e.echo"); + await client.PingAsync(); + + await client.PublishAsync("e2e.echo", "self"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var readTask = subscription.Msgs.ReadAsync(cts.Token).AsTask(); + var winner = await Task.WhenAny(readTask, Task.Delay(1000)); + + winner.ShouldNotBe(readTask); + } + + [Fact] + public async Task VerboseMode_OkResponses() + { + using var tcp = new TcpClient(); + await tcp.ConnectAsync("127.0.0.1", fixture.Port); + await using var ns = tcp.GetStream(); + using var reader = new StreamReader(ns, Encoding.ASCII, leaveOpen: true); + var writer = new StreamWriter(ns, Encoding.ASCII, leaveOpen: true) { AutoFlush = true, NewLine = "\r\n" }; + + // Read INFO + var info = await reader.ReadLineAsync(); + info.ShouldNotBeNull(); + info.ShouldStartWith("INFO"); + + await writer.WriteLineAsync("CONNECT {\"verbose\":true,\"protocol\":1}"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var connectResponse = await reader.ReadLineAsync(); + connectResponse.ShouldBe("+OK"); + + await writer.WriteLineAsync("SUB test 1"); + var subResponse = await reader.ReadLineAsync(); + subResponse.ShouldBe("+OK"); + + await writer.WriteLineAsync("PING"); + var pongResponse = await reader.ReadLineAsync(); + pongResponse.ShouldBe("PONG"); + } + + [Fact] + public async Task NoResponders_Returns503() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await Should.ThrowAsync(async () => + { + await client.RequestAsync("e2e.noresp.xxx", "ping", cancellationToken: cts.Token); + }); + } +} diff --git a/tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs new file mode 100644 index 0000000..7239bb6 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/AccountServerFixture.cs @@ -0,0 +1,49 @@ +using NATS.Client.Core; + +namespace NATS.E2E.Tests.Infrastructure; + +public sealed class AccountServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + + public int Port => _server.Port; + + public async Task InitializeAsync() + { + var config = """ + accounts { + ACCT_A { + users = [ + { user: "user_a", password: "pass_a" } + ] + } + ACCT_B { + users = [ + { user: "user_b", password: "pass_b" } + ] + } + } + """; + + _server = NatsServerProcess.WithConfig(config); + await _server.StartAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + } + + public NatsConnection CreateClientA() + { + return new NatsConnection(new NatsOpts { Url = $"nats://user_a:pass_a@127.0.0.1:{Port}" }); + } + + public NatsConnection CreateClientB() + { + return new NatsConnection(new NatsOpts { Url = $"nats://user_b:pass_b@127.0.0.1:{Port}" }); + } +} + +[CollectionDefinition("E2E-Accounts")] +public class AccountsCollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs new file mode 100644 index 0000000..cf74333 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/AuthServerFixture.cs @@ -0,0 +1,82 @@ +using NATS.Client.Core; +using NATS.NKeys; + +namespace NATS.E2E.Tests.Infrastructure; + +public sealed class AuthServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + + public string NKeyPublicKey { get; } + public string NKeySeed { get; } + + public int Port => _server.Port; + + public AuthServerFixture() + { + var kp = KeyPair.CreatePair(PrefixByte.User); + NKeyPublicKey = kp.GetPublicKey(); + NKeySeed = kp.GetSeed(); + } + + public async Task InitializeAsync() + { + var config = $$""" + max_payload: 512 + + authorization { + users: [ + { user: "testuser", password: "testpass" }, + { + user: "pubonly", + password: "pubpass", + permissions: { + publish: { allow: ["allowed.>"] }, + subscribe: { allow: ["_INBOX.>"] } + } + }, + { + user: "subonly", + password: "subpass", + permissions: { + subscribe: { allow: ["allowed.>", "_INBOX.>"] }, + publish: { allow: ["_INBOX.>"] } + } + }, + { user: "limited", password: "limpass" }, + { nkey: "{{NKeyPublicKey}}" } + ] + } + + max_subs: 5 + """; + + _server = NatsServerProcess.WithConfig(config); + await _server.StartAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + } + + public NatsConnection CreateClient(string user, string password) + { + var opts = new NatsOpts + { + Url = $"nats://127.0.0.1:{Port}", + AuthOpts = new NatsAuthOpts + { + Username = user, + Password = password, + }, + }; + return new NatsConnection(opts); + } + + public NatsConnection CreateClient() + => new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" }); +} + +[CollectionDefinition("E2E-Auth")] +public class AuthCollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/Infrastructure/Collections.cs b/tests/NATS.E2E.Tests/Infrastructure/Collections.cs new file mode 100644 index 0000000..078003f --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/Collections.cs @@ -0,0 +1,4 @@ +namespace NATS.E2E.Tests.Infrastructure; + +[CollectionDefinition("E2E")] +public class E2ECollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs b/tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs new file mode 100644 index 0000000..37b98a7 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/E2ETestHelper.cs @@ -0,0 +1,15 @@ +using NATS.Client.Core; + +namespace NATS.E2E.Tests.Infrastructure; + +public static class E2ETestHelper +{ + public static NatsConnection CreateClient(int port) + => new(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); + + public static NatsConnection CreateClient(int port, NatsOpts opts) + => new(opts with { Url = $"nats://127.0.0.1:{port}" }); + + public static CancellationToken Timeout(int seconds = 10) + => new CancellationTokenSource(TimeSpan.FromSeconds(seconds)).Token; +} diff --git a/tests/NATS.E2E.Tests/Infrastructure/JetStreamServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/JetStreamServerFixture.cs new file mode 100644 index 0000000..460aa15 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/JetStreamServerFixture.cs @@ -0,0 +1,45 @@ +using NATS.Client.Core; + +namespace NATS.E2E.Tests.Infrastructure; + +public sealed class JetStreamServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + private string _storeDir = null!; + + public int Port => _server.Port; + + public async Task InitializeAsync() + { + _storeDir = Path.Combine(Path.GetTempPath(), "nats-e2e-js-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_storeDir); + + var config = $$""" + jetstream { + store_dir: "{{_storeDir}}" + max_mem_store: 64mb + max_file_store: 256mb + } + """; + + _server = NatsServerProcess.WithConfig(config); + await _server.StartAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + + if (_storeDir is not null && Directory.Exists(_storeDir)) + { + try { Directory.Delete(_storeDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + } + + public NatsConnection CreateClient() + => new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" }); +} + +[CollectionDefinition("E2E-JetStream")] +public class JetStreamCollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs new file mode 100644 index 0000000..13d30be --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/MonitorServerFixture.cs @@ -0,0 +1,36 @@ +using NATS.Client.Core; +using System.Net.Http; + +namespace NATS.E2E.Tests.Infrastructure; + +public sealed class MonitorServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + + public int Port => _server.Port; + + public int MonitorPort => _server.MonitorPort!.Value; + + public HttpClient MonitorClient { get; private set; } = null!; + + public async Task InitializeAsync() + { + _server = new NatsServerProcess(enableMonitoring: true); + await _server.StartAsync(); + MonitorClient = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{MonitorPort}") }; + } + + public async Task DisposeAsync() + { + MonitorClient?.Dispose(); + await _server.DisposeAsync(); + } + + public NatsConnection CreateClient() + { + return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" }); + } +} + +[CollectionDefinition("E2E-Monitor")] +public class MonitorCollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs new file mode 100644 index 0000000..ac83eb0 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/NatsServerFixture.cs @@ -0,0 +1,31 @@ +using NATS.Client.Core; + +namespace NATS.E2E.Tests.Infrastructure; + +/// +/// xUnit fixture that manages a single NATS server process shared across a test collection. +/// +public sealed class NatsServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + + public int Port => _server.Port; + + public string ServerOutput => _server.Output; + + public async Task InitializeAsync() + { + _server = new NatsServerProcess(); + await _server.StartAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + } + + public NatsConnection CreateClient() + { + return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" }); + } +} diff --git a/tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs b/tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs new file mode 100644 index 0000000..0deb879 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/NatsServerProcess.cs @@ -0,0 +1,205 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace NATS.E2E.Tests.Infrastructure; + +/// +/// Manages a NATS.Server.Host child process for E2E testing. +/// Launches the server on an ephemeral port and polls TCP readiness. +/// +public sealed class NatsServerProcess : IAsyncDisposable +{ + private Process? _process; + private readonly StringBuilder _output = new(); + private readonly object _outputLock = new(); + private readonly string[]? _extraArgs; + private readonly string? _configContent; + private readonly bool _enableMonitoring; + private string? _configFilePath; + + public int Port { get; } + + public int? MonitorPort { get; } + + public string Output + { + get + { + lock (_outputLock) + return _output.ToString(); + } + } + + public NatsServerProcess(string[]? extraArgs = null, string? configContent = null, bool enableMonitoring = false) + { + Port = AllocateFreePort(); + _extraArgs = extraArgs; + _configContent = configContent; + _enableMonitoring = enableMonitoring; + + if (_enableMonitoring) + MonitorPort = AllocateFreePort(); + } + + /// + /// Convenience factory for creating a server with a config file. + /// + public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false) + => new(configContent: configContent, enableMonitoring: enableMonitoring); + + public async Task StartAsync() + { + var hostDll = ResolveHostDll(); + + // Write config file if provided + if (_configContent is not null) + { + _configFilePath = Path.Combine(Path.GetTempPath(), $"nats-e2e-{Guid.NewGuid():N}.conf"); + await File.WriteAllTextAsync(_configFilePath, _configContent); + } + + // Build argument string + var args = new StringBuilder($"exec \"{hostDll}\" -p {Port}"); + if (_configFilePath is not null) + args.Append($" -c \"{_configFilePath}\""); + if (_enableMonitoring && MonitorPort.HasValue) + args.Append($" -m {MonitorPort.Value}"); + if (_extraArgs is not null) + { + foreach (var arg in _extraArgs) + args.Append($" {arg}"); + } + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = args.ToString(), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + _process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + + _process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + lock (_outputLock) _output.AppendLine(e.Data); + }; + _process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + lock (_outputLock) _output.AppendLine(e.Data); + }; + + _process.Start(); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + await WaitForTcpReadyAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_process is not null) + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await _process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + // Already killed the tree above; nothing more to do + } + } + + _process.Dispose(); + _process = null; + } + + // Clean up temp config file + if (_configFilePath is not null && File.Exists(_configFilePath)) + { + File.Delete(_configFilePath); + _configFilePath = null; + } + } + + private async Task WaitForTcpReadyAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + while (!timeout.Token.IsCancellationRequested) + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token); + return; // Connected — server is ready + } + catch (SocketException) + { + await Task.Delay(100, timeout.Token); + } + } + + throw new TimeoutException( + $"NATS server did not become ready on port {Port} within 10s.\n\nServer output:\n{Output}"); + } + + private static string ResolveHostDll() + { + // Walk up from test output directory to find solution root (contains NatsDotNet.slnx) + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "NatsDotNet.slnx"))) + { + var dll = Path.Combine(dir.FullName, "src", "NATS.Server.Host", "bin", "Debug", "net10.0", "NATS.Server.Host.dll"); + if (File.Exists(dll)) + return dll; + + // DLL not found — build it + var build = Process.Start(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "build src/NATS.Server.Host/NATS.Server.Host.csproj -c Debug", + WorkingDirectory = dir.FullName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + build!.WaitForExit(); + + if (build.ExitCode != 0) + throw new InvalidOperationException( + $"Failed to build NATS.Server.Host:\n{build.StandardError.ReadToEnd()}"); + + if (File.Exists(dll)) + return dll; + + throw new FileNotFoundException($"Built NATS.Server.Host but DLL not found at: {dll}"); + } + + dir = dir.Parent; + } + + throw new FileNotFoundException( + "Could not find solution root (NatsDotNet.slnx) walking up from " + AppContext.BaseDirectory); + } + + internal static int AllocateFreePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint!).Port; + } +} diff --git a/tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs b/tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs new file mode 100644 index 0000000..3ad4c16 --- /dev/null +++ b/tests/NATS.E2E.Tests/Infrastructure/TlsServerFixture.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NATS.Client.Core; + +namespace NATS.E2E.Tests.Infrastructure; + +public sealed class TlsServerFixture : IAsyncLifetime +{ + private NatsServerProcess _server = null!; + private string _tempDir = null!; + + public int Port => _server.Port; + public string CaCertPath { get; private set; } = null!; + + public async Task InitializeAsync() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"nats-e2e-tls-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + var caCertPath = Path.Combine(_tempDir, "ca.pem"); + var serverCertPath = Path.Combine(_tempDir, "server-cert.pem"); + var serverKeyPath = Path.Combine(_tempDir, "server-key.pem"); + + GenerateCertificates(caCertPath, serverCertPath, serverKeyPath); + + CaCertPath = caCertPath; + + var config = $$""" + tls { + cert_file: "{{serverCertPath}}" + key_file: "{{serverKeyPath}}" + ca_file: "{{caCertPath}}" + } + """; + + _server = NatsServerProcess.WithConfig(config); + await _server.StartAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + + if (_tempDir is not null && Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + public NatsConnection CreateTlsClient() + { + var opts = new NatsOpts + { + Url = $"nats://127.0.0.1:{Port}", + TlsOpts = new NatsTlsOpts + { + Mode = TlsMode.Require, + InsecureSkipVerify = true, + }, + }; + return new NatsConnection(opts); + } + + public NatsConnection CreatePlainClient() + { + return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" }); + } + + private static void GenerateCertificates(string caCertPath, string serverCertPath, string serverKeyPath) + { + // Generate CA key and self-signed certificate + using var caKey = RSA.Create(2048); + var caReq = new CertificateRequest( + "CN=E2E Test CA", + caKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + caReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + + var now = DateTimeOffset.UtcNow; + using var caCert = caReq.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1)); + + // Generate server key and certificate signed by CA + using var serverKey = RSA.Create(2048); + var serverReq = new CertificateRequest( + "CN=localhost", + serverKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddIpAddress(System.Net.IPAddress.Loopback); + sanBuilder.AddDnsName("localhost"); + serverReq.CertificateExtensions.Add(sanBuilder.Build()); + serverReq.CertificateExtensions.Add( + new X509BasicConstraintsExtension(certificateAuthority: false, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: false)); + + using var serverCert = serverReq.Create(caCert, now.AddMinutes(-5), now.AddDays(1), [1, 2, 3, 4]); + + // Export CA cert to PEM + File.WriteAllText(caCertPath, caCert.ExportCertificatePem()); + + // Export server cert to PEM + File.WriteAllText(serverCertPath, serverCert.ExportCertificatePem()); + + // Export server private key to PEM + File.WriteAllText(serverKeyPath, serverKey.ExportRSAPrivateKeyPem()); + } +} + +[CollectionDefinition("E2E-TLS")] +public class TlsCollection : ICollectionFixture; diff --git a/tests/NATS.E2E.Tests/JetStreamTests.cs b/tests/NATS.E2E.Tests/JetStreamTests.cs new file mode 100644 index 0000000..5f0de6e --- /dev/null +++ b/tests/NATS.E2E.Tests/JetStreamTests.cs @@ -0,0 +1,295 @@ +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E-JetStream")] +public class JetStreamTests(JetStreamServerFixture fixture) +{ + // ------------------------------------------------------------------------- + // Test 1 — Create a stream and verify its reported info matches config + // ------------------------------------------------------------------------- + [Fact] + public async Task Stream_CreateAndInfo() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var stream = await js.CreateStreamAsync( + new StreamConfig("E2E_CREATE", ["js.create.>"]), + cts.Token); + + stream.Info.Config.Name.ShouldBe("E2E_CREATE"); + stream.Info.Config.Subjects.ShouldNotBeNull(); + stream.Info.Config.Subjects.ShouldContain("js.create.>"); + } + + // ------------------------------------------------------------------------- + // Test 2 — List streams and verify all created streams appear + // ------------------------------------------------------------------------- + [Fact] + public async Task Stream_ListAndNames() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_LIST_A", ["js.list.a.>"]), cts.Token); + await js.CreateStreamAsync(new StreamConfig("E2E_LIST_B", ["js.list.b.>"]), cts.Token); + await js.CreateStreamAsync(new StreamConfig("E2E_LIST_C", ["js.list.c.>"]), cts.Token); + + var names = new List(); + await foreach (var stream in js.ListStreamsAsync(cancellationToken: cts.Token)) + { + var name = stream.Info.Config.Name; + if (name is not null) + names.Add(name); + } + + names.ShouldContain("E2E_LIST_A"); + names.ShouldContain("E2E_LIST_B"); + names.ShouldContain("E2E_LIST_C"); + } + + // ------------------------------------------------------------------------- + // Test 3 — Delete a stream and verify it is gone + // ------------------------------------------------------------------------- + [Fact] + public async Task Stream_Delete() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_DEL", ["js.del.>"]), cts.Token); + await js.DeleteStreamAsync("E2E_DEL", cts.Token); + + await Should.ThrowAsync(async () => + await js.GetStreamAsync("E2E_DEL", cancellationToken: cts.Token)); + } + + // ------------------------------------------------------------------------- + // Test 4 — Publish messages and verify stream state reflects them + // ------------------------------------------------------------------------- + [Fact] + public async Task Stream_PublishAndGet() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + // Use unique stream name to avoid contamination from parallel tests + var streamName = $"E2E_PUB_{Random.Shared.Next(100000)}"; + await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.pub.{streamName}.>"]), cts.Token); + + await js.PublishAsync($"js.pub.{streamName}.one", "msg1", cancellationToken: cts.Token); + await js.PublishAsync($"js.pub.{streamName}.two", "msg2", cancellationToken: cts.Token); + await js.PublishAsync($"js.pub.{streamName}.three", "msg3", cancellationToken: cts.Token); + + var stream = await js.GetStreamAsync(streamName, cancellationToken: cts.Token); + stream.Info.State.Messages.ShouldBe(3L); + } + + // ------------------------------------------------------------------------- + // Test 5 — Purge a stream and verify message count drops to zero + // ------------------------------------------------------------------------- + [Fact] + public async Task Stream_Purge() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_PURGE", ["js.purge.>"]), cts.Token); + + for (var i = 0; i < 5; i++) + await js.PublishAsync($"js.purge.msg{i}", $"data{i}", cancellationToken: cts.Token); + + var stream = await js.GetStreamAsync("E2E_PURGE", cancellationToken: cts.Token); + await stream.PurgeAsync(new StreamPurgeRequest(), cts.Token); + + var refreshed = await js.GetStreamAsync("E2E_PURGE", cancellationToken: cts.Token); + refreshed.Info.State.Messages.ShouldBe(0L); + } + + // ------------------------------------------------------------------------- + // Test 6 — Create a durable pull consumer and fetch messages + // ------------------------------------------------------------------------- + [Fact] + public async Task Consumer_CreatePullAndConsume() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_PULL", ["js.pull.>"]), cts.Token); + + for (var i = 0; i < 5; i++) + await js.PublishAsync($"js.pull.msg{i}", $"payload{i}", cancellationToken: cts.Token); + + await js.CreateOrUpdateConsumerAsync("E2E_PULL", + new ConsumerConfig { Name = "pull-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit }, + cts.Token); + + var consumer = await js.GetConsumerAsync("E2E_PULL", "pull-consumer", cts.Token); + + var received = new List(); + await foreach (var msg in consumer.FetchAsync(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token)) + { + received.Add(msg.Data); + await msg.AckAsync(cancellationToken: cts.Token); + } + + received.Count.ShouldBe(5); + } + + // ------------------------------------------------------------------------- + // Test 7 — Explicit ack: fetching after ack yields no further messages + // ------------------------------------------------------------------------- + [Fact] + public async Task Consumer_AckExplicit() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_ACK", ["js.ack.>"]), cts.Token); + await js.PublishAsync("js.ack.one", "hello", cancellationToken: cts.Token); + + await js.CreateOrUpdateConsumerAsync("E2E_ACK", + new ConsumerConfig { Name = "ack-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit }, + cts.Token); + + var consumer = await js.GetConsumerAsync("E2E_ACK", "ack-consumer", cts.Token); + + // Fetch and ack the single message + await foreach (var msg in consumer.FetchAsync(new NatsJSFetchOpts { MaxMsgs = 1 }, cancellationToken: cts.Token)) + await msg.AckAsync(cancellationToken: cts.Token); + + // Second fetch should return nothing + var second = new List(); + await foreach (var msg in consumer.FetchAsync(new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) }, cancellationToken: cts.Token)) + second.Add(msg.Data); + + second.Count.ShouldBe(0); + } + + // ------------------------------------------------------------------------- + // Test 8 — List consumers, delete one, verify count drops + // ------------------------------------------------------------------------- + [Fact] + public async Task Consumer_ListAndDelete() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync(new StreamConfig("E2E_CONS_LIST", ["js.conslist.>"]), cts.Token); + + await js.CreateOrUpdateConsumerAsync("E2E_CONS_LIST", + new ConsumerConfig { Name = "cons-one", AckPolicy = ConsumerConfigAckPolicy.None }, + cts.Token); + await js.CreateOrUpdateConsumerAsync("E2E_CONS_LIST", + new ConsumerConfig { Name = "cons-two", AckPolicy = ConsumerConfigAckPolicy.None }, + cts.Token); + + var beforeNames = new List(); + await foreach (var c in js.ListConsumersAsync("E2E_CONS_LIST", cts.Token)) + { + var name = c.Info.Name; + if (name is not null) + beforeNames.Add(name); + } + + beforeNames.Count.ShouldBe(2); + + await js.DeleteConsumerAsync("E2E_CONS_LIST", "cons-one", cts.Token); + + var afterNames = new List(); + await foreach (var c in js.ListConsumersAsync("E2E_CONS_LIST", cts.Token)) + { + var name = c.Info.Name; + if (name is not null) + afterNames.Add(name); + } + + afterNames.Count.ShouldBe(1); + afterNames.ShouldContain("cons-two"); + } + + // ------------------------------------------------------------------------- + // Test 9 — MaxMsgs retention evicts oldest messages when limit is reached + // ------------------------------------------------------------------------- + [Fact] + public async Task Retention_LimitsMaxMessages() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await js.CreateStreamAsync( + new StreamConfig("E2E_MAXMSGS", ["js.maxmsgs.>"]) + { + MaxMsgs = 10, + }, + cts.Token); + + for (var i = 0; i < 15; i++) + await js.PublishAsync($"js.maxmsgs.{i}", $"val{i}", cancellationToken: cts.Token); + + var stream = await js.GetStreamAsync("E2E_MAXMSGS", cancellationToken: cts.Token); + stream.Info.State.Messages.ShouldBe(10L); + } + + // ------------------------------------------------------------------------- + // Test 10 — MaxAge retention expires messages after the configured window + // ------------------------------------------------------------------------- + [Fact] + public async Task Retention_MaxAge() + { + await using var client = fixture.CreateClient(); + await client.ConnectAsync(); + var js = new NatsJSContext(client); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await js.CreateStreamAsync( + new StreamConfig("E2E_MAXAGE", ["js.maxage.>"]) + { + MaxAge = TimeSpan.FromSeconds(2), + }, + cts.Token); + + for (var i = 0; i < 5; i++) + await js.PublishAsync($"js.maxage.{i}", $"val{i}", cancellationToken: cts.Token); + + var before = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token); + before.Info.State.Messages.ShouldBe(5L); + + await Task.Delay(3000, cts.Token); + + var after = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token); + after.Info.State.Messages.ShouldBe(0L); + } +} diff --git a/tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj b/tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj new file mode 100644 index 0000000..1454ad4 --- /dev/null +++ b/tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj @@ -0,0 +1,23 @@ + + + + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs b/tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs new file mode 100644 index 0000000..1976fd1 --- /dev/null +++ b/tests/NATS.E2E.Tests/SlopwatchSuppressAttribute.cs @@ -0,0 +1,12 @@ +// Marker attribute recognised by the slopwatch static-analysis tool. +// Apply to a test method to suppress a specific slopwatch rule violation. +// The justification must be 20+ characters explaining why the suppression is intentional. + +namespace NATS.E2E.Tests; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class SlopwatchSuppressAttribute(string ruleId, string justification) : Attribute +{ + public string RuleId { get; } = ruleId; + public string Justification { get; } = justification; +} diff --git a/tests/NATS.E2E.Tests/TlsTests.cs b/tests/NATS.E2E.Tests/TlsTests.cs new file mode 100644 index 0000000..d44b26a --- /dev/null +++ b/tests/NATS.E2E.Tests/TlsTests.cs @@ -0,0 +1,58 @@ +using NATS.Client.Core; +using NATS.E2E.Tests.Infrastructure; + +namespace NATS.E2E.Tests; + +[Collection("E2E-TLS")] +public class TlsTests(TlsServerFixture fixture) +{ + [Fact] + public async Task Tls_ClientConnectsSecurely() + { + await using var client = fixture.CreateTlsClient(); + await client.ConnectAsync(); + + await client.PingAsync(); + + client.ConnectionState.ShouldBe(NatsConnectionState.Open); + } + + [Fact] + public async Task Tls_PlainTextConnection_Rejected() + { + await using var client = fixture.CreatePlainClient(); + + var threw = false; + try + { + await client.ConnectAsync(); + await client.PingAsync(); + } + catch (Exception) + { + threw = true; + } + + threw.ShouldBeTrue(); + } + + [Fact] + public async Task Tls_PubSub_WorksOverEncryptedConnection() + { + await using var pub = fixture.CreateTlsClient(); + await using var sub = fixture.CreateTlsClient(); + + await pub.ConnectAsync(); + await sub.ConnectAsync(); + + await using var subscription = await sub.SubscribeCoreAsync("tls.pubsub.test"); + await sub.PingAsync(); + + await pub.PublishAsync("tls.pubsub.test", "secure-message"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await subscription.Msgs.ReadAsync(cts.Token); + + msg.Data.ShouldBe("secure-message"); + } +} diff --git a/tests/NATS.Server.Tests/Auth/AccountResponseAndInterestParityBatch1Tests.cs b/tests/NATS.Server.Tests/Auth/AccountResponseAndInterestParityBatch1Tests.cs new file mode 100644 index 0000000..856bb99 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/AccountResponseAndInterestParityBatch1Tests.cs @@ -0,0 +1,117 @@ +using NATS.Server.Auth; +using NATS.Server.Imports; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Auth; + +public class AccountResponseAndInterestParityBatch1Tests +{ + [Fact] + public void ClientInfoHdr_constant_matches_go_value() + { + Account.ClientInfoHdr.ShouldBe("Nats-Request-Info"); + } + + [Fact] + public void Interest_and_subscription_interest_count_plain_and_queue_matches() + { + using var account = new Account("A"); + account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "1" }); + account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "2", Queue = "workers" }); + + account.Interest("orders.created").ShouldBe(2); + account.SubscriptionInterest("orders.created").ShouldBeTrue(); + account.SubscriptionInterest("payments.created").ShouldBeFalse(); + } + + [Fact] + public void NumServiceImports_counts_distinct_from_subject_keys() + { + using var importer = new Account("importer"); + using var exporter = new Account("exporter"); + + importer.Imports.AddServiceImport(new ServiceImport + { + DestinationAccount = exporter, + From = "svc.a", + To = "svc.remote.a", + }); + importer.Imports.AddServiceImport(new ServiceImport + { + DestinationAccount = exporter, + From = "svc.a", + To = "svc.remote.b", + }); + importer.Imports.AddServiceImport(new ServiceImport + { + DestinationAccount = exporter, + From = "svc.b", + To = "svc.remote.c", + }); + + importer.NumServiceImports().ShouldBe(2); + } + + [Fact] + public void NumPendingResponses_filters_by_service_export() + { + using var account = new Account("A"); + account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null); + account.AddServiceExport("svc.two", ServiceResponseType.Singleton, null); + + var seOne = account.Exports.Services["svc.one"]; + var seTwo = account.Exports.Services["svc.two"]; + + account.Exports.Responses["r1"] = new ServiceImport + { + DestinationAccount = account, + From = "_R_.AAA.>", + To = "reply.one", + Export = seOne, + IsResponse = true, + }; + account.Exports.Responses["r2"] = new ServiceImport + { + DestinationAccount = account, + From = "_R_.BBB.>", + To = "reply.two", + Export = seOne, + IsResponse = true, + }; + account.Exports.Responses["r3"] = new ServiceImport + { + DestinationAccount = account, + From = "_R_.CCC.>", + To = "reply.three", + Export = seTwo, + IsResponse = true, + }; + + account.NumPendingAllResponses().ShouldBe(3); + account.NumPendingResponses("svc.one").ShouldBe(2); + account.NumPendingResponses("svc.two").ShouldBe(1); + account.NumPendingResponses("svc.unknown").ShouldBe(0); + } + + [Fact] + public void RemoveRespServiceImport_removes_mapping_for_specified_reason() + { + using var account = new Account("A"); + account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null); + var seOne = account.Exports.Services["svc.one"]; + + var responseSi = new ServiceImport + { + DestinationAccount = account, + From = "_R_.ZZZ.>", + To = "reply", + Export = seOne, + IsResponse = true, + }; + account.Exports.Responses["r1"] = responseSi; + + account.RemoveRespServiceImport(responseSi, ResponseServiceImportRemovalReason.Timeout); + + account.Exports.Responses.Count.ShouldBe(0); + } +} diff --git a/tests/NATS.Server.Tests/Auth/AuthModelAndCalloutConstantsParityTests.cs b/tests/NATS.Server.Tests/Auth/AuthModelAndCalloutConstantsParityTests.cs new file mode 100644 index 0000000..36457df --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/AuthModelAndCalloutConstantsParityTests.cs @@ -0,0 +1,46 @@ +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class AuthModelAndCalloutConstantsParityTests +{ + [Fact] + public void NkeyUser_exposes_parity_fields() + { + var now = DateTimeOffset.UtcNow; + var nkeyUser = new NKeyUser + { + Nkey = "UABC", + Issued = now, + AllowedConnectionTypes = new HashSet { "STANDARD", "WEBSOCKET" }, + ProxyRequired = true, + }; + + nkeyUser.Issued.ShouldBe(now); + nkeyUser.ProxyRequired.ShouldBeTrue(); + nkeyUser.AllowedConnectionTypes.ShouldContain("STANDARD"); + } + + [Fact] + public void User_exposes_parity_fields() + { + var user = new User + { + Username = "alice", + Password = "secret", + AllowedConnectionTypes = new HashSet { "STANDARD" }, + ProxyRequired = false, + }; + + user.ProxyRequired.ShouldBeFalse(); + user.AllowedConnectionTypes.ShouldContain("STANDARD"); + } + + [Fact] + public void External_auth_callout_constants_match_go_subjects_and_header() + { + ExternalAuthCalloutAuthenticator.AuthCalloutSubject.ShouldBe("$SYS.REQ.USER.AUTH"); + ExternalAuthCalloutAuthenticator.AuthRequestSubject.ShouldBe("nats-authorization-request"); + ExternalAuthCalloutAuthenticator.AuthRequestXKeyHeader.ShouldBe("Nats-Server-Xkey"); + } +} diff --git a/tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs b/tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs new file mode 100644 index 0000000..8dc5aa9 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/AuthServiceParityBatch4Tests.cs @@ -0,0 +1,89 @@ +using NATS.NKeys; +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests.Auth; + +public class AuthServiceParityBatch4Tests +{ + [Fact] + public void Build_assigns_global_account_to_orphan_users() + { + var service = AuthService.Build(new NatsOptions + { + Users = [new User { Username = "alice", Password = "secret" }], + }); + + var result = service.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }); + + result.ShouldNotBeNull(); + result.AccountName.ShouldBe(Account.GlobalAccountName); + } + + [Fact] + public void Build_assigns_global_account_to_orphan_nkeys() + { + using var kp = KeyPair.CreatePair(PrefixByte.User); + var pub = kp.GetPublicKey(); + var nonce = "test-nonce"u8.ToArray(); + var sig = new byte[64]; + kp.Sign(nonce, sig); + + var service = AuthService.Build(new NatsOptions + { + NKeys = [new NKeyUser { Nkey = pub }], + }); + + var result = service.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions + { + Nkey = pub, + Sig = Convert.ToBase64String(sig), + }, + Nonce = nonce, + }); + + result.ShouldNotBeNull(); + result.AccountName.ShouldBe(Account.GlobalAccountName); + } + + [Fact] + public void Build_validates_response_permissions_defaults_and_publish_allow() + { + var service = AuthService.Build(new NatsOptions + { + Users = + [ + new User + { + Username = "alice", + Password = "secret", + Permissions = new Permissions + { + Response = new ResponsePermission { MaxMsgs = 0, Expires = TimeSpan.Zero }, + }, + }, + ], + }); + + var result = service.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "secret" }, + Nonce = [], + }); + + result.ShouldNotBeNull(); + result.Permissions.ShouldNotBeNull(); + result.Permissions.Response.ShouldNotBeNull(); + result.Permissions.Response.MaxMsgs.ShouldBe(NatsProtocol.DefaultAllowResponseMaxMsgs); + result.Permissions.Response.Expires.ShouldBe(NatsProtocol.DefaultAllowResponseExpiration); + result.Permissions.Publish.ShouldNotBeNull(); + result.Permissions.Publish.Allow.ShouldNotBeNull(); + result.Permissions.Publish.Allow.Count.ShouldBe(0); + } +} diff --git a/tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs b/tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs new file mode 100644 index 0000000..8c8b67a --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/TlsMapAuthParityBatch1Tests.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class TlsMapAuthParityBatch1Tests +{ + [Fact] + public void GetTlsAuthDcs_extracts_domain_components_from_subject() + { + using var cert = CreateSelfSignedCert("CN=alice,DC=example,DC=com"); + + TlsMapAuthenticator.GetTlsAuthDcs(cert.SubjectName).ShouldBe("DC=example,DC=com"); + } + + [Fact] + public void DnsAltNameLabels_and_matches_follow_rfc6125_shape() + { + var labels = TlsMapAuthenticator.DnsAltNameLabels("*.Example.COM"); + labels.ShouldBe(["*", "example", "com"]); + + TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://node.example.com:6222")]).ShouldBeTrue(); + TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://a.b.example.com:6222")]).ShouldBeFalse(); + } + + [Fact] + public void Authenticate_can_match_user_from_email_or_dns_san() + { + using var cert = CreateSelfSignedCertWithSan("CN=ignored", "ops@example.com", "router.example.com"); + var auth = new TlsMapAuthenticator([ + new User { Username = "ops@example.com", Password = "" }, + new User { Username = "router.example.com", Password = "" }, + ]); + + var ctx = new ClientAuthContext + { + Opts = new Protocol.ClientOptions(), + Nonce = [], + ClientCertificate = cert, + }; + + var result = auth.Authenticate(ctx); + result.ShouldNotBeNull(); + (result.Identity == "ops@example.com" || result.Identity == "router.example.com").ShouldBeTrue(); + } + + private static X509Certificate2 CreateSelfSignedCert(string subjectName) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + private static X509Certificate2 CreateSelfSignedCertWithSan(string subjectName, string email, string dns) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sans = new SubjectAlternativeNameBuilder(); + sans.AddEmailAddress(email); + sans.AddDnsName(dns); + req.CertificateExtensions.Add(sans.Build()); + return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } +} diff --git a/tests/NATS.Server.Tests/Configuration/ConfigPedanticParityBatch1Tests.cs b/tests/NATS.Server.Tests/Configuration/ConfigPedanticParityBatch1Tests.cs new file mode 100644 index 0000000..2e7f4ef --- /dev/null +++ b/tests/NATS.Server.Tests/Configuration/ConfigPedanticParityBatch1Tests.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Configuration; + +public class ConfigPedanticParityBatch1Tests +{ + [Fact] + public void ParseWithChecks_matches_parse_for_basic_input() + { + const string config = "port: 4222\nhost: 127.0.0.1\n"; + + var regular = NatsConfParser.Parse(config); + var withChecks = NatsConfParser.ParseWithChecks(config); + + withChecks["port"].ShouldBe(regular["port"]); + withChecks["host"].ShouldBe(regular["host"]); + } + + [Fact] + public void ParseFileWithChecks_and_digest_wrappers_are_available_and_stable() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "port: 4222\n"); + + var parsed = NatsConfParser.ParseFileWithChecks(path); + parsed["port"].ShouldBe(4222L); + + var (cfg1, d1) = NatsConfParser.ParseFileWithChecksDigest(path); + var (cfg2, d2) = NatsConfParser.ParseFileWithDigest(path); + var (_, d1Repeat) = NatsConfParser.ParseFileWithChecksDigest(path); + + cfg1["port"].ShouldBe(4222L); + cfg2["port"].ShouldBe(4222L); + d1.ShouldStartWith("sha256:"); + d2.ShouldStartWith("sha256:"); + d1.ShouldBe(d1Repeat); + d1.ShouldNotBe(d2); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void PedanticToken_accessors_match_expected_values() + { + var token = new Token(TokenType.Integer, "42", 3, 7); + var pedantic = new PedanticToken(token, value: 42L, usedVariable: true, sourceFile: "test.conf"); + + pedantic.Value().ShouldBe(42L); + pedantic.Line().ShouldBe(3); + pedantic.Position().ShouldBe(7); + pedantic.IsUsedVariable().ShouldBeTrue(); + pedantic.SourceFile().ShouldBe("test.conf"); + pedantic.MarshalJson().ShouldBe("42"); + } + + [Fact] + public void Parser_exposes_pedantic_compatibility_hooks() + { + var parserType = typeof(NatsConfParser); + parserType.GetMethod("CleanupUsedEnvVars", BindingFlags.NonPublic | BindingFlags.Static).ShouldNotBeNull(); + + var parserStateType = parserType.GetNestedType("ParserState", BindingFlags.NonPublic); + parserStateType.ShouldNotBeNull(); + parserStateType!.GetMethod("PushItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull(); + parserStateType.GetMethod("PopItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull(); + } + + [Fact] + public void Bcrypt_prefix_values_are_preserved_for_2a_and_2b() + { + var parsed2a = NatsConfParser.Parse("pwd: $2a$abc\n"); + var parsed2b = NatsConfParser.Parse("pwd: $2b$abc\n"); + + parsed2a["pwd"].ShouldBe("$2a$abc"); + parsed2b["pwd"].ShouldBe("$2b$abc"); + } +} diff --git a/tests/NATS.Server.Tests/Configuration/ConfigWarningsParityBatch1Tests.cs b/tests/NATS.Server.Tests/Configuration/ConfigWarningsParityBatch1Tests.cs new file mode 100644 index 0000000..1b06069 --- /dev/null +++ b/tests/NATS.Server.Tests/Configuration/ConfigWarningsParityBatch1Tests.cs @@ -0,0 +1,31 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Configuration; + +public class ConfigWarningsParityBatch1Tests +{ + [Fact] + public void Config_warning_types_expose_message_and_source() + { + var warning = new ConfigWarningException("warn", "conf:1:2"); + var unknown = new UnknownConfigFieldWarning("mystery_field", "conf:3:1"); + + warning.Message.ShouldBe("warn"); + warning.SourceLocation.ShouldBe("conf:1:2"); + unknown.Field.ShouldBe("mystery_field"); + unknown.SourceLocation.ShouldBe("conf:3:1"); + unknown.Message.ShouldContain("unknown field"); + } + + [Fact] + public void ProcessConfig_collects_unknown_field_warnings_when_errors_are_present() + { + var ex = Should.Throw(() => ConfigProcessor.ProcessConfig(""" + max_sub_tokens: 300 + totally_unknown_field: 1 + """)); + + ex.Errors.ShouldNotBeEmpty(); + ex.Warnings.ShouldContain(w => w.Contains("unknown field totally_unknown_field", StringComparison.Ordinal)); + } +} diff --git a/tests/NATS.Server.Tests/Events/EventApiAndSubjectsParityBatch2Tests.cs b/tests/NATS.Server.Tests/Events/EventApiAndSubjectsParityBatch2Tests.cs new file mode 100644 index 0000000..1d90416 --- /dev/null +++ b/tests/NATS.Server.Tests/Events/EventApiAndSubjectsParityBatch2Tests.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using NATS.Server.Events; + +namespace NATS.Server.Tests.Events; + +public class EventApiAndSubjectsParityBatch2Tests +{ + [Fact] + public void EventSubjects_DefineMissingServerRequestSubjects() + { + EventSubjects.RemoteLatency.ShouldBe("$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2"); + EventSubjects.UserDirectInfo.ShouldBe("$SYS.REQ.USER.INFO"); + EventSubjects.UserDirectReq.ShouldBe("$SYS.REQ.USER.{0}.INFO"); + EventSubjects.AccountNumSubsReq.ShouldBe("$SYS.REQ.ACCOUNT.NSUBS"); + EventSubjects.AccountSubs.ShouldBe("$SYS._INBOX_.{0}.NSUBS"); + EventSubjects.ClientKickReq.ShouldBe("$SYS.REQ.SERVER.{0}.KICK"); + EventSubjects.ClientLdmReq.ShouldBe("$SYS.REQ.SERVER.{0}.LDM"); + EventSubjects.ServerStatsPingReq.ShouldBe("$SYS.REQ.SERVER.PING.STATSZ"); + EventSubjects.ServerReloadReq.ShouldBe("$SYS.REQ.SERVER.{0}.RELOAD"); + } + + [Fact] + public void OcspSubjects_MatchGoPatterns() + { + EventSubjects.OcspPeerReject.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT"); + EventSubjects.OcspPeerChainlinkInvalid.ShouldBe("$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID"); + } + + [Fact] + public void OcspPeerRejectEvent_IncludesPeerCertInfo() + { + var evt = new OcspPeerRejectEventMsg + { + Id = "id", + Kind = "client", + Reason = "revoked", + Peer = new EventCertInfo + { + Subject = "CN=client", + Issuer = "CN=issuer", + Fingerprint = "fingerprint", + Raw = "raw", + }, + }; + + var json = JsonSerializer.Serialize(evt); + json.ShouldContain("\"peer\":"); + json.ShouldContain("\"subject\":\"CN=client\""); + } + + [Fact] + public void OcspPeerChainlinkInvalidEvent_SerializesExpectedShape() + { + var evt = new OcspPeerChainlinkInvalidEventMsg + { + Id = "id", + Link = new EventCertInfo { Subject = "CN=link" }, + Peer = new EventCertInfo { Subject = "CN=peer" }, + }; + + var json = JsonSerializer.Serialize(evt); + json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.ocsp_peer_link_invalid\""); + json.ShouldContain("\"link\":"); + json.ShouldContain("\"peer\":"); + } + + [Fact] + public void EventFilterOptions_HasCoreGoFields() + { + var opts = new EventFilterOptions + { + Name = "srv-a", + Cluster = "cluster-a", + Host = "127.0.0.1", + Tags = ["a", "b"], + Domain = "domain-a", + }; + + opts.Name.ShouldBe("srv-a"); + opts.Cluster.ShouldBe("cluster-a"); + opts.Host.ShouldBe("127.0.0.1"); + opts.Tags.ShouldBe(["a", "b"]); + opts.Domain.ShouldBe("domain-a"); + } + + [Fact] + public void OptionRequestTypes_IncludeBaseFilterFields() + { + new StatszEventOptions { Name = "n" }.Name.ShouldBe("n"); + new ConnzEventOptions { Cluster = "c" }.Cluster.ShouldBe("c"); + new RoutezEventOptions { Host = "h" }.Host.ShouldBe("h"); + new HealthzEventOptions { Domain = "d" }.Domain.ShouldBe("d"); + new JszEventOptions { Tags = ["t"] }.Tags.ShouldBe(["t"]); + } + + [Fact] + public void ServerApiResponses_ExposeDataAndError() + { + var response = new ServerAPIResponse + { + Server = new EventServerInfo { Id = "S1" }, + Data = new { ok = true }, + Error = new ServerAPIError { Code = 500, Description = "err" }, + }; + + response.Server.Id.ShouldBe("S1"); + response.Error?.Code.ShouldBe(500); + response.Error?.Description.ShouldBe("err"); + } + + [Fact] + public void TypedServerApiWrappers_CarryResponsePayload() + { + new ServerAPIConnzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIRoutezResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIGatewayzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIJszResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIHealthzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIVarzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPISubszResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPILeafzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIAccountzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIExpvarzResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIpqueueszResponse { Data = new object() }.Data.ShouldNotBeNull(); + new ServerAPIRaftzResponse { Data = new object() }.Data.ShouldNotBeNull(); + } + + [Fact] + public void RequestPayloadTypes_KickAndLdm() + { + var kick = new KickClientReq { ClientId = 22 }; + var ldm = new LDMClientReq { ClientId = 33 }; + + kick.ClientId.ShouldBe(22UL); + ldm.ClientId.ShouldBe(33UL); + } + + [Fact] + public void UserInfo_IncludesExpectedIdentityFields() + { + var info = new UserInfo + { + User = "alice", + Account = "A", + Permissions = "pubsub", + }; + + info.User.ShouldBe("alice"); + info.Account.ShouldBe("A"); + info.Permissions.ShouldBe("pubsub"); + } +} diff --git a/tests/NATS.Server.Tests/Events/EventCompressionTests.cs b/tests/NATS.Server.Tests/Events/EventCompressionTests.cs index e6256e0..87a328e 100644 --- a/tests/NATS.Server.Tests/Events/EventCompressionTests.cs +++ b/tests/NATS.Server.Tests/Events/EventCompressionTests.cs @@ -168,4 +168,31 @@ public class EventCompressionTests : IDisposable EventCompressor.TotalUncompressed.ShouldBe(0L); EventCompressor.BytesSaved.ShouldBe(0L); } + + [Fact] + public void GetAcceptEncoding_ParsesSnappyAndGzip() + { + EventCompressor.GetAcceptEncoding("gzip, snappy").ShouldBe(EventCompressionType.Snappy); + EventCompressor.GetAcceptEncoding("gzip").ShouldBe(EventCompressionType.Gzip); + EventCompressor.GetAcceptEncoding("br").ShouldBe(EventCompressionType.Unsupported); + EventCompressor.GetAcceptEncoding(null).ShouldBe(EventCompressionType.None); + } + + [Fact] + public void CompressionHeaderConstants_MatchGo() + { + EventCompressor.AcceptEncodingHeader.ShouldBe("Accept-Encoding"); + EventCompressor.ContentEncodingHeader.ShouldBe("Content-Encoding"); + } + + [Fact] + public void CompressAndDecompress_Gzip_RoundTrip_MatchesOriginal() + { + var payload = Encoding.UTF8.GetBytes("""{"server":"s1","data":"gzip-payload"}"""); + + var compressed = EventCompressor.Compress(payload, EventCompressionType.Gzip); + var restored = EventCompressor.Decompress(compressed, EventCompressionType.Gzip); + + restored.ShouldBe(payload); + } } diff --git a/tests/NATS.Server.Tests/Events/EventServerInfoCapabilityParityBatch1Tests.cs b/tests/NATS.Server.Tests/Events/EventServerInfoCapabilityParityBatch1Tests.cs new file mode 100644 index 0000000..8308c92 --- /dev/null +++ b/tests/NATS.Server.Tests/Events/EventServerInfoCapabilityParityBatch1Tests.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using NATS.Server.Events; + +namespace NATS.Server.Tests.Events; + +public class EventServerInfoCapabilityParityBatch1Tests +{ + [Fact] + public void ServerCapability_flags_match_expected_values() + { + ((ulong)ServerCapability.JetStreamEnabled).ShouldBe(1UL << 0); + ((ulong)ServerCapability.BinaryStreamSnapshot).ShouldBe(1UL << 1); + ((ulong)ServerCapability.AccountNRG).ShouldBe(1UL << 2); + } + + [Fact] + public void EventServerInfo_capability_methods_set_and_read_flags() + { + var info = new EventServerInfo(); + + info.SetJetStreamEnabled(); + info.SetBinaryStreamSnapshot(); + info.SetAccountNRG(); + + info.JetStream.ShouldBeTrue(); + info.JetStreamEnabled().ShouldBeTrue(); + info.BinaryStreamSnapshot().ShouldBeTrue(); + info.AccountNRG().ShouldBeTrue(); + } + + [Fact] + public void ServerID_serializes_with_name_host_id_fields() + { + var payload = new ServerID + { + Name = "srv-a", + Host = "127.0.0.1", + Id = "N1", + }; + + var json = JsonSerializer.Serialize(payload); + json.ShouldContain("\"name\":\"srv-a\""); + json.ShouldContain("\"host\":\"127.0.0.1\""); + json.ShouldContain("\"id\":\"N1\""); + } +} diff --git a/tests/NATS.Server.Tests/Events/RemoteServerEventTests.cs b/tests/NATS.Server.Tests/Events/RemoteServerEventTests.cs index 661b355..3e3800f 100644 --- a/tests/NATS.Server.Tests/Events/RemoteServerEventTests.cs +++ b/tests/NATS.Server.Tests/Events/RemoteServerEventTests.cs @@ -129,7 +129,7 @@ public class RemoteServerEventTests string.Format(EventSubjects.RemoteServerShutdown, serverId) .ShouldBe($"$SYS.SERVER.{serverId}.REMOTE.SHUTDOWN"); string.Format(EventSubjects.LeafNodeConnected, serverId) - .ShouldBe($"$SYS.SERVER.{serverId}.LEAFNODE.CONNECT"); + .ShouldBe($"$SYS.ACCOUNT.{serverId}.LEAFNODE.CONNECT"); } // --- JSON serialization --- diff --git a/tests/NATS.Server.Tests/Gateways/GatewayConnectionDirectionParityBatch2Tests.cs b/tests/NATS.Server.Tests/Gateways/GatewayConnectionDirectionParityBatch2Tests.cs new file mode 100644 index 0000000..ddc3104 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayConnectionDirectionParityBatch2Tests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Gateways; + +public class GatewayConnectionDirectionParityBatch2Tests +{ + [Fact] + public async Task Gateway_manager_tracks_inbound_and_outbound_connection_sets() + { + var a = await StartServerAsync(MakeGatewayOptions("GW-A")); + var b = await StartServerAsync(MakeGatewayOptions("GW-B", a.Server.GatewayListen)); + + try + { + await WaitForCondition(() => + a.Server.NumInboundGateways() == 1 && + b.Server.NumOutboundGateways() == 1, + 10000); + + a.Server.NumInboundGateways().ShouldBe(1); + a.Server.NumOutboundGateways().ShouldBe(0); + b.Server.NumOutboundGateways().ShouldBe(1); + b.Server.NumInboundGateways().ShouldBe(0); + + var aManager = a.Server.GatewayManager; + var bManager = b.Server.GatewayManager; + aManager.ShouldNotBeNull(); + bManager.ShouldNotBeNull(); + + aManager!.HasInbound(b.Server.ServerId).ShouldBeTrue(); + bManager!.HasInbound(a.Server.ServerId).ShouldBeFalse(); + + bManager.GetOutboundGatewayConnection(a.Server.ServerId).ShouldNotBeNull(); + bManager.GetOutboundGatewayConnection("does-not-exist").ShouldBeNull(); + + aManager.GetInboundGatewayConnections().Count.ShouldBe(1); + aManager.GetOutboundGatewayConnections().Count.ShouldBe(0); + bManager.GetOutboundGatewayConnections().Count.ShouldBe(1); + bManager.GetInboundGatewayConnections().Count.ShouldBe(0); + } + finally + { + await DisposeServers(a, b); + } + } + + private static NatsOptions MakeGatewayOptions(string gatewayName, string? remote = null) + { + return new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = gatewayName, + Host = "127.0.0.1", + Port = 0, + Remotes = remote is null ? [] : [remote], + }, + }; + } + + private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync(NatsOptions opts) + { + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + return (server, cts); + } + + private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) + { + foreach (var (server, cts) in servers) + { + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs) + { + using var cts = new CancellationTokenSource(timeoutMs); + while (!cts.IsCancellationRequested) + { + if (predicate()) + return; + await Task.Yield(); + } + + throw new TimeoutException("Condition not met."); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayRemoteConfigParityBatch3Tests.cs b/tests/NATS.Server.Tests/Gateways/GatewayRemoteConfigParityBatch3Tests.cs new file mode 100644 index 0000000..1f77535 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayRemoteConfigParityBatch3Tests.cs @@ -0,0 +1,61 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Gateways; + +public class GatewayRemoteConfigParityBatch3Tests +{ + [Fact] + public void RemoteGatewayOptions_tracks_connection_attempts_and_implicit_flag() + { + var cfg = new RemoteGatewayOptions { Name = "GW-B", Implicit = true }; + + cfg.IsImplicit().ShouldBeTrue(); + cfg.GetConnAttempts().ShouldBe(0); + cfg.BumpConnAttempts().ShouldBe(1); + cfg.BumpConnAttempts().ShouldBe(2); + cfg.GetConnAttempts().ShouldBe(2); + cfg.ResetConnAttempts(); + cfg.GetConnAttempts().ShouldBe(0); + } + + [Fact] + public void RemoteGatewayOptions_add_and_update_urls_normalize_and_deduplicate() + { + var cfg = new RemoteGatewayOptions(); + cfg.AddUrls(["127.0.0.1:7222", "nats://127.0.0.1:7222", "nats://127.0.0.1:7223"]); + + cfg.Urls.Count.ShouldBe(2); + cfg.Urls.ShouldContain("nats://127.0.0.1:7222"); + cfg.Urls.ShouldContain("nats://127.0.0.1:7223"); + + cfg.UpdateUrls( + configuredUrls: ["127.0.0.1:7333"], + discoveredUrls: ["nats://127.0.0.1:7334", "127.0.0.1:7333"]); + + cfg.Urls.Count.ShouldBe(2); + cfg.Urls.ShouldContain("nats://127.0.0.1:7333"); + cfg.Urls.ShouldContain("nats://127.0.0.1:7334"); + } + + [Fact] + public void RemoteGatewayOptions_save_tls_hostname_and_get_urls_helpers() + { + var cfg = new RemoteGatewayOptions + { + Urls = ["127.0.0.1:7444", "nats://localhost:7445"], + }; + + cfg.SaveTlsHostname("nats://gw.example.net:7522"); + cfg.TlsName.ShouldBe("gw.example.net"); + + var urlStrings = cfg.GetUrlsAsStrings(); + urlStrings.Count.ShouldBe(2); + urlStrings.ShouldContain("nats://127.0.0.1:7444"); + urlStrings.ShouldContain("nats://localhost:7445"); + + var urls = cfg.GetUrls(); + urls.Count.ShouldBe(2); + urls.ShouldContain(u => u.Authority == "127.0.0.1:7444"); + urls.ShouldContain(u => u.Authority == "localhost:7445"); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayReplyAndConfigParityBatch1Tests.cs b/tests/NATS.Server.Tests/Gateways/GatewayReplyAndConfigParityBatch1Tests.cs new file mode 100644 index 0000000..9a0cd77 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayReplyAndConfigParityBatch1Tests.cs @@ -0,0 +1,104 @@ +using NATS.Server.Configuration; +using NATS.Server.Gateways; + +namespace NATS.Server.Tests.Gateways; + +public class GatewayReplyAndConfigParityBatch1Tests +{ + [Fact] + public void HasGatewayReplyPrefix_accepts_new_and_old_prefixes() + { + ReplyMapper.HasGatewayReplyPrefix("_GR_.clusterA.reply").ShouldBeTrue(); + ReplyMapper.HasGatewayReplyPrefix("$GR.clusterA.reply").ShouldBeTrue(); + ReplyMapper.HasGatewayReplyPrefix("_INBOX.reply").ShouldBeFalse(); + } + + [Fact] + public void IsGatewayRoutedSubject_reports_old_prefix_flag() + { + ReplyMapper.IsGatewayRoutedSubject("_GR_.C1.r", out var newPrefixOldFlag).ShouldBeTrue(); + newPrefixOldFlag.ShouldBeFalse(); + + ReplyMapper.IsGatewayRoutedSubject("$GR.C1.r", out var oldPrefixOldFlag).ShouldBeTrue(); + oldPrefixOldFlag.ShouldBeTrue(); + } + + [Fact] + public void TryRestoreGatewayReply_handles_old_prefix_format() + { + ReplyMapper.TryRestoreGatewayReply("$GR.clusterA.reply.one", out var restored).ShouldBeTrue(); + restored.ShouldBe("reply.one"); + } + + [Fact] + public void GatewayHash_helpers_are_deterministic_and_expected_length() + { + var hash1 = ReplyMapper.ComputeGatewayHash("east"); + var hash2 = ReplyMapper.ComputeGatewayHash("east"); + var oldHash1 = ReplyMapper.ComputeOldGatewayHash("east"); + var oldHash2 = ReplyMapper.ComputeOldGatewayHash("east"); + + hash1.ShouldBe(hash2); + oldHash1.ShouldBe(oldHash2); + hash1.Length.ShouldBe(ReplyMapper.GatewayHashLen); + oldHash1.Length.ShouldBe(ReplyMapper.OldGatewayHashLen); + } + + [Fact] + public void Legacy_prefixed_reply_extracts_cluster_and_not_hash() + { + ReplyMapper.TryExtractClusterId("$GR.clusterB.inbox.reply", out var cluster).ShouldBeTrue(); + cluster.ShouldBe("clusterB"); + ReplyMapper.TryExtractHash("$GR.clusterB.inbox.reply", out _).ShouldBeFalse(); + } + + [Fact] + public void RemoteGatewayOptions_clone_deep_copies_url_list() + { + var original = new RemoteGatewayOptions + { + Name = "gw-west", + Urls = ["nats://127.0.0.1:7522", "nats://127.0.0.1:7523"], + }; + + var clone = original.Clone(); + clone.ShouldNotBeSameAs(original); + clone.Name.ShouldBe(original.Name); + clone.Urls.ShouldBe(original.Urls); + + clone.Urls.Add("nats://127.0.0.1:7524"); + original.Urls.Count.ShouldBe(2); + } + + [Fact] + public void ValidateGatewayOptions_checks_required_fields() + { + GatewayManager.ValidateGatewayOptions(new GatewayOptions + { + Name = "gw", + Host = "127.0.0.1", + Port = 7222, + Remotes = ["127.0.0.1:8222"], + }, out var error).ShouldBeTrue(); + error.ShouldBeNull(); + + GatewayManager.ValidateGatewayOptions(new GatewayOptions { Port = 7222 }, out error).ShouldBeFalse(); + error.ShouldNotBeNull(); + error.ShouldContain("name"); + + GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = -1 }, out error).ShouldBeFalse(); + error.ShouldNotBeNull(); + error.ShouldContain("0-65535"); + + GatewayManager.ValidateGatewayOptions(new GatewayOptions { Name = "gw", Port = 7222, Remotes = [""] }, out error).ShouldBeFalse(); + error.ShouldNotBeNull(); + error.ShouldContain("cannot be empty"); + } + + [Fact] + public void Gateway_tls_warning_constant_is_present() + { + GatewayManager.GatewayTlsInsecureWarning.ShouldNotBeNullOrWhiteSpace(); + GatewayManager.GatewayTlsInsecureWarning.ShouldContain("TLS"); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayServerAccessorParityBatch4Tests.cs b/tests/NATS.Server.Tests/Gateways/GatewayServerAccessorParityBatch4Tests.cs new file mode 100644 index 0000000..a658bd7 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayServerAccessorParityBatch4Tests.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Gateways; + +public class GatewayServerAccessorParityBatch4Tests +{ + [Fact] + public void Gateway_address_url_and_name_accessors_reflect_gateway_options() + { + using var server = new NatsServer( + new NatsOptions + { + Gateway = new GatewayOptions + { + Name = "gw-a", + Host = "127.0.0.1", + Port = 7222, + }, + }, + NullLoggerFactory.Instance); + + server.GatewayAddr().ShouldBe("127.0.0.1:7222"); + server.GetGatewayURL().ShouldBe("127.0.0.1:7222"); + server.GetGatewayName().ShouldBe("gw-a"); + } + + [Fact] + public void Gateway_accessors_return_null_when_gateway_is_not_configured() + { + using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance); + + server.GatewayAddr().ShouldBeNull(); + server.GetGatewayURL().ShouldBeNull(); + server.GetGatewayName().ShouldBeNull(); + } +} diff --git a/tests/NATS.Server.Tests/Internal/InternalDsParityBatch2Tests.cs b/tests/NATS.Server.Tests/Internal/InternalDsParityBatch2Tests.cs new file mode 100644 index 0000000..08503fb --- /dev/null +++ b/tests/NATS.Server.Tests/Internal/InternalDsParityBatch2Tests.cs @@ -0,0 +1,91 @@ +using System.Text; +using NATS.Server.Internal.Avl; +using NATS.Server.Internal.Gsl; +using NATS.Server.Internal.SubjectTree; +using NATS.Server.Internal.SysMem; +using NATS.Server.Internal.TimeHashWheel; + +namespace NATS.Server.Tests.Internal; + +public class InternalDsParityBatch2Tests +{ + [Fact] + public void SubjectTreeHelper_IntersectGSL_matches_interested_subjects_once() + { + var tree = new SubjectTree(); + tree.Insert("foo.bar"u8.ToArray(), 1); + tree.Insert("foo.baz"u8.ToArray(), 2); + tree.Insert("other.subject"u8.ToArray(), 3); + + var sublist = new GenericSubjectList(); + sublist.Insert("foo.*", 1); + sublist.Insert("foo.bar", 2); // overlap should not duplicate callback for same subject + + var seen = new HashSet(StringComparer.Ordinal); + SubjectTreeHelper.IntersectGSL(tree, sublist, (subject, _) => + { + seen.Add(Encoding.UTF8.GetString(subject)); + }); + + seen.Count.ShouldBe(2); + seen.ShouldContain("foo.bar"); + seen.ShouldContain("foo.baz"); + } + + [Fact] + public void SubjectTree_Dump_outputs_node_and_leaf_structure() + { + var tree = new SubjectTree(); + tree.Insert("foo.bar"u8.ToArray(), 1); + tree.Insert("foo.baz"u8.ToArray(), 2); + + using var sw = new StringWriter(); + tree.Dump(sw); + var dump = sw.ToString(); + + dump.ShouldContain("NODE"); + dump.ShouldContain("LEAF"); + dump.ShouldContain("Prefix:"); + } + + [Fact] + public void SequenceSet_Encode_supports_destination_buffer_reuse() + { + var set = new SequenceSet(); + set.Insert(1); + set.Insert(65); + set.Insert(1024); + + var buffer = new byte[set.EncodeLength() + 32]; + var written = set.Encode(buffer); + written.ShouldBe(set.EncodeLength()); + + var (decoded, bytesRead) = SequenceSet.Decode(buffer.AsSpan(0, written)); + bytesRead.ShouldBe(written); + decoded.Exists(1).ShouldBeTrue(); + decoded.Exists(65).ShouldBeTrue(); + decoded.Exists(1024).ShouldBeTrue(); + } + + [Fact] + public void HashWheelEntry_struct_exposes_sequence_and_expiration() + { + var entry = new HashWheel.HashWheelEntry(42, 99); + entry.Sequence.ShouldBe((ulong)42); + entry.Expires.ShouldBe(99); + } + + [Fact] + public void SystemMemory_returns_positive_memory_value() + { + SystemMemory.Memory().ShouldBeGreaterThan(0); + } + + [Fact] + public void SimpleSubjectList_works_with_empty_marker_values() + { + var list = new SimpleSubjectList(); + list.Insert("foo.bar", new SimpleSublistValue()); + list.HasInterest("foo.bar").ShouldBeTrue(); + } +} diff --git a/tests/NATS.Server.Tests/Internal/InternalDsPeriodicSamplerParityTests.cs b/tests/NATS.Server.Tests/Internal/InternalDsPeriodicSamplerParityTests.cs new file mode 100644 index 0000000..8206b43 --- /dev/null +++ b/tests/NATS.Server.Tests/Internal/InternalDsPeriodicSamplerParityTests.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Internal; + +public class InternalDsPeriodicSamplerParityTests +{ + [Fact] + [SlopwatchSuppress("SW004", "Test must observe a real 1-second CPU sampling timer tick; wall-clock elapsed time is the observable under test")] + public async Task VarzHandler_uses_periodic_background_cpu_sampler() + { + var options = new NatsOptions { Host = "127.0.0.1", Port = 0 }; + var server = new NatsServer(options, NullLoggerFactory.Instance); + using var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + try + { + using var handler = new VarzHandler(server, options, NullLoggerFactory.Instance); + var field = typeof(VarzHandler).GetField("_lastCpuSampleTime", BindingFlags.NonPublic | BindingFlags.Instance); + field.ShouldNotBeNull(); + + var before = (DateTime)field!.GetValue(handler)!; + await Task.Delay(TimeSpan.FromMilliseconds(1200)); + var after = (DateTime)field.GetValue(handler)!; + + after.ShouldBeGreaterThan(before); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } +} diff --git a/tests/NATS.Server.Tests/JetStream/Api/JetStreamApiLimitsParityBatch1Tests.cs b/tests/NATS.Server.Tests/JetStream/Api/JetStreamApiLimitsParityBatch1Tests.cs new file mode 100644 index 0000000..fa1a4bc --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Api/JetStreamApiLimitsParityBatch1Tests.cs @@ -0,0 +1,108 @@ +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Validation; +using NATS.Server.JetStream; + +namespace NATS.Server.Tests.JetStream.Api; + +public class JetStreamApiLimitsParityBatch1Tests +{ + [Fact] + public void Constants_match_go_reference_values() + { + JetStreamApiLimits.JSMaxDescriptionLen.ShouldBe(4_096); + JetStreamApiLimits.JSMaxMetadataLen.ShouldBe(128 * 1024); + JetStreamApiLimits.JSMaxNameLen.ShouldBe(255); + JetStreamApiLimits.JSDefaultRequestQueueLimit.ShouldBe(10_000); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("ORDERS", true)] + [InlineData("ORD ERS", false)] + [InlineData("ORDERS.*", false)] + [InlineData("ORDERS.>", false)] + public void IsValidName_enforces_expected_rules(string? name, bool expected) + { + JetStreamConfigValidator.IsValidName(name).ShouldBe(expected); + } + + [Fact] + public void Stream_create_rejects_name_over_max_length() + { + var manager = new StreamManager(); + var response = manager.CreateOrUpdate(new StreamConfig + { + Name = new string('S', JetStreamApiLimits.JSMaxNameLen + 1), + Subjects = ["a"], + }); + + response.Error.ShouldNotBeNull(); + response.Error!.Description.ShouldBe("invalid stream name"); + } + + [Fact] + public void Stream_create_rejects_description_over_max_bytes() + { + var manager = new StreamManager(); + var response = manager.CreateOrUpdate(new StreamConfig + { + Name = "LIMITDESC", + Subjects = ["a"], + Description = new string('d', JetStreamApiLimits.JSMaxDescriptionLen + 1), + }); + + response.Error.ShouldNotBeNull(); + response.Error!.Description.ShouldBe("stream description is too long"); + } + + [Fact] + public void Stream_create_rejects_metadata_over_max_bytes() + { + var manager = new StreamManager(); + var response = manager.CreateOrUpdate(new StreamConfig + { + Name = "LIMITMETA", + Subjects = ["a"], + Metadata = new Dictionary + { + ["k"] = new string('m', JetStreamApiLimits.JSMaxMetadataLen), + }, + }); + + response.Error.ShouldNotBeNull(); + response.Error!.Description.ShouldBe("stream metadata exceeds maximum size"); + } + + [Fact] + public void Consumer_create_rejects_durable_name_over_max_length() + { + var manager = new ConsumerManager(); + var response = manager.CreateOrUpdate("S", new ConsumerConfig + { + DurableName = new string('C', JetStreamApiLimits.JSMaxNameLen + 1), + }); + + response.Error.ShouldNotBeNull(); + response.Error!.Description.ShouldBe("invalid durable name"); + } + + [Fact] + public void Consumer_create_rejects_metadata_over_max_bytes() + { + var manager = new ConsumerManager(); + var response = manager.CreateOrUpdate("S", new ConsumerConfig + { + DurableName = "C1", + Metadata = new Dictionary + { + ["k"] = new string('m', JetStreamApiLimits.JSMaxMetadataLen), + }, + }); + + response.Error.ShouldNotBeNull(); + response.Error!.Description.ShouldBe("consumer metadata exceeds maximum size"); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamConfigModelParityBatch3Tests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamConfigModelParityBatch3Tests.cs new file mode 100644 index 0000000..027f40a --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamConfigModelParityBatch3Tests.cs @@ -0,0 +1,129 @@ +using NATS.Server.Configuration; +using NATS.Server.JetStream; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamConfigModelParityBatch3Tests +{ + [Fact] + public void JetStreamOptions_exposes_extended_go_config_fields() + { + var opts = new JetStreamOptions + { + SyncInterval = TimeSpan.FromSeconds(2), + SyncAlways = true, + CompressOk = true, + UniqueTag = "az", + Strict = true, + MaxAckPending = 123, + MemoryMaxStreamBytes = 1111, + StoreMaxStreamBytes = 2222, + MaxBytesRequired = true, + }; + + opts.SyncInterval.ShouldBe(TimeSpan.FromSeconds(2)); + opts.SyncAlways.ShouldBeTrue(); + opts.CompressOk.ShouldBeTrue(); + opts.UniqueTag.ShouldBe("az"); + opts.Strict.ShouldBeTrue(); + opts.MaxAckPending.ShouldBe(123); + opts.MemoryMaxStreamBytes.ShouldBe(1111); + opts.StoreMaxStreamBytes.ShouldBe(2222); + opts.MaxBytesRequired.ShouldBeTrue(); + } + + [Fact] + public void ConfigProcessor_parses_extended_jetstream_fields() + { + var opts = ConfigProcessor.ProcessConfig(""" + jetstream { + store_dir: '/tmp/js' + max_mem_store: 1024 + max_file_store: 2048 + domain: 'D' + sync_interval: '2s' + sync_always: true + compress_ok: true + unique_tag: 'az' + strict: true + max_ack_pending: 42 + memory_max_stream_bytes: 10000 + store_max_stream_bytes: 20000 + max_bytes_required: true + } + """); + + opts.JetStream.ShouldNotBeNull(); + var js = opts.JetStream!; + js.StoreDir.ShouldBe("/tmp/js"); + js.MaxMemoryStore.ShouldBe(1024); + js.MaxFileStore.ShouldBe(2048); + js.Domain.ShouldBe("D"); + js.SyncInterval.ShouldBe(TimeSpan.FromSeconds(2)); + js.SyncAlways.ShouldBeTrue(); + js.CompressOk.ShouldBeTrue(); + js.UniqueTag.ShouldBe("az"); + js.Strict.ShouldBeTrue(); + js.MaxAckPending.ShouldBe(42); + js.MemoryMaxStreamBytes.ShouldBe(10000); + js.StoreMaxStreamBytes.ShouldBe(20000); + js.MaxBytesRequired.ShouldBeTrue(); + } + + [Fact] + public void JetStream_struct_models_cover_stats_limits_and_tiers() + { + var api = new JetStreamApiStats + { + Total = 10, + Errors = 2, + Inflight = 1, + }; + + var tier = new JetStreamTier + { + Name = "R3", + Memory = 1000, + Store = 2000, + Streams = 3, + Consumers = 5, + }; + + var limits = new JetStreamAccountLimits + { + MaxMemory = 10_000, + MaxStore = 20_000, + MaxStreams = 7, + MaxConsumers = 9, + MaxAckPending = 25, + MemoryMaxStreamBytes = 1_000, + StoreMaxStreamBytes = 2_000, + MaxBytesRequired = true, + Tiers = new Dictionary + { + ["R3"] = tier, + }, + }; + + var stats = new JetStreamStats + { + Memory = 123, + Store = 456, + ReservedMemory = 11, + ReservedStore = 22, + Accounts = 2, + HaAssets = 4, + Api = api, + }; + + limits.Tiers["R3"].Name.ShouldBe("R3"); + limits.MaxAckPending.ShouldBe(25); + limits.MaxBytesRequired.ShouldBeTrue(); + + stats.Memory.ShouldBe(123); + stats.Store.ShouldBe(456); + stats.Api.Total.ShouldBe(10UL); + stats.Api.Errors.ShouldBe(2UL); + stats.Api.Inflight.ShouldBe(1); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamServerConfigParityBatch2Tests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamServerConfigParityBatch2Tests.cs new file mode 100644 index 0000000..e49b4dc --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamServerConfigParityBatch2Tests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamServerConfigParityBatch2Tests +{ + [Fact] + public void JetStream_constants_match_go_default_values() + { + JetStreamOptions.JetStreamStoreDir.ShouldBe("jetstream"); + JetStreamOptions.JetStreamMaxStoreDefault.ShouldBe(1L << 40); + JetStreamOptions.JetStreamMaxMemDefault.ShouldBe(256L * 1024 * 1024); + } + + [Fact] + public async Task Server_exposes_jetstream_enabled_config_and_store_dir() + { + var options = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + JetStream = new JetStreamOptions + { + StoreDir = Path.Combine(Path.GetTempPath(), "js-" + Guid.NewGuid().ToString("N")), + MaxMemoryStore = 10_000, + MaxFileStore = 20_000, + MaxStreams = 7, + MaxConsumers = 11, + Domain = "D1", + }, + }; + + var server = new NatsServer(options, NullLoggerFactory.Instance); + using var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + try + { + server.JetStreamEnabled().ShouldBeTrue(); + server.StoreDir().ShouldBe(options.JetStream.StoreDir); + + var cfg = server.JetStreamConfig(); + cfg.ShouldNotBeNull(); + cfg!.StoreDir.ShouldBe(options.JetStream.StoreDir); + cfg.MaxMemoryStore.ShouldBe(options.JetStream.MaxMemoryStore); + cfg.MaxFileStore.ShouldBe(options.JetStream.MaxFileStore); + cfg.MaxStreams.ShouldBe(options.JetStream.MaxStreams); + cfg.MaxConsumers.ShouldBe(options.JetStream.MaxConsumers); + cfg.Domain.ShouldBe(options.JetStream.Domain); + + cfg.MaxStreams = 99; + server.JetStreamConfig()!.MaxStreams.ShouldBe(options.JetStream.MaxStreams); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + if (Directory.Exists(options.JetStream.StoreDir)) + Directory.Delete(options.JetStream.StoreDir, recursive: true); + } + } + + [Fact] + public void Server_returns_empty_or_null_jetstream_config_when_disabled() + { + var server = new NatsServer(new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + }, NullLoggerFactory.Instance); + + server.JetStreamEnabled().ShouldBeFalse(); + server.JetStreamConfig().ShouldBeNull(); + server.StoreDir().ShouldBe(string.Empty); + } +} diff --git a/tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs b/tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs index 6f162fc..81e6bf1 100644 --- a/tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs +++ b/tests/NATS.Server.Tests/JetStreamMonitoringParityTests.cs @@ -34,7 +34,7 @@ public class JetStreamMonitoringParityTests jsz.Consumers.ShouldBeGreaterThanOrEqualTo(1); jsz.ApiTotal.ShouldBeGreaterThanOrEqualTo((ulong)0); - var varz = await new VarzHandler(server, options).HandleVarzAsync(); + var varz = await new VarzHandler(server, options, NullLoggerFactory.Instance).HandleVarzAsync(); varz.JetStream.Stats.Api.Total.ShouldBeGreaterThanOrEqualTo((ulong)0); await cts.CancelAsync(); diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafConnectionAndRemoteConfigParityBatch1Tests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionAndRemoteConfigParityBatch1Tests.cs new file mode 100644 index 0000000..9b9d0cd --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionAndRemoteConfigParityBatch1Tests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.LeafNodes; + +namespace NATS.Server.Tests.LeafNodes; + +public class LeafConnectionAndRemoteConfigParityBatch1Tests +{ + [Fact] + public async Task LeafConnection_role_helpers_reflect_connection_flags() + { + await using var connection = CreateConnection(); + + connection.IsSolicitedLeafNode().ShouldBeFalse(); + connection.IsSpokeLeafNode().ShouldBeFalse(); + connection.IsHubLeafNode().ShouldBeTrue(); + connection.IsIsolatedLeafNode().ShouldBeFalse(); + + connection.IsSolicited = true; + connection.IsSpoke = true; + connection.Isolated = true; + + connection.IsSolicitedLeafNode().ShouldBeTrue(); + connection.IsSpokeLeafNode().ShouldBeTrue(); + connection.IsHubLeafNode().ShouldBeFalse(); + connection.IsIsolatedLeafNode().ShouldBeTrue(); + } + + [Fact] + public void RemoteLeafOptions_pick_next_url_round_robins() + { + var remote = new RemoteLeafOptions + { + Urls = + [ + "nats://127.0.0.1:7422", + "nats://127.0.0.1:7423", + "nats://127.0.0.1:7424", + ], + }; + + remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422"); + remote.GetCurrentUrl().ShouldBe("nats://127.0.0.1:7422"); + remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7423"); + remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7424"); + remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422"); + } + + [Fact] + public void RemoteLeafOptions_pick_next_url_without_entries_throws() + { + var remote = new RemoteLeafOptions(); + Should.Throw(() => remote.PickNextUrl()); + } + + [Fact] + public void RemoteLeafOptions_saves_tls_hostname_and_user_password_from_url() + { + var remote = new RemoteLeafOptions(); + + remote.SaveTlsHostname("nats://leaf.example.com:7422"); + remote.TlsName.ShouldBe("leaf.example.com"); + + remote.SaveUserPassword("nats://demo:secret@leaf.example.com:7422"); + remote.Username.ShouldBe("demo"); + remote.Password.ShouldBe("secret"); + } + + [Fact] + public void RemoteLeafOptions_connect_delay_round_trips() + { + var remote = new RemoteLeafOptions(); + remote.GetConnectDelay().ShouldBe(TimeSpan.Zero); + + remote.SetConnectDelay(TimeSpan.FromSeconds(30)); + remote.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void RemoteLeafNodeStillValid_checks_configured_and_disabled_remotes() + { + var manager = new LeafNodeManager( + new LeafNodeOptions + { + Host = "127.0.0.1", + Port = 0, + Remotes = ["127.0.0.1:7422"], + RemoteLeaves = + [ + new RemoteLeafOptions + { + Urls = ["nats://127.0.0.1:7423"], + }, + ], + }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeTrue(); + manager.RemoteLeafNodeStillValid("nats://127.0.0.1:7423").ShouldBeTrue(); + manager.RemoteLeafNodeStillValid("127.0.0.1:7999").ShouldBeFalse(); + + manager.DisableLeafConnect("127.0.0.1:7422"); + manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeFalse(); + } + + [Fact] + public void LeafNode_delay_constants_match_go_defaults() + { + LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected.ShouldBe(TimeSpan.FromSeconds(30)); + LeafNodeManager.LeafNodeReconnectAfterPermViolation.ShouldBe(TimeSpan.FromSeconds(30)); + LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame.ShouldBe(TimeSpan.FromSeconds(30)); + LeafNodeManager.LeafNodeWaitBeforeClose.ShouldBe(TimeSpan.FromSeconds(5)); + } + + private static LeafConnection CreateConnection() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(endpoint); + + var server = listener.AcceptSocket(); + server.Dispose(); + listener.Stop(); + + return new LeafConnection(client); + } +} diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch3Tests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch3Tests.cs new file mode 100644 index 0000000..9f7f72f --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch3Tests.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using NATS.Server.Configuration; +using NATS.Server.LeafNodes; + +namespace NATS.Server.Tests.LeafNodes; + +public class LeafConnectionParityBatch3Tests +{ + [Fact] + public async Task SendLeafConnect_writes_connect_json_payload_with_expected_fields() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port); + using var leafSocket = await listener.AcceptSocketAsync(); + await using var leaf = new LeafConnection(leafSocket); + + var info = new LeafConnectInfo + { + Jwt = "jwt-token", + Nkey = "nkey", + Sig = "sig", + Hub = true, + Cluster = "C1", + Headers = true, + JetStream = true, + Compression = "s2_auto", + RemoteAccount = "A", + Proto = 1, + }; + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await leaf.SendLeafConnectAsync(info, timeout.Token); + + var line = await ReadLineAsync(remoteSocket, timeout.Token); + line.ShouldStartWith("CONNECT "); + + var payload = line["CONNECT ".Length..]; + using var json = JsonDocument.Parse(payload); + var root = json.RootElement; + + root.GetProperty("jwt").GetString().ShouldBe("jwt-token"); + root.GetProperty("nkey").GetString().ShouldBe("nkey"); + root.GetProperty("sig").GetString().ShouldBe("sig"); + root.GetProperty("hub").GetBoolean().ShouldBeTrue(); + root.GetProperty("cluster").GetString().ShouldBe("C1"); + root.GetProperty("headers").GetBoolean().ShouldBeTrue(); + root.GetProperty("jetstream").GetBoolean().ShouldBeTrue(); + root.GetProperty("compression").GetString().ShouldBe("s2_auto"); + root.GetProperty("remote_account").GetString().ShouldBe("A"); + root.GetProperty("proto").GetInt32().ShouldBe(1); + } + + [Fact] + public async Task RemoteCluster_returns_cluster_from_leaf_handshake_attributes() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port); + using var leafSocket = await listener.AcceptSocketAsync(); + await using var leaf = new LeafConnection(leafSocket); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL"); + await WriteLineAsync(remoteSocket, "LEAF REMOTE cluster=HUB-A domain=JS-A", timeout.Token); + + await handshakeTask; + + leaf.RemoteId.ShouldBe("REMOTE"); + leaf.RemoteCluster().ShouldBe("HUB-A"); + leaf.RemoteJetStreamDomain.ShouldBe("JS-A"); + } + + [Fact] + public async Task SetLeafConnectDelayIfSoliciting_sets_delay_only_for_solicited_connections() + { + await using var solicited = await CreateConnectionAsync(); + solicited.IsSolicited = true; + solicited.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30)); + solicited.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30)); + + await using var inbound = await CreateConnectionAsync(); + inbound.IsSolicited = false; + inbound.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30)); + inbound.GetConnectDelay().ShouldBe(TimeSpan.Zero); + } + + [Fact] + public async Task LeafProcessErr_maps_known_errors_to_reconnect_delays_on_solicited_connections() + { + await using var leaf = await CreateConnectionAsync(); + leaf.IsSolicited = true; + + leaf.LeafProcessErr("Permissions Violation for Subscription to foo"); + leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation); + + leaf.LeafProcessErr("Loop detected"); + leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected); + + leaf.LeafProcessErr("Cluster name is same"); + leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame); + } + + [Fact] + public async Task LeafSubPermViolation_and_LeafPermViolation_set_permission_delay() + { + await using var leaf = await CreateConnectionAsync(); + leaf.IsSolicited = true; + + leaf.LeafSubPermViolation("subj.A"); + leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation); + + leaf.SetLeafConnectDelayIfSoliciting(TimeSpan.Zero); + leaf.LeafPermViolation(pub: true, subj: "subj.B"); + leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation); + } + + [Fact] + public async Task CancelMigrateTimer_stops_pending_timer_callback() + { + var remote = new RemoteLeafOptions(); + using var signal = new SemaphoreSlim(0, 1); + + remote.StartMigrateTimer(_ => signal.Release(), TimeSpan.FromMilliseconds(120)); + remote.CancelMigrateTimer(); + + // The timer was disposed before its 120 ms deadline. We wait 300 ms via WaitAsync; + // if the callback somehow fires it will release the semaphore and WaitAsync returns + // true, which the assertion catches. No Task.Delay required. + var fired = await signal.WaitAsync(TimeSpan.FromMilliseconds(300)); + fired.ShouldBeFalse(); + } + + private static async Task CreateConnectionAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(IPAddress.Loopback, endpoint.Port); + + var server = await listener.AcceptSocketAsync(); + listener.Stop(); + client.Dispose(); + + return new LeafConnection(server); + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) + throw new IOException("Connection closed while reading line"); + if (single[0] == (byte)'\n') + break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); +} diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch4Tests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch4Tests.cs new file mode 100644 index 0000000..bc09979 --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafConnectionParityBatch4Tests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using NATS.Server.LeafNodes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.LeafNodes; + +public class LeafConnectionParityBatch4Tests +{ + [Fact] + public async Task SendLsPlus_with_queue_weight_writes_weighted_frame() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port); + using var leafSocket = await listener.AcceptSocketAsync(); + await using var leaf = new LeafConnection(leafSocket); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL"); + await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token); + await handshakeTask; + + await leaf.SendLsPlusAsync("$G", "jobs.>", "workers", queueWeight: 3, timeout.Token); + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS+ $G jobs.> workers 3"); + } + + [Fact] + public async Task ReadLoop_parses_queue_weight_from_ls_plus() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port); + using var leafSocket = await listener.AcceptSocketAsync(); + await using var leaf = new LeafConnection(leafSocket); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL"); + await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token); + await handshakeTask; + + var received = new List(); + leaf.RemoteSubscriptionReceived = sub => + { + received.Add(sub); + return Task.CompletedTask; + }; + leaf.StartLoop(timeout.Token); + + await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 7", timeout.Token); + await WaitForAsync(() => received.Count >= 1, timeout.Token); + + received[0].Subject.ShouldBe("jobs.>"); + received[0].Queue.ShouldBe("workers"); + received[0].QueueWeight.ShouldBe(7); + } + + [Fact] + public async Task ReadLoop_defaults_invalid_queue_weight_to_one() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port); + using var leafSocket = await listener.AcceptSocketAsync(); + await using var leaf = new LeafConnection(leafSocket); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL"); + await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token); + await handshakeTask; + + var received = new List(); + leaf.RemoteSubscriptionReceived = sub => + { + received.Add(sub); + return Task.CompletedTask; + }; + leaf.StartLoop(timeout.Token); + + await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 0", timeout.Token); + await WaitForAsync(() => received.Count >= 1, timeout.Token); + + received[0].QueueWeight.ShouldBe(1); + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) + throw new IOException("Connection closed while reading line"); + if (single[0] == (byte)'\n') + break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); + + private static async Task WaitForAsync(Func predicate, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + if (predicate()) + return; + + await Task.Yield(); + } + + throw new TimeoutException("Timed out waiting for condition."); + } +} diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafNodeManagerParityBatch5Tests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafNodeManagerParityBatch5Tests.cs new file mode 100644 index 0000000..481a31e --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafNodeManagerParityBatch5Tests.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.LeafNodes; + +namespace NATS.Server.Tests.LeafNodes; + +public class LeafNodeManagerParityBatch5Tests +{ + [Fact] + [SlopwatchSuppress("SW004", "Delay verifies a blocked subject is NOT forwarded; absence of a frame cannot be observed via synchronization primitives")] + public async Task PropagateLocalSubscription_enforces_spoke_subscribe_permissions_and_keeps_queue_weight() + { + await using var ctx = await CreateManagerWithInboundConnectionAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1"); + conn.ShouldNotBeNull(); + conn!.IsSpoke = true; + + var sync = ctx.Manager.SendPermsAndAccountInfo( + ctx.ConnectionId, + "$G", + pubAllow: null, + subAllow: ["allowed.>"]); + sync.Found.ShouldBeTrue(); + sync.PermsSynced.ShouldBeTrue(); + + ctx.Manager.PropagateLocalSubscription("$G", "blocked.data", null); + ctx.Manager.PropagateLocalSubscription("$G", "allowed.data", "workers", queueWeight: 4); + + // Only the allowed subject should appear on the wire; the blocked one is filtered synchronously. + var line = await ReadLineAsync(ctx.RemoteSocket, timeout.Token); + line.ShouldBe("LS+ $G allowed.data workers 4"); + } + + [Fact] + public async Task PropagateLocalSubscription_allows_loop_and_gateway_reply_prefixes_for_spoke() + { + await using var ctx = await CreateManagerWithInboundConnectionAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1"); + conn.ShouldNotBeNull(); + conn!.IsSpoke = true; + + ctx.Manager.SendPermsAndAccountInfo( + ctx.ConnectionId, + "$G", + pubAllow: null, + subAllow: ["allowed.>"]); + + ctx.Manager.PropagateLocalSubscription("$G", "$LDS.HUB.loop", null); + (await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G $LDS.HUB.loop"); + + ctx.Manager.PropagateLocalSubscription("$G", "_GR_.A.reply", null); + (await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G _GR_.A.reply"); + } + + private sealed class ManagerContext : IAsyncDisposable + { + private readonly CancellationTokenSource _cts; + + public ManagerContext(LeafNodeManager manager, string connectionId, Socket remoteSocket, CancellationTokenSource cts) + { + Manager = manager; + ConnectionId = connectionId; + RemoteSocket = remoteSocket; + _cts = cts; + } + + public LeafNodeManager Manager { get; } + public string ConnectionId { get; } + public Socket RemoteSocket { get; } + + public async ValueTask DisposeAsync() + { + RemoteSocket.Close(); + await Manager.DisposeAsync(); + _cts.Dispose(); + } + } + + private static async Task CreateManagerWithInboundConnectionAsync() + { + var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 }; + var manager = new LeafNodeManager( + options, + new ServerStats(), + "HUB", + _ => { }, + _ => { }, + NullLogger.Instance); + + var cts = new CancellationTokenSource(); + await manager.StartAsync(cts.Token); + + var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var registered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnConnectionRegistered = id => registered.TrySetResult(id); + timeout.Token.Register(() => registered.TrySetCanceled(timeout.Token)); + + await WriteLineAsync(remoteSocket, "LEAF SPOKE1", timeout.Token); + (await ReadLineAsync(remoteSocket, timeout.Token)).ShouldStartWith("LEAF "); + + var connectionId = await registered.Task; + return new ManagerContext(manager, connectionId, remoteSocket, cts); + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) + throw new IOException("Connection closed while reading line"); + if (single[0] == (byte)'\n') + break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); +} diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafSubKeyParityBatch2Tests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafSubKeyParityBatch2Tests.cs new file mode 100644 index 0000000..5a8759c --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafSubKeyParityBatch2Tests.cs @@ -0,0 +1,42 @@ +using NATS.Server.LeafNodes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.LeafNodes; + +public class LeafSubKeyParityBatch2Tests +{ + [Fact] + public void Constants_match_go_leaf_key_and_delay_values() + { + LeafSubKey.KeyRoutedSub.ShouldBe("R"); + LeafSubKey.KeyRoutedSubByte.ShouldBe((byte)'R'); + LeafSubKey.KeyRoutedLeafSub.ShouldBe("L"); + LeafSubKey.KeyRoutedLeafSubByte.ShouldBe((byte)'L'); + LeafSubKey.SharedSysAccDelay.ShouldBe(TimeSpan.FromMilliseconds(250)); + LeafSubKey.ConnectProcessTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void KeyFromSub_matches_go_subject_and_queue_shape() + { + LeafSubKey.KeyFromSub(NewSub("foo")).ShouldBe("foo"); + LeafSubKey.KeyFromSub(NewSub("foo", "bar")).ShouldBe("foo bar"); + } + + [Fact] + public void KeyFromSubWithOrigin_matches_go_routed_and_leaf_routed_shapes() + { + LeafSubKey.KeyFromSubWithOrigin(NewSub("foo")).ShouldBe("R foo"); + LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar")).ShouldBe("R foo bar"); + LeafSubKey.KeyFromSubWithOrigin(NewSub("foo"), "leaf").ShouldBe("L foo leaf"); + LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar"), "leaf").ShouldBe("L foo bar leaf"); + } + + private static Subscription NewSub(string subject, string? queue = null) + => new() + { + Subject = subject, + Queue = queue, + Sid = Guid.NewGuid().ToString("N"), + }; +} diff --git a/tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs b/tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs index 34042d5..b62f2fb 100644 --- a/tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs +++ b/tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text; namespace NATS.Server.Tests; @@ -8,14 +9,46 @@ public class ConnzParityFieldTests public async Task Connz_includes_identity_tls_and_proxy_parity_fields() { await using var fx = await MonitoringParityFixture.StartAsync(); - await fx.ConnectClientAsync("u", "orders.created"); + var jwt = BuildJwt("UISSUER", ["team:core", "tier:gold"]); + await fx.ConnectClientAsync("proxy:edge", "orders.created", jwt); - var connz = fx.GetConnz("?subs=detail"); + var connz = fx.GetConnz("?subs=detail&auth=true"); connz.Conns.ShouldNotBeEmpty(); + var conn = connz.Conns.Single(c => c.AuthorizedUser == "proxy:edge"); + conn.Proxy.ShouldNotBeNull(); + conn.Proxy.Key.ShouldBe("edge"); + conn.Jwt.ShouldBe(jwt); + conn.IssuerKey.ShouldBe("UISSUER"); + conn.Tags.ShouldContain("team:core"); var json = JsonSerializer.Serialize(connz); json.ShouldContain("tls_peer_cert_subject"); - json.ShouldContain("jwt_issuer_key"); + json.ShouldContain("tls_peer_certs"); + json.ShouldContain("issuer_key"); + json.ShouldContain("\"tags\""); json.ShouldContain("proxy"); + json.ShouldNotContain("jwt_issuer_key"); + } + + private static string BuildJwt(string issuer, string[] tags) + { + static string B64Url(string json) + { + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + var header = B64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}"); + var payload = B64Url(JsonSerializer.Serialize(new + { + iss = issuer, + nats = new + { + tags, + }, + })); + return $"{header}.{payload}.eA"; } } diff --git a/tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs b/tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs index d811654..69c8bd1 100644 --- a/tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs +++ b/tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs @@ -46,6 +46,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable [ new User { Username = "u", Password = "p", Account = "A" }, new User { Username = "v", Password = "p", Account = "B" }, + new User { Username = "proxy:edge", Password = "p", Account = "A" }, ], }; @@ -56,7 +57,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable return new MonitoringParityFixture(server, options, cts); } - public async Task ConnectClientAsync(string username, string? subscribeSubject) + public async Task ConnectClientAsync(string username, string? subscribeSubject, string? jwt = null) { var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, _server.Port); @@ -65,7 +66,10 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable var stream = client.GetStream(); await ReadLineAsync(stream); // INFO - var connect = $"CONNECT {{\"user\":\"{username}\",\"pass\":\"p\"}}\r\n"; + var connectPayload = string.IsNullOrWhiteSpace(jwt) + ? $"{{\"user\":\"{username}\",\"pass\":\"p\"}}" + : $"{{\"user\":\"{username}\",\"pass\":\"p\",\"jwt\":\"{jwt}\"}}"; + var connect = $"CONNECT {connectPayload}\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(connect)); if (!string.IsNullOrEmpty(subscribeSubject)) await stream.WriteAsync(Encoding.ASCII.GetBytes($"SUB {subscribeSubject} sid-{username}\r\n")); @@ -82,7 +86,7 @@ internal sealed class MonitoringParityFixture : IAsyncDisposable public async Task GetVarzAsync() { - using var handler = new VarzHandler(_server, _options); + using var handler = new VarzHandler(_server, _options, NullLoggerFactory.Instance); return await handler.HandleVarzAsync(); } diff --git a/tests/NATS.Server.Tests/Monitoring/MonitoringHealthAndSortParityBatch1Tests.cs b/tests/NATS.Server.Tests/Monitoring/MonitoringHealthAndSortParityBatch1Tests.cs new file mode 100644 index 0000000..ab24e07 --- /dev/null +++ b/tests/NATS.Server.Tests/Monitoring/MonitoringHealthAndSortParityBatch1Tests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Monitoring; + +public class MonitoringHealthAndSortParityBatch1Tests +{ + [Fact] + public void SortOpt_IsValid_matches_defined_values() + { + foreach (var value in Enum.GetValues()) + value.IsValid().ShouldBeTrue(); + + ((SortOpt)999).IsValid().ShouldBeFalse(); + } + + [Fact] + public void HealthStatus_ok_serializes_with_go_shape_fields() + { + var json = JsonSerializer.Serialize(HealthStatus.Ok()); + + json.ShouldContain("\"status\":\"ok\""); + json.ShouldContain("\"status_code\":200"); + json.ShouldContain("\"errors\":[]"); + } + + [Fact] + public void HealthzError_serializes_enum_as_string() + { + var json = JsonSerializer.Serialize(new HealthzError + { + Type = HealthzErrorType.JetStream, + Error = "jetstream unavailable", + }); + + json.ShouldContain("\"type\":\"JetStream\""); + json.ShouldContain("\"error\":\"jetstream unavailable\""); + } +} diff --git a/tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs b/tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs new file mode 100644 index 0000000..6599228 --- /dev/null +++ b/tests/NATS.Server.Tests/Monitoring/TlsPeerCertParityTests.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Monitoring; + +public class TlsPeerCertParityTests +{ + [Fact] + public void TLSPeerCert_serializes_go_shape_fields() + { + var cert = new TLSPeerCert + { + Subject = "CN=peer", + SubjectPKISha256 = new string('a', 64), + CertSha256 = new string('b', 64), + }; + + var json = JsonSerializer.Serialize(cert); + + json.ShouldContain("\"subject\":\"CN=peer\""); + json.ShouldContain("\"subject_pk_sha256\":"); + json.ShouldContain("\"cert_sha256\":"); + } + + [Fact] + public void TlsPeerCertMapper_produces_subject_and_sha256_values_from_certificate() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=peer", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + + var mapped = TlsPeerCertMapper.FromCertificate(cert); + + mapped.Length.ShouldBe(1); + mapped[0].Subject.ShouldContain("CN=peer"); + mapped[0].SubjectPKISha256.Length.ShouldBe(64); + mapped[0].CertSha256.Length.ShouldBe(64); + } + + [Fact] + public void ConnInfo_json_includes_tls_peer_certs_array() + { + var info = new ConnInfo + { + Cid = 1, + TlsPeerCertSubject = "CN=peer", + TlsPeerCerts = + [ + new TLSPeerCert + { + Subject = "CN=peer", + SubjectPKISha256 = new string('c', 64), + CertSha256 = new string('d', 64), + }, + ], + }; + + var json = JsonSerializer.Serialize(info); + json.ShouldContain("\"tls_peer_certs\":["); + json.ShouldContain("\"subject_pk_sha256\":"); + json.ShouldContain("\"cert_sha256\":"); + } +} diff --git a/tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs b/tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs new file mode 100644 index 0000000..c1a8762 --- /dev/null +++ b/tests/NATS.Server.Tests/Mqtt/MqttModelParityBatch3Tests.cs @@ -0,0 +1,92 @@ +using NATS.Server.Mqtt; + +namespace NATS.Server.Tests.Mqtt; + +public class MqttModelParityBatch3Tests +{ + [Fact] + public void Mqtt_helper_models_cover_go_core_shapes() + { + var jsa = new MqttJsa + { + AccountName = "A", + ReplyPrefix = "$MQTT.JSA.A", + Domain = "D1", + }; + + var pubMsg = new MqttJsPubMsg + { + Subject = "$MQTT.msgs.s1", + Payload = new byte[] { 1, 2, 3 }, + ReplyTo = "$MQTT.JSA.A.reply", + }; + + var delete = new MqttRetMsgDel + { + Topic = "devices/x", + Sequence = 123, + }; + + var persisted = new MqttPersistedSession + { + ClientId = "c1", + LastPacketId = 7, + MaxAckPending = 1024, + }; + + var retainedRef = new MqttRetainedMessageRef + { + StreamSequence = 88, + Subject = "$MQTT.rmsgs.devices/x", + }; + + var sub = new MqttSub + { + Filter = "devices/+", + Qos = 1, + JsDur = "DUR-c1", + Prm = true, + Reserved = false, + }; + + var filter = new MqttFilter + { + Filter = "devices/#", + Qos = 1, + TopicToken = "devices", + }; + + var parsedHeader = new MqttParsedPublishNatsHeader + { + Subject = "devices/x", + Mapped = "devices.y", + IsPublish = true, + IsPubRel = false, + }; + + jsa.AccountName.ShouldBe("A"); + pubMsg.Payload.ShouldBe(new byte[] { 1, 2, 3 }); + delete.Sequence.ShouldBe(123UL); + persisted.MaxAckPending.ShouldBe(1024); + retainedRef.StreamSequence.ShouldBe(88UL); + sub.JsDur.ShouldBe("DUR-c1"); + filter.TopicToken.ShouldBe("devices"); + parsedHeader.IsPublish.ShouldBeTrue(); + } + + [Fact] + public void Retained_message_model_includes_origin_flags_and_source_fields() + { + var msg = new MqttRetainedMessage( + Topic: "devices/x", + Payload: new byte[] { 0x41, 0x42 }, + Origin: "origin-a", + Flags: 0b_0000_0011, + Source: "src-a"); + + msg.Topic.ShouldBe("devices/x"); + msg.Origin.ShouldBe("origin-a"); + msg.Flags.ShouldBe((byte)0b_0000_0011); + msg.Source.ShouldBe("src-a"); + } +} diff --git a/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch1Tests.cs b/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch1Tests.cs new file mode 100644 index 0000000..2376ffa --- /dev/null +++ b/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch1Tests.cs @@ -0,0 +1,74 @@ +using System.Text; +using NATS.Server.Mqtt; + +namespace NATS.Server.Tests.Mqtt; + +public class MqttProtocolConstantsParityBatch1Tests +{ + [Fact] + public void Constants_match_mqtt_go_reference_values() + { + MqttProtocolConstants.SubscribeFlags.ShouldBe((byte)0x02); + + MqttProtocolConstants.ConnAckAccepted.ShouldBe((byte)0x00); + MqttProtocolConstants.ConnAckUnacceptableProtocolVersion.ShouldBe((byte)0x01); + MqttProtocolConstants.ConnAckIdentifierRejected.ShouldBe((byte)0x02); + MqttProtocolConstants.ConnAckServerUnavailable.ShouldBe((byte)0x03); + MqttProtocolConstants.ConnAckBadUserNameOrPassword.ShouldBe((byte)0x04); + MqttProtocolConstants.ConnAckNotAuthorized.ShouldBe((byte)0x05); + + MqttProtocolConstants.MaxPayloadSize.ShouldBe(268_435_455); + MqttProtocolConstants.DefaultAckWait.ShouldBe(TimeSpan.FromSeconds(30)); + MqttProtocolConstants.MaxAckTotalLimit.ShouldBe(0xFFFF); + } + + [Fact] + public void ParseSubscribe_accepts_required_subscribe_flags() + { + var payload = CreateSubscribePayload(packetId: 7, ("sport/tennis/#", 1)); + + var info = MqttBinaryDecoder.ParseSubscribe(payload, flags: MqttProtocolConstants.SubscribeFlags); + + info.PacketId.ShouldBe((ushort)7); + info.Filters.Count.ShouldBe(1); + info.Filters[0].TopicFilter.ShouldBe("sport/tennis/#"); + info.Filters[0].QoS.ShouldBe((byte)1); + } + + [Fact] + public void ParseSubscribe_rejects_invalid_subscribe_flags() + { + var payload = CreateSubscribePayload(packetId: 5, ("topic/one", 0)); + + var ex = Should.Throw(() => MqttBinaryDecoder.ParseSubscribe(payload, flags: 0x00)); + ex.Message.ShouldContain("invalid fixed-header flags"); + } + + private static byte[] CreateSubscribePayload(ushort packetId, params (string Topic, byte Qos)[] filters) + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + WriteUInt16BigEndian(writer, packetId); + foreach (var (topic, qos) in filters) + { + WriteString(writer, topic); + writer.Write(qos); + } + + return ms.ToArray(); + } + + private static void WriteString(BinaryWriter writer, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + WriteUInt16BigEndian(writer, (ushort)bytes.Length); + writer.Write(bytes); + } + + private static void WriteUInt16BigEndian(BinaryWriter writer, ushort value) + { + writer.Write((byte)(value >> 8)); + writer.Write((byte)(value & 0xFF)); + } +} diff --git a/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch2Tests.cs b/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch2Tests.cs new file mode 100644 index 0000000..cb109fa --- /dev/null +++ b/tests/NATS.Server.Tests/Mqtt/MqttProtocolConstantsParityBatch2Tests.cs @@ -0,0 +1,91 @@ +using System.Text; +using NATS.Server.Mqtt; + +namespace NATS.Server.Tests.Mqtt; + +public class MqttProtocolConstantsParityBatch2Tests +{ + [Fact] + public void Extended_constants_match_go_reference_values() + { + MqttProtocolConstants.MultiLevelSidSuffix.ShouldBe(" fwc"); + MqttProtocolConstants.Prefix.ShouldBe("$MQTT."); + MqttProtocolConstants.SubPrefix.ShouldBe("$MQTT.sub."); + + MqttProtocolConstants.StreamName.ShouldBe("$MQTT_msgs"); + MqttProtocolConstants.StreamSubjectPrefix.ShouldBe("$MQTT.msgs."); + MqttProtocolConstants.RetainedMsgsStreamName.ShouldBe("$MQTT_rmsgs"); + MqttProtocolConstants.RetainedMsgsStreamSubject.ShouldBe("$MQTT.rmsgs."); + MqttProtocolConstants.SessStreamName.ShouldBe("$MQTT_sess"); + MqttProtocolConstants.SessStreamSubjectPrefix.ShouldBe("$MQTT.sess."); + MqttProtocolConstants.SessionsStreamNamePrefix.ShouldBe("$MQTT_sess_"); + MqttProtocolConstants.QoS2IncomingMsgsStreamName.ShouldBe("$MQTT_qos2in"); + MqttProtocolConstants.QoS2IncomingMsgsStreamSubjectPrefix.ShouldBe("$MQTT.qos2.in."); + + MqttProtocolConstants.OutStreamName.ShouldBe("$MQTT_out"); + MqttProtocolConstants.OutSubjectPrefix.ShouldBe("$MQTT.out."); + MqttProtocolConstants.PubRelSubjectPrefix.ShouldBe("$MQTT.out.pubrel."); + MqttProtocolConstants.PubRelDeliverySubjectPrefix.ShouldBe("$MQTT.deliver.pubrel."); + MqttProtocolConstants.PubRelConsumerDurablePrefix.ShouldBe("$MQTT_PUBREL_"); + + MqttProtocolConstants.JSARepliesPrefix.ShouldBe("$MQTT.JSA."); + MqttProtocolConstants.JSAIdTokenPos.ShouldBe(3); + MqttProtocolConstants.JSATokenPos.ShouldBe(4); + MqttProtocolConstants.JSAClientIDPos.ShouldBe(5); + MqttProtocolConstants.JSAStreamCreate.ShouldBe("SC"); + MqttProtocolConstants.JSAStreamUpdate.ShouldBe("SU"); + MqttProtocolConstants.JSAStreamLookup.ShouldBe("SL"); + MqttProtocolConstants.JSAStreamDel.ShouldBe("SD"); + MqttProtocolConstants.JSAConsumerCreate.ShouldBe("CC"); + MqttProtocolConstants.JSAConsumerLookup.ShouldBe("CL"); + MqttProtocolConstants.JSAConsumerDel.ShouldBe("CD"); + MqttProtocolConstants.JSAMsgStore.ShouldBe("MS"); + MqttProtocolConstants.JSAMsgLoad.ShouldBe("ML"); + MqttProtocolConstants.JSAMsgDelete.ShouldBe("MD"); + MqttProtocolConstants.JSASessPersist.ShouldBe("SP"); + MqttProtocolConstants.JSARetainedMsgDel.ShouldBe("RD"); + MqttProtocolConstants.JSAStreamNames.ShouldBe("SN"); + + MqttProtocolConstants.SparkbNBirth.ShouldBe("NBIRTH"); + MqttProtocolConstants.SparkbDBirth.ShouldBe("DBIRTH"); + MqttProtocolConstants.SparkbNDeath.ShouldBe("NDEATH"); + MqttProtocolConstants.SparkbDDeath.ShouldBe("DDEATH"); + Encoding.ASCII.GetString(MqttProtocolConstants.SparkbNamespaceTopicPrefix).ShouldBe("spBv1.0/"); + Encoding.ASCII.GetString(MqttProtocolConstants.SparkbCertificatesTopicPrefix).ShouldBe("$sparkplug/certificates/"); + + MqttProtocolConstants.NatsHeaderPublish.ShouldBe("Nmqtt-Pub"); + MqttProtocolConstants.NatsRetainedMessageTopic.ShouldBe("Nmqtt-RTopic"); + MqttProtocolConstants.NatsRetainedMessageOrigin.ShouldBe("Nmqtt-ROrigin"); + MqttProtocolConstants.NatsRetainedMessageFlags.ShouldBe("Nmqtt-RFlags"); + MqttProtocolConstants.NatsRetainedMessageSource.ShouldBe("Nmqtt-RSource"); + MqttProtocolConstants.NatsPubRelHeader.ShouldBe("Nmqtt-PubRel"); + MqttProtocolConstants.NatsHeaderSubject.ShouldBe("Nmqtt-Subject"); + MqttProtocolConstants.NatsHeaderMapped.ShouldBe("Nmqtt-Mapped"); + } + + [Fact] + public void WriteString_writes_length_prefixed_utf8() + { + var encoded = MqttPacketWriter.WriteString("MQTT"); + + encoded.Length.ShouldBe(6); + encoded[0].ShouldBe((byte)0x00); + encoded[1].ShouldBe((byte)0x04); + Encoding.UTF8.GetString(encoded.AsSpan(2)).ShouldBe("MQTT"); + } + + [Fact] + public void WriteBytes_writes_length_prefixed_binary_payload() + { + var encoded = MqttPacketWriter.WriteBytes(new byte[] { 0xAA, 0xBB, 0xCC }); + + encoded.ShouldBe(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }); + } + + [Fact] + public void WriteBytes_rejects_payload_larger_than_uint16() + { + var payload = new byte[ushort.MaxValue + 1]; + Should.Throw(() => MqttPacketWriter.WriteBytes(payload)); + } +} diff --git a/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs b/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs index fe0e2c7..41afc82 100644 --- a/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs +++ b/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs @@ -12,6 +12,7 @@ using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; +using NATS.Server.Auth; using NATS.Server.Monitoring; using NATS.Server.Protocol; @@ -717,6 +718,121 @@ public class MsgTraceGoParityTests : IAsyncLifetime await cts.CancelAsync(); } + /// + /// Username/password authorization violations are tracked in closed connections. + /// Go: TestClosedUPAuthorizationViolation (closed_conns_test.go:187) + /// + [Fact] + public async Task ClosedConns_up_auth_violation_close_reason_tracked() + { + // Go: TestClosedUPAuthorizationViolation (closed_conns_test.go:187) + var port = GetFreePort(); + using var cts = new CancellationTokenSource(); + using var server = new NatsServer( + new NatsOptions + { + Port = port, + Users = + [ + new User { Username = "my_user", Password = "my_secret" }, + ], + }, + NullLoggerFactory.Instance); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + // No credentials + using (var conn1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + await conn1.ConnectAsync(IPAddress.Loopback, port); + await ReadUntilAsync(conn1, "\r\n"); // INFO + await conn1.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray()); + await ReadUntilAsync(conn1, "-ERR", 2000); + } + + // Wrong password + using (var conn2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + await conn2.ConnectAsync(IPAddress.Loopback, port); + await ReadUntilAsync(conn2, "\r\n"); // INFO + await conn2.SendAsync( + "CONNECT {\"verbose\":false,\"user\":\"my_user\",\"pass\":\"wrong_pass\"}\r\nPING\r\n"u8.ToArray()); + await ReadUntilAsync(conn2, "-ERR", 2000); + } + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline) + { + if (server.GetClosedClients().Count >= 2) + break; + await Task.Delay(10); + } + + var conns = server.GetClosedClients().ToList(); + conns.Count.ShouldBeGreaterThanOrEqualTo(2); + conns.Take(2).All(c => c.Reason.Contains("Authorization Violation", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue(); + + await cts.CancelAsync(); + } + + /// + /// TLS handshake failures are tracked in closed connections with the TLS reason. + /// Go: TestClosedTLSHandshake (closed_conns_test.go:247) + /// + [Fact] + public async Task ClosedConns_tls_handshake_close_reason_tracked() + { + // Go: TestClosedTLSHandshake (closed_conns_test.go:247) + var (certPath, keyPath) = TlsHelperTests.GenerateTestCertFiles(); + try + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(); + using var server = new NatsServer( + new NatsOptions + { + Port = port, + TlsCert = certPath, + TlsKey = keyPath, + TlsVerify = true, + AllowNonTls = false, + }, + NullLoggerFactory.Instance); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + // Plain TCP client against TLS-required port should fail handshake. + using (var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + await conn.ConnectAsync(IPAddress.Loopback, port); + await ReadUntilAsync(conn, "\r\n"); // INFO + await conn.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray()); + _ = await ReadUntilAsync(conn, "-ERR", 1000); + } + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline) + { + if (server.GetClosedClients().Any()) + break; + await Task.Delay(10); + } + + var conns = server.GetClosedClients().ToList(); + conns.Count.ShouldBeGreaterThan(0); + conns.Any(c => c.Reason.Contains("TLS Handshake Error", StringComparison.OrdinalIgnoreCase)) + .ShouldBeTrue(); + + await cts.CancelAsync(); + } + finally + { + File.Delete(certPath); + File.Delete(keyPath); + } + } + // ─── ClosedState enum (closed_conns_test.go — checkReason) ─────────────── /// diff --git a/tests/NATS.Server.Tests/NatsConfLexerTests.cs b/tests/NATS.Server.Tests/NatsConfLexerTests.cs index d664fc1..e67378c 100644 --- a/tests/NATS.Server.Tests/NatsConfLexerTests.cs +++ b/tests/NATS.Server.Tests/NatsConfLexerTests.cs @@ -83,6 +83,14 @@ public class NatsConfLexerTests keys[0].Value.ShouldBe("foo"); } + [Fact] + public void Lex_CommentBody_EmitsTextToken() + { + var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList(); + var commentBody = tokens.Single(t => t.Type == TokenType.Text); + commentBody.Value.ShouldBe(" this is a comment"); + } + [Fact] public void Lex_SlashComment_IsIgnored() { @@ -218,4 +226,13 @@ public class NatsConfLexerTests tokens[1].Type.ShouldBe(TokenType.String); tokens[1].Value.ShouldBe("3xyz"); } + + [Fact] + public void Lex_Unicode_surrogate_pairs_in_strings_are_preserved() + { + var tokens = NatsConfLexer.Tokenize("msg = \"rocket🚀\"\nport = 4222").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("rocket🚀"); + tokens[2].Line.ShouldBe(2); + } } diff --git a/tests/NATS.Server.Tests/ParserTests.cs b/tests/NATS.Server.Tests/ParserTests.cs index f910c56..ad8716a 100644 --- a/tests/NATS.Server.Tests/ParserTests.cs +++ b/tests/NATS.Server.Tests/ParserTests.cs @@ -275,4 +275,15 @@ public class ParserTests var ex = await ParseExpectingErrorAsync(input); ex.ShouldBeOfType(); } + + // Mirrors Go TestParsePubSizeOverflow: oversized decimal payload lengths + // must be rejected during PUB argument parsing. + // Reference: golang/nats-server/server/parser_test.go TestParsePubSizeOverflow + [Fact] + public async Task Parse_pub_size_overflow_fails() + { + var ex = await ParseExpectingErrorAsync("PUB foo 1234567890\r\n"); + ex.ShouldBeOfType(); + ex.Message.ShouldContain("Invalid payload size"); + } } diff --git a/tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs b/tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs new file mode 100644 index 0000000..44e1ca6 --- /dev/null +++ b/tests/NATS.Server.Tests/Protocol/ProtoWireParityTests.cs @@ -0,0 +1,87 @@ +using NATS.Server.Protocol; + +namespace NATS.Server.Tests.ProtocolParity; + +public class ProtoWireParityTests +{ + [Fact] + public void ScanField_reads_tag_and_value_size_for_length_delimited_field() + { + // field=2, type=2, len=3, bytes=abc + byte[] bytes = [0x12, 0x03, (byte)'a', (byte)'b', (byte)'c']; + + var (number, wireType, size) = ProtoWire.ScanField(bytes); + + number.ShouldBe(2); + wireType.ShouldBe(2); + size.ShouldBe(5); + } + + [Fact] + public void ScanTag_rejects_invalid_field_numbers() + { + var zeroFieldEx = Should.Throw(() => ProtoWire.ScanTag([0x00])); + zeroFieldEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber); + + var tooLargeTag = ProtoWire.EncodeVarint(((ulong)int.MaxValue + 1UL) << 3); + var tooLargeEx = Should.Throw(() => ProtoWire.ScanTag(tooLargeTag)); + tooLargeEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber); + } + + [Fact] + public void ScanFieldValue_supports_expected_wire_types() + { + ProtoWire.ScanFieldValue(5, [0, 0, 0, 0]).ShouldBe(4); + ProtoWire.ScanFieldValue(1, [0, 0, 0, 0, 0, 0, 0, 0]).ShouldBe(8); + ProtoWire.ScanFieldValue(0, [0x01]).ShouldBe(1); + } + + [Fact] + public void ScanFieldValue_rejects_unsupported_wire_type() + { + var ex = Should.Throw(() => ProtoWire.ScanFieldValue(3, [0x00])); + ex.Message.ShouldBe("unsupported type: 3"); + } + + [Fact] + public void ScanVarint_reports_insufficient_and_overflow_errors() + { + var insufficient = Should.Throw(() => ProtoWire.ScanVarint([0x80])); + insufficient.Message.ShouldBe(ProtoWire.ErrProtoInsufficient); + + byte[] overflow = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02]; + var tooLarge = Should.Throw(() => ProtoWire.ScanVarint(overflow)); + tooLarge.Message.ShouldBe(ProtoWire.ErrProtoOverflow); + } + + [Fact] + public void ScanBytes_reports_insufficient_when_length_prefix_exceeds_payload() + { + var ex = Should.Throw(() => ProtoWire.ScanBytes([0x04, 0x01, 0x02])); + ex.Message.ShouldBe(ProtoWire.ErrProtoInsufficient); + } + + [Fact] + public void EncodeVarint_round_trips_values_via_scan_varint() + { + ulong[] values = + [ + 0UL, + 1UL, + 127UL, + 128UL, + 16_383UL, + 16_384UL, + (1UL << 32) - 1, + ulong.MaxValue, + ]; + + foreach (var value in values) + { + var encoded = ProtoWire.EncodeVarint(value); + var (decoded, size) = ProtoWire.ScanVarint(encoded); + decoded.ShouldBe(value); + size.ShouldBe(encoded.Length); + } + } +} diff --git a/tests/NATS.Server.Tests/Protocol/ProtocolDefaultConstantsGapParityTests.cs b/tests/NATS.Server.Tests/Protocol/ProtocolDefaultConstantsGapParityTests.cs new file mode 100644 index 0000000..a21c89b --- /dev/null +++ b/tests/NATS.Server.Tests/Protocol/ProtocolDefaultConstantsGapParityTests.cs @@ -0,0 +1,75 @@ +using NATS.Server; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests.ProtocolParity; + +public class ProtocolDefaultConstantsGapParityTests +{ + [Fact] + public void NatsProtocol_exposes_core_default_constants() + { + NatsProtocol.DefaultHost.ShouldBe("0.0.0.0"); + NatsProtocol.DefaultHttpPort.ShouldBe(8222); + NatsProtocol.DefaultHttpBasePath.ShouldBe("/"); + NatsProtocol.DefaultRoutePoolSize.ShouldBe(3); + NatsProtocol.DefaultLeafNodePort.ShouldBe(7422); + NatsProtocol.MaxPayloadMaxSize.ShouldBe(8 * 1024 * 1024); + NatsProtocol.DefaultMaxConnections.ShouldBe(64 * 1024); + NatsProtocol.DefaultPingMaxOut.ShouldBe(2); + NatsProtocol.DefaultMaxClosedClients.ShouldBe(10_000); + NatsProtocol.DefaultConnectErrorReports.ShouldBe(3600); + NatsProtocol.DefaultReconnectErrorReports.ShouldBe(1); + NatsProtocol.DefaultAllowResponseMaxMsgs.ShouldBe(1); + NatsProtocol.DefaultServiceLatencySampling.ShouldBe(100); + NatsProtocol.DefaultSystemAccount.ShouldBe("$SYS"); + NatsProtocol.DefaultGlobalAccount.ShouldBe("$G"); + NatsProtocol.ProtoSnippetSize.ShouldBe(32); + NatsProtocol.MaxControlLineSnippetSize.ShouldBe(128); + } + + [Fact] + public void NatsProtocol_exposes_core_default_timespans() + { + NatsProtocol.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay.ShouldBe(TimeSpan.FromMilliseconds(50)); + NatsProtocol.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + NatsProtocol.DefaultRouteConnect.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultRouteConnectMax.ShouldBe(TimeSpan.FromSeconds(30)); + NatsProtocol.DefaultRouteReconnect.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultRouteDial.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultLeafNodeReconnect.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultLeafTlsTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + NatsProtocol.DefaultLeafNodeInfoWait.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultRttMeasurementInterval.ShouldBe(TimeSpan.FromHours(1)); + NatsProtocol.DefaultAllowResponseExpiration.ShouldBe(TimeSpan.FromMinutes(2)); + NatsProtocol.DefaultServiceExportResponseThreshold.ShouldBe(TimeSpan.FromMinutes(2)); + NatsProtocol.DefaultAccountFetchTimeout.ShouldBe(TimeSpan.FromMilliseconds(1900)); + NatsProtocol.DefaultPingInterval.ShouldBe(TimeSpan.FromMinutes(2)); + NatsProtocol.DefaultFlushDeadline.ShouldBe(TimeSpan.FromSeconds(10)); + NatsProtocol.AcceptMinSleep.ShouldBe(TimeSpan.FromMilliseconds(10)); + NatsProtocol.AcceptMaxSleep.ShouldBe(TimeSpan.FromSeconds(1)); + NatsProtocol.DefaultLameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2)); + NatsProtocol.DefaultLameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void NatsOptions_defaults_are_bound_to_protocol_defaults() + { + var options = new NatsOptions(); + + options.Host.ShouldBe(NatsProtocol.DefaultHost); + options.Port.ShouldBe(NatsProtocol.DefaultPort); + options.MaxConnections.ShouldBe(NatsProtocol.DefaultMaxConnections); + options.AuthTimeout.ShouldBe(NatsProtocol.AuthTimeout); + options.PingInterval.ShouldBe(NatsProtocol.DefaultPingInterval); + options.MaxPingsOut.ShouldBe(NatsProtocol.DefaultPingMaxOut); + options.WriteDeadline.ShouldBe(NatsProtocol.DefaultFlushDeadline); + options.TlsTimeout.ShouldBe(NatsProtocol.TlsTimeout); + options.TlsHandshakeFirstFallback.ShouldBe(NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay); + options.MaxClosedClients.ShouldBe(NatsProtocol.DefaultMaxClosedClients); + options.LameDuckDuration.ShouldBe(NatsProtocol.DefaultLameDuckDuration); + options.LameDuckGracePeriod.ShouldBe(NatsProtocol.DefaultLameDuckGracePeriod); + options.ConnectErrorReports.ShouldBe(NatsProtocol.DefaultConnectErrorReports); + options.ReconnectErrorReports.ShouldBe(NatsProtocol.DefaultReconnectErrorReports); + } +} diff --git a/tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs b/tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs new file mode 100644 index 0000000..63283ae --- /dev/null +++ b/tests/NATS.Server.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs @@ -0,0 +1,45 @@ +using System.Buffers; +using System.Text; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests.ProtocolParity; + +public class ProtocolParserSnippetGapParityTests +{ + [Fact] + public void ProtoSnippet_returns_empty_quotes_when_start_is_out_of_range() + { + var bytes = "PING"u8.ToArray(); + var snippet = NatsParser.ProtoSnippet(bytes.Length, 2, bytes); + snippet.ShouldBe("\"\""); + } + + [Fact] + public void ProtoSnippet_limits_to_requested_window_and_quotes_output() + { + var bytes = "ABCDEFGHIJ"u8.ToArray(); + var snippet = NatsParser.ProtoSnippet(2, 4, bytes); + snippet.ShouldBe("\"CDEF\""); + } + + [Fact] + public void ProtoSnippet_matches_go_behavior_when_max_runs_past_buffer_end() + { + var bytes = "ABCDE"u8.ToArray(); + var snippet = NatsParser.ProtoSnippet(0, 32, bytes); + snippet.ShouldBe("\"ABCD\""); + } + + [Fact] + public void Parse_exceeding_max_control_line_includes_snippet_context_in_error() + { + var parser = new NatsParser(); + var longSubject = new string('a', NatsProtocol.MaxControlLineSize + 1); + var input = Encoding.ASCII.GetBytes($"PUB {longSubject} 0\r\n\r\n"); + ReadOnlySequence buffer = new(input); + + var ex = Should.Throw(() => parser.TryParse(ref buffer, out _)); + ex.Message.ShouldContain("Maximum control line exceeded"); + ex.Message.ShouldContain("snip="); + } +} diff --git a/tests/NATS.Server.Tests/Raft/RaftConfigAndStateParityBatch1Tests.cs b/tests/NATS.Server.Tests/Raft/RaftConfigAndStateParityBatch1Tests.cs new file mode 100644 index 0000000..ba969cd --- /dev/null +++ b/tests/NATS.Server.Tests/Raft/RaftConfigAndStateParityBatch1Tests.cs @@ -0,0 +1,63 @@ +using NATS.Server.Raft; + +namespace NATS.Server.Tests.Raft; + +public class RaftConfigAndStateParityBatch1Tests +{ + [Fact] + public void RaftState_string_matches_go_labels() + { + RaftState.Follower.String().ShouldBe("Follower"); + RaftState.Leader.String().ShouldBe("Leader"); + RaftState.Candidate.String().ShouldBe("Candidate"); + RaftState.Closed.String().ShouldBe("Closed"); + } + + [Fact] + public void RaftConfig_exposes_go_shape_fields() + { + var cfg = new RaftConfig + { + Name = "META", + Store = new object(), + Log = new object(), + Track = true, + Observer = true, + Recovering = true, + ScaleUp = true, + }; + + cfg.Name.ShouldBe("META"); + cfg.Store.ShouldNotBeNull(); + cfg.Log.ShouldNotBeNull(); + cfg.Track.ShouldBeTrue(); + cfg.Observer.ShouldBeTrue(); + cfg.Recovering.ShouldBeTrue(); + cfg.ScaleUp.ShouldBeTrue(); + } + + [Fact] + public void RaftNode_group_defaults_to_id_when_not_supplied() + { + using var node = new RaftNode("N1"); + node.GroupName.ShouldBe("N1"); + } + + [Fact] + public void RaftNode_group_uses_explicit_value_when_supplied() + { + using var node = new RaftNode("N1", group: "G1"); + node.GroupName.ShouldBe("G1"); + } + + [Fact] + public void RaftNode_created_utc_is_set_on_construction() + { + var before = DateTime.UtcNow; + using var node = new RaftNode("N1"); + var after = DateTime.UtcNow; + + node.CreatedUtc.ShouldBeGreaterThanOrEqualTo(before); + node.CreatedUtc.ShouldBeLessThanOrEqualTo(after); + } +} diff --git a/tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs b/tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs new file mode 100644 index 0000000..1159f6a --- /dev/null +++ b/tests/NATS.Server.Tests/Raft/RaftNodeParityBatch2Tests.cs @@ -0,0 +1,149 @@ +using NATS.Server.Raft; + +namespace NATS.Server.Tests.Raft; + +public class RaftNodeParityBatch2Tests +{ + private static RaftNode ElectSingleNodeLeader() + { + var node = new RaftNode("n1"); + node.ConfigureCluster([node]); + node.StartElection(1); + node.IsLeader.ShouldBeTrue(); + return node; + } + + [Fact] + public void Leader_tracking_flags_update_on_election_and_heartbeat() + { + var node1 = new RaftNode("n1"); + var node2 = new RaftNode("n2"); + var node3 = new RaftNode("n3"); + + node1.ConfigureCluster([node1, node2, node3]); + node2.ConfigureCluster([node1, node2, node3]); + node3.ConfigureCluster([node1, node2, node3]); + + node1.StartElection(3); + node1.ReceiveVote(node2.GrantVote(node1.Term, node1.Id), 3); + + node1.IsLeader.ShouldBeTrue(); + node1.GroupLeader.ShouldBe("n1"); + node1.Leaderless.ShouldBeFalse(); + node1.HadPreviousLeader.ShouldBeTrue(); + node1.LeaderSince.ShouldNotBeNull(); + + node2.ReceiveHeartbeat(node1.Term, fromPeerId: "n1"); + node2.IsLeader.ShouldBeFalse(); + node2.GroupLeader.ShouldBe("n1"); + node2.Leaderless.ShouldBeFalse(); + node2.HadPreviousLeader.ShouldBeTrue(); + node2.LeaderSince.ShouldBeNull(); + } + + [Fact] + public void Stepdown_clears_group_leader_and_leader_since() + { + using var leader = ElectSingleNodeLeader(); + leader.GroupLeader.ShouldBe("n1"); + leader.LeaderSince.ShouldNotBeNull(); + + leader.RequestStepDown(); + + leader.Leaderless.ShouldBeTrue(); + leader.GroupLeader.ShouldBe(RaftNode.NoLeader); + leader.LeaderSince.ShouldBeNull(); + } + + [Fact] + public void Observer_mode_can_be_toggled() + { + using var node = new RaftNode("n1"); + node.IsObserver.ShouldBeFalse(); + + node.SetObserver(true); + node.IsObserver.ShouldBeTrue(); + + node.SetObserver(false); + node.IsObserver.ShouldBeFalse(); + } + + [Fact] + public void Cluster_size_adjustments_enforce_boot_and_leader_rules() + { + using var node = new RaftNode("n1"); + node.ClusterSize().ShouldBe(1); + + node.AdjustBootClusterSize(1).ShouldBeTrue(); + node.ClusterSize().ShouldBe(2); // floor is 2 + + node.ConfigureCluster([node]); + node.StartElection(1); + node.IsLeader.ShouldBeTrue(); + + node.AdjustClusterSize(5).ShouldBeTrue(); + node.ClusterSize().ShouldBe(5); + node.AdjustBootClusterSize(7).ShouldBeFalse(); + } + + [Fact] + public async Task Progress_size_and_applied_accessors_report_expected_values() + { + using var leader = ElectSingleNodeLeader(); + await leader.ProposeAsync("abc", CancellationToken.None); + await leader.ProposeAsync("de", CancellationToken.None); + + var progress = leader.Progress(); + progress.Index.ShouldBe(2); + progress.Commit.ShouldBe(2); + progress.Applied.ShouldBe(2); + + var size = leader.Size(); + size.Entries.ShouldBe(2); + size.Bytes.ShouldBe(5); + + var applied = leader.Applied(1); + applied.Entries.ShouldBe(1); + applied.Bytes.ShouldBe(3); + leader.ProcessedIndex.ShouldBe(1); + } + + [Fact] + public void Campaign_timeout_randomization_and_defaults_match_go_constants() + { + using var node = new RaftNode("n1"); + for (var i = 0; i < 20; i++) + { + var timeout = node.RandomizedCampaignTimeout(); + timeout.ShouldBeGreaterThanOrEqualTo(RaftNode.MinCampaignTimeoutDefault); + timeout.ShouldBeLessThan(RaftNode.MaxCampaignTimeoutDefault); + } + + RaftNode.HbIntervalDefault.ShouldBe(TimeSpan.FromSeconds(1)); + RaftNode.LostQuorumIntervalDefault.ShouldBe(TimeSpan.FromSeconds(10)); + RaftNode.ObserverModeIntervalDefault.ShouldBe(TimeSpan.FromHours(48)); + RaftNode.PeerRemoveTimeoutDefault.ShouldBe(TimeSpan.FromMinutes(5)); + RaftNode.NoLeader.ShouldBe(string.Empty); + RaftNode.NoVote.ShouldBe(string.Empty); + } + + [Fact] + public void Stop_wait_for_stop_and_delete_set_lifecycle_state() + { + var path = Path.Combine(Path.GetTempPath(), $"raft-node-delete-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + File.WriteAllText(Path.Combine(path, "marker.txt"), "x"); + + using var node = new RaftNode("n1", persistDirectory: path); + node.IsDeleted.ShouldBeFalse(); + + node.Stop(); + node.WaitForStop(); + node.IsDeleted.ShouldBeFalse(); + Directory.Exists(path).ShouldBeTrue(); + + node.Delete(); + node.IsDeleted.ShouldBeTrue(); + Directory.Exists(path).ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs b/tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs new file mode 100644 index 0000000..5d26d7d --- /dev/null +++ b/tests/NATS.Server.Tests/Raft/RaftParityBatch3Tests.cs @@ -0,0 +1,79 @@ +using NATS.Server.Raft; + +namespace NATS.Server.Tests.Raft; + +public class RaftParityBatch3Tests +{ + [Fact] + public async Task ProposeMulti_proposes_entries_in_order() + { + using var leader = ElectSingleNodeLeader(); + + var indexes = await leader.ProposeMultiAsync(["cmd-1", "cmd-2", "cmd-3"], CancellationToken.None); + + indexes.Count.ShouldBe(3); + indexes[0].ShouldBe(1); + indexes[1].ShouldBe(2); + indexes[2].ShouldBe(3); + leader.Log.Entries.Count.ShouldBe(3); + } + + [Fact] + public void PeerState_tracks_lag_and_current_flags() + { + var peer = new RaftPeerState + { + PeerId = "n2", + NextIndex = 10, + MatchIndex = 7, + LastContact = DateTime.UtcNow, + }; + + peer.RecalculateLag(); + peer.RefreshCurrent(TimeSpan.FromSeconds(1)); + + peer.Lag.ShouldBe(2); + peer.Current.ShouldBeTrue(); + + peer.LastContact = DateTime.UtcNow - TimeSpan.FromSeconds(5); + peer.RefreshCurrent(TimeSpan.FromSeconds(1)); + peer.Current.ShouldBeFalse(); + } + + [Fact] + public void CommittedEntry_contains_index_and_entries() + { + var entries = new[] + { + new RaftLogEntry(42, 3, "set x"), + new RaftLogEntry(43, 3, "set y"), + }; + + var committed = new CommittedEntry(43, entries); + + committed.Index.ShouldBe(43); + committed.Entries.Count.ShouldBe(2); + committed.Entries[0].Command.ShouldBe("set x"); + } + + [Fact] + public void RaftEntry_roundtrips_to_wire_shape() + { + var entry = new RaftEntry(RaftEntryType.AddPeer, new byte[] { 1, 2, 3 }); + + var wire = entry.ToWire(); + var decoded = RaftEntry.FromWire(wire); + + decoded.Type.ShouldBe(RaftEntryType.AddPeer); + decoded.Data.ShouldBe(new byte[] { 1, 2, 3 }); + } + + private static RaftNode ElectSingleNodeLeader() + { + var node = new RaftNode("n1"); + node.ConfigureCluster([node]); + node.StartElection(1); + node.IsLeader.ShouldBeTrue(); + return node; + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteBatchProtoParityBatch3Tests.cs b/tests/NATS.Server.Tests/Routes/RouteBatchProtoParityBatch3Tests.cs new file mode 100644 index 0000000..b39c17f --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteBatchProtoParityBatch3Tests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using NATS.Server.Routes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Routes; + +public class RouteBatchProtoParityBatch3Tests +{ + [Fact] + public async Task SendRouteSubProtosAsync_writes_batched_rs_plus_frames() + { + var (connection, peer) = CreateRoutePair(); + try + { + await connection.SendRouteSubProtosAsync( + [ + new RemoteSubscription("orders.*", null, "r1", Account: "A"), + new RemoteSubscription("orders.q", "workers", "r1", Account: "A", QueueWeight: 2), + ], + CancellationToken.None); + + var data = ReadFromPeer(peer); + data.ShouldContain("RS+ A orders.*"); + data.ShouldContain("RS+ A orders.q workers 2"); + } + finally + { + await connection.DisposeAsync(); + peer.Dispose(); + } + } + + [Fact] + public async Task SendRouteUnSubProtosAsync_writes_batched_rs_minus_frames() + { + var (connection, peer) = CreateRoutePair(); + try + { + await connection.SendRouteUnSubProtosAsync( + [ + new RemoteSubscription("orders.*", null, "r1", Account: "A"), + new RemoteSubscription("orders.q", "workers", "r1", Account: "A"), + ], + CancellationToken.None); + + var data = ReadFromPeer(peer); + data.ShouldContain("RS- A orders.*"); + data.ShouldContain("RS- A orders.q workers"); + } + finally + { + await connection.DisposeAsync(); + peer.Dispose(); + } + } + + [Fact] + public async Task SendRouteSubOrUnSubProtosAsync_skips_empty_lines_and_flushes_once() + { + var (connection, peer) = CreateRoutePair(); + try + { + await connection.SendRouteSubOrUnSubProtosAsync( + ["RS+ A foo.bar", "", " ", "RS- A foo.bar"], + CancellationToken.None); + + var data = ReadFromPeer(peer); + data.ShouldContain("RS+ A foo.bar"); + data.ShouldContain("RS- A foo.bar"); + data.ShouldNotContain("\r\n\r\n"); + } + finally + { + await connection.DisposeAsync(); + peer.Dispose(); + } + } + + private static (RouteConnection Route, Socket Peer) CreateRoutePair() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(endpoint); + + var server = listener.AcceptSocket(); + listener.Stop(); + + return (new RouteConnection(client), server); + } + + private static string ReadFromPeer(Socket peer) + { + peer.ReceiveTimeout = 2_000; + var buffer = new byte[4096]; + var read = peer.Receive(buffer); + return Encoding.ASCII.GetString(buffer, 0, read); + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteInfoBroadcastParityBatch4Tests.cs b/tests/NATS.Server.Tests/Routes/RouteInfoBroadcastParityBatch4Tests.cs new file mode 100644 index 0000000..e92cc24 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteInfoBroadcastParityBatch4Tests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server; + +namespace NATS.Server.Tests.Routes; + +public class RouteInfoBroadcastParityBatch4Tests +{ + [Fact] + public async Task UpdateServerINFOAndSendINFOToClients_broadcasts_INFO_to_connected_clients() + { + var port = GetFreePort(); + using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance); + using var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(IPAddress.Loopback, port); + + _ = await ReadLineAsync(socket, CancellationToken.None); // initial INFO + await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None); + _ = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None); + + server.UpdateServerINFOAndSendINFOToClients(); + + var info = await ReadLineAsync(socket, CancellationToken.None); + info.ShouldStartWith("INFO "); + + await server.ShutdownAsync(); + } + + private static async Task ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct) + { + var end = DateTime.UtcNow.AddSeconds(3); + var builder = new StringBuilder(); + while (DateTime.UtcNow < end) + { + var line = await ReadLineAsync(socket, ct); + if (line.Length == 0) + continue; + + builder.AppendLine(line); + if (builder.ToString().Contains(token, StringComparison.Ordinal)) + return builder.ToString(); + } + + return builder.ToString(); + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var buffer = new List(256); + var one = new byte[1]; + while (true) + { + var n = await socket.ReceiveAsync(one.AsMemory(0, 1), SocketFlags.None, ct); + if (n == 0) + break; + if (one[0] == '\n') + break; + if (one[0] != '\r') + buffer.Add(one[0]); + } + + return Encoding.ASCII.GetString([.. buffer]); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs b/tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs new file mode 100644 index 0000000..19e4198 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteParityHelpersBatch1Tests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.Protocol; +using NATS.Server.Routes; + +namespace NATS.Server.Tests.Routes; + +public class RouteParityHelpersBatch1Tests +{ + [Fact] + public void BuildConnectInfoJson_includes_connectinfo_compat_fields() + { + var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topo-v1"); + + json.ShouldContain("\"verbose\":false"); + json.ShouldContain("\"pedantic\":false"); + json.ShouldContain("\"echo\":false"); + json.ShouldContain("\"tls_required\":false"); + json.ShouldContain("\"headers\":true"); + json.ShouldContain("\"name\":\"S1\""); + json.ShouldContain("\"cluster\":\"\""); + json.ShouldContain("\"dynamic\":false"); + json.ShouldContain("\"lnoc\":false"); + json.ShouldContain("\"lnocu\":false"); + } + + [Fact] + public void HasThisRouteConfigured_matches_explicit_routes_with_scheme_normalization() + { + var manager = CreateManager(new ClusterOptions + { + Host = "127.0.0.1", + Port = 0, + Routes = ["127.0.0.1:7222"], + }); + + manager.HasThisRouteConfigured("127.0.0.1:7222").ShouldBeTrue(); + manager.HasThisRouteConfigured("nats-route://127.0.0.1:7222").ShouldBeTrue(); + manager.HasThisRouteConfigured("nats://127.0.0.1:7222").ShouldBeTrue(); + manager.HasThisRouteConfigured("127.0.0.1:7999").ShouldBeFalse(); + } + + [Fact] + public void ProcessImplicitRoute_skips_configured_routes_and_tracks_new_routes() + { + var manager = CreateManager(new ClusterOptions + { + Host = "127.0.0.1", + Port = 0, + Routes = ["127.0.0.1:7222"], + }); + + var serverInfo = new ServerInfo + { + ServerId = "S2", + ServerName = "S2", + Version = NatsProtocol.Version, + Host = "127.0.0.1", + Port = 7222, + ConnectUrls = ["127.0.0.1:7222", "nats-route://127.0.0.1:7444"], + }; + + manager.ProcessImplicitRoute(serverInfo); + + manager.DiscoveredRoutes.ShouldNotContain("127.0.0.1:7222"); + manager.DiscoveredRoutes.ShouldContain("nats-route://127.0.0.1:7444"); + } + + [Fact] + public void RouteStillValid_checks_configured_and_discovered_routes() + { + var manager = CreateManager(new ClusterOptions + { + Host = "127.0.0.1", + Port = 0, + Routes = ["127.0.0.1:7222"], + }); + + manager.RouteStillValid("nats://127.0.0.1:7222").ShouldBeTrue(); + manager.RouteStillValid("127.0.0.1:7555").ShouldBeFalse(); + + manager.ProcessImplicitRoute(new ServerInfo + { + ServerId = "S2", + ServerName = "S2", + Version = NatsProtocol.Version, + Host = "127.0.0.1", + Port = 7444, + ConnectUrls = ["127.0.0.1:7444"], + }); + + manager.RouteStillValid("nats-route://127.0.0.1:7444").ShouldBeTrue(); + } + + [Fact] + public async Task Solicited_route_helpers_upgrade_and_query_status() + { + var manager = CreateManager(); + await using var connection = MakeRouteConnection(); + + manager.RegisterRoute("S2", connection); + manager.HasSolicitedRoute("S2").ShouldBeFalse(); + + manager.UpgradeRouteToSolicited("S2").ShouldBeTrue(); + connection.IsSolicitedRoute().ShouldBeTrue(); + manager.HasSolicitedRoute("S2").ShouldBeTrue(); + manager.IsDuplicateServerName("S2").ShouldBeTrue(); + } + + [Fact] + public async Task RemoveRoute_cleans_hash_and_account_route_indexes() + { + var manager = CreateManager(); + var connection = MakeRouteConnection(); + + manager.RegisterRoute("S2", connection); + manager.RegisterRouteByHash("S2", connection); + manager.RegisterAccountRoute("A", connection); + + manager.HashedRouteCount.ShouldBe(1); + manager.DedicatedRouteCount.ShouldBe(1); + + manager.RemoveRoute("S2").ShouldBeTrue(); + manager.HashedRouteCount.ShouldBe(0); + manager.DedicatedRouteCount.ShouldBe(0); + } + + [Fact] + public async Task TryParseRemoteUnsub_parses_rs_minus_and_ls_minus() + { + RouteConnection.TryParseRemoteUnsub("RS- ACCT_A foo.bar q1", out var account1, out var subject1, out var queue1).ShouldBeTrue(); + account1.ShouldBe("ACCT_A"); + subject1.ShouldBe("foo.bar"); + queue1.ShouldBe("q1"); + + RouteConnection.TryParseRemoteUnsub("LS- ACCT_B foo.>", out var account2, out var subject2, out var queue2).ShouldBeTrue(); + account2.ShouldBe("ACCT_B"); + subject2.ShouldBe("foo.>"); + queue2.ShouldBeNull(); + + RouteConnection.TryParseRemoteUnsub("RS+ ACCT_A foo.bar", out _, out _, out _).ShouldBeFalse(); + } + + private static RouteManager CreateManager(ClusterOptions? options = null) + => new( + options ?? new ClusterOptions { Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + private static RouteConnection MakeRouteConnection() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect(endpoint); + + var server = listener.AcceptSocket(); + server.Dispose(); + listener.Stop(); + + return new RouteConnection(client); + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs b/tests/NATS.Server.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs new file mode 100644 index 0000000..9d7d36d --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Routes; + +public class RouteRemoteSubCleanupParityBatch2Tests +{ + [Fact] + public void Routed_sub_key_helpers_parse_account_and_queue_fields() + { + var key = SubList.BuildRoutedSubKey("R1", "A", "orders.*", "q1"); + + SubList.GetAccNameFromRoutedSubKey(key).ShouldBe("A"); + + var info = SubList.GetRoutedSubKeyInfo(key); + info.ShouldNotBeNull(); + info.Value.RouteId.ShouldBe("R1"); + info.Value.Account.ShouldBe("A"); + info.Value.Subject.ShouldBe("orders.*"); + info.Value.Queue.ShouldBe("q1"); + + SubList.GetRoutedSubKeyInfo("invalid").ShouldBeNull(); + SubList.GetAccNameFromRoutedSubKey("invalid").ShouldBeNull(); + } + + [Fact] + public void Remove_remote_subs_methods_only_remove_matching_route_or_account() + { + using var sl = new SubList(); + sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A")); + sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "B")); + sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "A")); + + sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue(); + sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue(); + + sl.RemoveRemoteSubsForAccount("r1", "A").ShouldBe(1); + sl.HasRemoteInterest("A", "orders.created").ShouldBeTrue(); // r2 still present + sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue(); + + sl.RemoveRemoteSubs("r2").ShouldBe(1); + sl.HasRemoteInterest("A", "orders.created").ShouldBeFalse(); + sl.HasRemoteInterest("B", "orders.created").ShouldBeTrue(); + } + + [Fact] + public async Task Route_disconnect_cleans_remote_interest_without_explicit_rs_minus() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = Guid.NewGuid().ToString("N"), + Host = "127.0.0.1", + Port = 0, + PoolSize = 1, + }, + }; + + var server = new NatsServer(opts, NullLoggerFactory.Instance); + using var serverCts = new CancellationTokenSource(); + _ = server.StartAsync(serverCts.Token); + await server.WaitForReadyAsync(); + + try + { + var cluster = server.ClusterListen!; + var sep = cluster.LastIndexOf(':'); + var host = cluster[..sep]; + var port = int.Parse(cluster[(sep + 1)..]); + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + await remote.ConnectAsync(IPAddress.Parse(host), port, timeout.Token); + + await WriteLineAsync(remote, "ROUTE REMOTE1", timeout.Token); + var response = await ReadLineAsync(remote, timeout.Token); + response.ShouldStartWith("ROUTE "); + + await WriteLineAsync(remote, "RS+ $G route.cleanup.test", timeout.Token); + await WaitForCondition(() => server.HasRemoteInterest("route.cleanup.test"), 5000); + + remote.Dispose(); + + await WaitForCondition(() => !server.HasRemoteInterest("route.cleanup.test"), 10000); + server.HasRemoteInterest("route.cleanup.test").ShouldBeFalse(); + } + finally + { + await serverCts.CancelAsync(); + server.Dispose(); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs) + { + using var timeout = new CancellationTokenSource(timeoutMs); + while (!timeout.IsCancellationRequested) + { + if (predicate()) + return; + + await Task.Yield(); + } + + throw new TimeoutException("Condition not met."); + } + + private static async Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + { + var data = Encoding.ASCII.GetBytes($"{line}\r\n"); + await socket.SendAsync(data, ct); + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var one = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(one, SocketFlags.None, ct); + if (read == 0) + throw new IOException("Socket closed while reading line"); + + if (one[0] == (byte)'\n') + break; + if (one[0] != (byte)'\r') + bytes.Add(one[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } +} diff --git a/tests/NATS.Server.Tests/Server/CoreServerClientAccessorsParityBatch2Tests.cs b/tests/NATS.Server.Tests/Server/CoreServerClientAccessorsParityBatch2Tests.cs new file mode 100644 index 0000000..fad0bf4 --- /dev/null +++ b/tests/NATS.Server.Tests/Server/CoreServerClientAccessorsParityBatch2Tests.cs @@ -0,0 +1,95 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Auth; +using NATS.Server.Protocol; + +namespace NATS.Server.Tests.Server; + +public class CoreServerClientAccessorsParityBatch2Tests +{ + [Fact] + public void Client_protocol_constants_match_go_values() + { + ClientProtocolVersion.ClientProtoZero.ShouldBe(0); + ClientProtocolVersion.ClientProtoInfo.ShouldBe(1); + + ((int)ClientConnectionType.NonClient).ShouldBe(0); + ((int)ClientConnectionType.Nats).ShouldBe(1); + ((int)ClientConnectionType.Mqtt).ShouldBe(2); + ((int)ClientConnectionType.WebSocket).ShouldBe(3); + } + + [Fact] + public void NatsClient_getters_and_client_type_behave_as_expected() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var stream = new MemoryStream(); + + var opts = new NatsOptions(); + var info = new ServerInfo + { + ServerId = "srv1", + ServerName = "srv", + Version = "1.0.0", + Host = "127.0.0.1", + Port = 4222, + }; + var auth = AuthService.Build(opts); + var nonce = new byte[] { 1, 2, 3 }; + var stats = new ServerStats(); + + using var client = new NatsClient( + id: 42, + stream: stream, + socket: socket, + options: opts, + serverInfo: info, + authService: auth, + nonce: nonce, + logger: NullLogger.Instance, + serverStats: stats); + + client.ClientType().ShouldBe(ClientConnectionType.Nats); + client.IsWebSocket = true; + client.ClientType().ShouldBe(ClientConnectionType.WebSocket); + client.IsWebSocket = false; + client.IsMqtt = true; + client.ClientType().ShouldBe(ClientConnectionType.Mqtt); + client.GetName().ShouldBe(string.Empty); + client.GetNonce().ShouldBe(nonce); + client.ToString().ShouldContain("cid=42"); + } + + [Fact] + public void NatsClient_client_type_non_client_when_kind_is_not_client() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var stream = new MemoryStream(); + + var opts = new NatsOptions(); + var info = new ServerInfo + { + ServerId = "srv1", + ServerName = "srv", + Version = "1.0.0", + Host = "127.0.0.1", + Port = 4222, + }; + var auth = AuthService.Build(opts); + var stats = new ServerStats(); + + using var routeClient = new NatsClient( + id: 7, + stream: stream, + socket: socket, + options: opts, + serverInfo: info, + authService: auth, + nonce: null, + logger: NullLogger.Instance, + serverStats: stats, + kind: ClientKind.Router); + + routeClient.ClientType().ShouldBe(ClientConnectionType.NonClient); + } +} diff --git a/tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs b/tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs new file mode 100644 index 0000000..8aaa0c9 --- /dev/null +++ b/tests/NATS.Server.Tests/Server/CoreServerGapParityTests.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests.Server; + +public class CoreServerGapParityTests +{ + [Fact] + public void ClientURL_uses_advertise_when_present() + { + using var server = new NatsServer( + new NatsOptions { Host = "0.0.0.0", Port = 4222, ClientAdvertise = "demo.example.net:4333" }, + NullLoggerFactory.Instance); + + server.ClientURL().ShouldBe("nats://demo.example.net:4333"); + } + + [Fact] + public void ClientURL_uses_loopback_for_wildcard_host() + { + using var server = new NatsServer( + new NatsOptions { Host = "0.0.0.0", Port = 4222 }, + NullLoggerFactory.Instance); + + server.ClientURL().ShouldBe("nats://127.0.0.1:4222"); + } + + [Fact] + public void WebsocketURL_uses_default_host_port_when_enabled() + { + using var server = new NatsServer( + new NatsOptions + { + WebSocket = new WebSocketOptions + { + Host = "0.0.0.0", + Port = 8080, + NoTls = true, + }, + }, + NullLoggerFactory.Instance); + + server.WebsocketURL().ShouldBe("ws://127.0.0.1:8080"); + } + + [Fact] + public void WebsocketURL_returns_null_when_disabled() + { + using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance); + + server.WebsocketURL().ShouldBeNull(); + } + + [Fact] + public void Account_count_methods_reflect_loaded_and_active_accounts() + { + using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance); + + server.NumLoadedAccounts().ShouldBe(2); // $G + $SYS + server.NumActiveAccounts().ShouldBe(0); + + var app = server.GetOrCreateAccount("APP"); + server.NumLoadedAccounts().ShouldBe(3); + + app.AddClient(42); + server.NumActiveAccounts().ShouldBe(1); + } + + [Fact] + public void Address_and_counter_methods_are_derived_from_options_and_stats() + { + using var server = new NatsServer( + new NatsOptions + { + Host = "127.0.0.1", + Port = 4222, + MonitorHost = "127.0.0.1", + MonitorPort = 8222, + ProfPort = 6060, + }, + NullLoggerFactory.Instance); + + server.Stats.Routes = 2; + server.Stats.Gateways = 1; + server.Stats.Leafs = 3; + + server.Addr().ShouldBe("127.0.0.1:4222"); + server.MonitorAddr().ShouldBe("127.0.0.1:8222"); + server.ProfilerAddr().ShouldBe("127.0.0.1:6060"); + server.NumRoutes().ShouldBe(2); + server.NumLeafNodes().ShouldBe(3); + server.NumRemotes().ShouldBe(6); + } + + [Fact] + public void ToString_includes_identity_and_address() + { + using var server = new NatsServer( + new NatsOptions { ServerName = "test-node", Host = "127.0.0.1", Port = 4222 }, + NullLoggerFactory.Instance); + + var value = server.ToString(); + + value.ShouldContain("NatsServer("); + value.ShouldContain("Name=test-node"); + value.ShouldContain("Addr=127.0.0.1:4222"); + } + + [Fact] + public void PortsInfo_returns_configured_listen_endpoints() + { + using var server = new NatsServer( + new NatsOptions + { + Host = "127.0.0.1", + Port = 4222, + MonitorHost = "127.0.0.1", + MonitorPort = 8222, + ProfPort = 6060, + WebSocket = new WebSocketOptions { Host = "127.0.0.1", Port = 8443 }, + Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 }, + LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 7422 }, + }, + NullLoggerFactory.Instance); + + var ports = server.PortsInfo(); + + ports.Nats.ShouldContain("127.0.0.1:4222"); + ports.Monitoring.ShouldContain("127.0.0.1:8222"); + ports.Profile.ShouldContain("127.0.0.1:6060"); + ports.WebSocket.ShouldContain("127.0.0.1:8443"); + ports.Cluster.ShouldContain("127.0.0.1:6222"); + ports.LeafNodes.ShouldContain("127.0.0.1:7422"); + } + + [Fact] + public void Profiler_and_peer_accessors_have_parity_surface() + { + using var server = new NatsServer( + new NatsOptions { Port = 4222, ProfPort = 6060 }, + NullLoggerFactory.Instance); + + server.StartProfiler().ShouldBeTrue(); + server.ActivePeers().ShouldBeEmpty(); + } + + [Fact] + public void Connect_urls_helpers_include_non_wildcard_and_cache_refresh() + { + using var server = new NatsServer( + new NatsOptions { Host = "127.0.0.1", Port = 4222 }, + NullLoggerFactory.Instance); + + var urls = server.GetConnectURLs(); + urls.ShouldContain("nats://127.0.0.1:4222"); + + server.UpdateServerINFOAndSendINFOToClients(); + var info = Encoding.ASCII.GetString(server.CachedInfoLine); + info.ShouldContain("\"connect_urls\":[\"nats://127.0.0.1:4222\"]"); + } + + [Fact] + public async Task DisconnectClientByID_closes_connected_client() + { + var port = GetFreePort(); + using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance); + using var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + using var socket = await ConnectAndHandshakeAsync(port); + await WaitUntilAsync(() => server.ClientCount == 1); + var clientId = server.GetClients().Single().Id; + + server.DisconnectClientByID(clientId).ShouldBeTrue(); + await WaitUntilAsync(() => server.ClientCount == 0); + + await server.ShutdownAsync(); + } + + [Fact] + public async Task LDMClientByID_closes_connected_client() + { + var port = GetFreePort(); + using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance); + using var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + using var socket = await ConnectAndHandshakeAsync(port); + await WaitUntilAsync(() => server.ClientCount == 1); + var clientId = server.GetClients().Single().Id; + + server.LDMClientByID(clientId).ShouldBeTrue(); + await WaitUntilAsync(() => server.ClientCount == 0); + + await server.ShutdownAsync(); + } + + private static async Task ConnectAndHandshakeAsync(int port) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(IPAddress.Loopback, port); + + _ = await ReadLineAsync(socket, CancellationToken.None); // INFO + await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None); + var pong = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None); + pong.ShouldContain("PONG"); + + return socket; + } + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var buffer = new List(256); + var single = new byte[1]; + while (true) + { + var n = await socket.ReceiveAsync(single.AsMemory(0, 1), SocketFlags.None, ct); + if (n == 0) + break; + + if (single[0] == '\n') + break; + + if (single[0] != '\r') + buffer.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. buffer]); + } + + private static async Task ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct) + { + var end = DateTime.UtcNow.AddSeconds(3); + var builder = new StringBuilder(); + while (DateTime.UtcNow < end) + { + var line = await ReadLineAsync(socket, ct); + if (line.Length == 0) + continue; + + builder.AppendLine(line); + if (builder.ToString().Contains(token, StringComparison.Ordinal)) + return builder.ToString(); + } + + return builder.ToString(); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static async Task WaitUntilAsync(Func predicate) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!cts.IsCancellationRequested) + { + if (predicate()) + return; + + await Task.Yield(); + } + + throw new TimeoutException("Condition was not met in time."); + } +} diff --git a/tests/NATS.Server.Tests/Server/CoreServerOptionsParityBatch3Tests.cs b/tests/NATS.Server.Tests/Server/CoreServerOptionsParityBatch3Tests.cs new file mode 100644 index 0000000..e94b390 --- /dev/null +++ b/tests/NATS.Server.Tests/Server/CoreServerOptionsParityBatch3Tests.cs @@ -0,0 +1,106 @@ +using NATS.Server.Server; + +namespace NATS.Server.Tests.Server; + +public class CoreServerOptionsParityBatch3Tests +{ + [Fact] + public void Core_ports_and_compression_types_exist_with_expected_defaults() + { + var ports = new Ports + { + Nats = ["nats://127.0.0.1:4222"], + Monitoring = ["http://127.0.0.1:8222"], + }; + + ports.Nats.ShouldHaveSingleItem(); + ports.Monitoring.ShouldHaveSingleItem(); + + CompressionModes.Off.ShouldBe("off"); + CompressionModes.S2Auto.ShouldBe("s2_auto"); + + var opts = new CompressionOpts(); + opts.Mode.ShouldBe(CompressionModes.Off); + opts.RTTThresholds.ShouldNotBeNull(); + } + + [Fact] + public void RoutesFromStr_parses_comma_delimited_routes() + { + var routes = NatsOptions.RoutesFromStr(" nats://a:6222, tls://b:7222 "); + + routes.Count.ShouldBe(2); + routes[0].ToString().ShouldBe("nats://a:6222/"); + routes[1].ToString().ShouldBe("tls://b:7222/"); + } + + [Fact] + public void Clone_returns_deep_copy_for_common_collections() + { + var original = new NatsOptions + { + Host = "127.0.0.1", + Port = 4222, + Tags = new Dictionary { ["a"] = "1" }, + SubjectMappings = new Dictionary { ["foo.*"] = "bar.$1" }, + TlsPinnedCerts = ["abc"], + }; + original.InCmdLine.Add("host"); + + var clone = original.Clone(); + + clone.ShouldNotBeSameAs(original); + clone.Tags.ShouldNotBeSameAs(original.Tags); + clone.SubjectMappings.ShouldNotBeSameAs(original.SubjectMappings); + clone.TlsPinnedCerts.ShouldNotBeSameAs(original.TlsPinnedCerts); + clone.InCmdLine.ShouldNotBeSameAs(original.InCmdLine); + clone.InCmdLine.ShouldContain("host"); + } + + [Fact] + public void ProcessConfigString_sets_config_digest_and_applies_values() + { + var opts = new NatsOptions(); + opts.ProcessConfigString("port: 4333"); + + opts.Port.ShouldBe(4333); + opts.ConfigDigest().ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void NoErrOnUnknownFields_toggle_is_available() + { + NatsOptions.NoErrOnUnknownFields(true); + var ex = Record.Exception(() => new NatsOptions().ProcessConfigString("totally_unknown_field: 1")); + ex.ShouldBeNull(); + NatsOptions.NoErrOnUnknownFields(false); + } + + [Fact] + public void Option_parity_types_exist() + { + var jsLimits = new JSLimitOpts + { + MaxRequestBatch = 10, + MaxAckPending = 20, + }; + jsLimits.MaxRequestBatch.ShouldBe(10); + jsLimits.MaxAckPending.ShouldBe(20); + + var callout = new AuthCallout + { + Issuer = "issuer", + Account = "A", + AllowedAccounts = ["A", "B"], + }; + callout.Issuer.ShouldBe("issuer"); + callout.AllowedAccounts.ShouldContain("B"); + + var proxies = new ProxiesConfig + { + Trusted = [new ProxyConfig { Key = "k1" }], + }; + proxies.Trusted.Count.ShouldBe(1); + proxies.Trusted[0].Key.ShouldBe("k1"); + } +} diff --git a/tests/NATS.Server.Tests/Server/UtilitiesAndRateCounterParityBatch1Tests.cs b/tests/NATS.Server.Tests/Server/UtilitiesAndRateCounterParityBatch1Tests.cs new file mode 100644 index 0000000..1def72a --- /dev/null +++ b/tests/NATS.Server.Tests/Server/UtilitiesAndRateCounterParityBatch1Tests.cs @@ -0,0 +1,53 @@ +using System.Net.Sockets; +using NATS.Server.Routes; +using NATS.Server.Server; + +namespace NATS.Server.Tests.Server; + +public class UtilitiesAndRateCounterParityBatch1Tests +{ + [Fact] + public void ParseHostPort_uses_default_port_for_missing_zero_and_minus_one() + { + ServerUtilities.ParseHostPort("127.0.0.1", 4222).ShouldBe(("127.0.0.1", 4222)); + ServerUtilities.ParseHostPort("127.0.0.1:0", 4222).ShouldBe(("127.0.0.1", 4222)); + ServerUtilities.ParseHostPort("127.0.0.1:-1", 4222).ShouldBe(("127.0.0.1", 4222)); + ServerUtilities.ParseHostPort(":4333", 4222).ShouldBe(("", 4333)); + } + + [Fact] + public void RedactUrl_helpers_redact_password_for_single_and_list_inputs() + { + ServerUtilities.RedactUrlString("nats://foo:bar@example.com:4222") + .ShouldBe("nats://foo:xxxxx@example.com:4222"); + ServerUtilities.RedactUrlString("nats://example.com:4222") + .ShouldBe("nats://example.com:4222"); + + var redacted = ServerUtilities.RedactUrlList( + ["nats://a:b@one:4222", "nats://noauth:4223"]); + redacted[0].ShouldBe("nats://a:xxxxx@one:4222"); + redacted[1].ShouldBe("nats://noauth:4223"); + } + + [Fact] + public void RateCounter_allow_and_count_blocked_match_go_behavior() + { + var rc = new RateCounter(2); + + rc.Allow().ShouldBeTrue(); + rc.Allow().ShouldBeTrue(); + rc.Allow().ShouldBeFalse(); + rc.Allow().ShouldBeFalse(); + + rc.CountBlocked().ShouldBe((ulong)2); + rc.CountBlocked().ShouldBe((ulong)0); // reset on read + } + + [Fact] + public void CreateRouteDialSocket_disables_keepalive() + { + using var socket = RouteManager.CreateRouteDialSocket(); + var keepAlive = Convert.ToInt32(socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive)); + keepAlive.ShouldBe(0); + } +} diff --git a/tests/NATS.Server.Tests/Server/UtilitiesErrorConstantsParityBatch2Tests.cs b/tests/NATS.Server.Tests/Server/UtilitiesErrorConstantsParityBatch2Tests.cs new file mode 100644 index 0000000..793d643 --- /dev/null +++ b/tests/NATS.Server.Tests/Server/UtilitiesErrorConstantsParityBatch2Tests.cs @@ -0,0 +1,28 @@ +using NATS.Server.Server; + +namespace NATS.Server.Tests.Server; + +public class UtilitiesErrorConstantsParityBatch2Tests +{ + [Fact] + public void Error_constants_match_go_error_literals_batch2() + { + ServerErrorConstants.ErrBadQualifier.ShouldBe("bad qualifier"); + ServerErrorConstants.ErrTooManyAccountConnections.ShouldBe("maximum account active connections exceeded"); + ServerErrorConstants.ErrTooManySubs.ShouldBe("maximum subscriptions exceeded"); + ServerErrorConstants.ErrTooManySubTokens.ShouldBe("subject has exceeded number of tokens limit"); + ServerErrorConstants.ErrReservedAccount.ShouldBe("reserved account"); + ServerErrorConstants.ErrMissingService.ShouldBe("service missing"); + ServerErrorConstants.ErrBadServiceType.ShouldBe("bad service response type"); + ServerErrorConstants.ErrBadSampling.ShouldBe("bad sampling percentage, should be 1-100"); + ServerErrorConstants.ErrAccountResolverUpdateTooSoon.ShouldBe("account resolver update too soon"); + ServerErrorConstants.ErrAccountResolverSameClaims.ShouldBe("account resolver no new claims"); + ServerErrorConstants.ErrStreamImportAuthorization.ShouldBe("stream import not authorized"); + ServerErrorConstants.ErrStreamImportBadPrefix.ShouldBe("stream import prefix can not contain wildcard tokens"); + ServerErrorConstants.ErrStreamImportDuplicate.ShouldBe("stream import already exists"); + ServerErrorConstants.ErrServiceImportAuthorization.ShouldBe("service import not authorized"); + ServerErrorConstants.ErrImportFormsCycle.ShouldBe("import forms a cycle"); + ServerErrorConstants.ErrCycleSearchDepth.ShouldBe("search cycle depth exhausted"); + ServerErrorConstants.ErrNoTransforms.ShouldBe("no matching transforms available"); + } +} diff --git a/tests/NATS.Server.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs b/tests/NATS.Server.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs new file mode 100644 index 0000000..142b8e8 --- /dev/null +++ b/tests/NATS.Server.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs @@ -0,0 +1,46 @@ +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Subscriptions; + +public class SubListCtorAndNotificationParityTests +{ + [Fact] + public void Constructor_with_enableCache_false_disables_cache() + { + var subList = new SubList(enableCache: false); + + subList.CacheEnabled().ShouldBeFalse(); + } + + [Fact] + public void NewSublistNoCache_factory_disables_cache() + { + var subList = SubList.NewSublistNoCache(); + + subList.CacheEnabled().ShouldBeFalse(); + } + + [Fact] + public void RegisterNotification_emits_true_on_first_interest_and_false_on_last_interest() + { + var subList = new SubList(); + var notifications = new List(); + subList.RegisterNotification(v => notifications.Add(v)); + + var sub = new Subscription { Subject = "foo", Sid = "1" }; + subList.Insert(sub); + subList.Remove(sub); + + notifications.ShouldBe([true, false]); + } + + [Fact] + public void SubjectMatch_alias_helpers_match_existing_behavior() + { + SubjectMatch.SubjectHasWildcard("foo.*").ShouldBeTrue(); + SubjectMatch.SubjectHasWildcard("foo.bar").ShouldBeFalse(); + + SubjectMatch.IsValidLiteralSubject("foo.bar").ShouldBeTrue(); + SubjectMatch.IsValidLiteralSubject("foo.*").ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Subscriptions/SubListParityBatch2Tests.cs b/tests/NATS.Server.Tests/Subscriptions/SubListParityBatch2Tests.cs new file mode 100644 index 0000000..bfe0d6e --- /dev/null +++ b/tests/NATS.Server.Tests/Subscriptions/SubListParityBatch2Tests.cs @@ -0,0 +1,130 @@ +using NATS.Server; +using NATS.Server.Auth; +using NATS.Server.Protocol; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Subscriptions; + +public class SubListParityBatch2Tests +{ + [Fact] + public void RegisterQueueNotification_tracks_first_and_last_exact_queue_interest() + { + var subList = new SubList(); + var notifications = new List(); + Action callback = hasInterest => notifications.Add(hasInterest); + + subList.RegisterQueueNotification("foo.bar", "q", callback).ShouldBeTrue(); + notifications.ShouldBe([false]); + + var sub1 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "1" }; + var sub2 = new Subscription { Subject = "foo.bar", Queue = "q", Sid = "2" }; + + subList.Insert(sub1); + subList.Insert(sub2); + notifications.ShouldBe([false, true]); + + subList.Remove(sub1); + notifications.ShouldBe([false, true]); + + subList.Remove(sub2); + notifications.ShouldBe([false, true, false]); + + subList.ClearQueueNotification("foo.bar", "q", callback).ShouldBeTrue(); + } + + [Fact] + public void UpdateRemoteQSub_updates_queue_weight_for_match_remote() + { + var subList = new SubList(); + var original = new RemoteSubscription("foo.bar", "q", "R1", Account: "A", QueueWeight: 1); + subList.ApplyRemoteSub(original); + subList.MatchRemote("A", "foo.bar").Count.ShouldBe(1); + + subList.UpdateRemoteQSub(original with { QueueWeight = 3 }); + subList.MatchRemote("A", "foo.bar").Count.ShouldBe(3); + } + + [Fact] + public void SubListStats_Add_aggregates_stats_like_go() + { + var stats = new SubListStats + { + NumSubs = 1, + NumCache = 2, + NumInserts = 3, + NumRemoves = 4, + NumMatches = 10, + MaxFanout = 5, + TotalFanout = 8, + CacheEntries = 2, + CacheHits = 6, + }; + + stats.Add(new SubListStats + { + NumSubs = 2, + NumCache = 3, + NumInserts = 4, + NumRemoves = 5, + NumMatches = 30, + MaxFanout = 9, + TotalFanout = 12, + CacheEntries = 3, + CacheHits = 15, + }); + + stats.NumSubs.ShouldBe((uint)3); + stats.NumCache.ShouldBe((uint)5); + stats.NumInserts.ShouldBe((ulong)7); + stats.NumRemoves.ShouldBe((ulong)9); + stats.NumMatches.ShouldBe((ulong)40); + stats.MaxFanout.ShouldBe((uint)9); + stats.AvgFanout.ShouldBe(4.0); + stats.CacheHitRate.ShouldBe(0.525); + } + + [Fact] + public void NumLevels_returns_max_trie_depth() + { + var subList = new SubList(); + subList.Insert(new Subscription { Subject = "foo.bar.baz", Sid = "1" }); + subList.Insert(new Subscription { Subject = "foo.bar", Sid = "2" }); + + subList.NumLevels().ShouldBe(3); + } + + [Fact] + public void LocalSubs_filters_non_local_kinds_and_optionally_includes_leaf() + { + var subList = new SubList(); + subList.Insert(new Subscription { Subject = "foo.a", Sid = "1", Client = new TestClient(ClientKind.Client) }); + subList.Insert(new Subscription { Subject = "foo.b", Sid = "2", Client = new TestClient(ClientKind.Router) }); + subList.Insert(new Subscription { Subject = "foo.c", Sid = "3", Client = new TestClient(ClientKind.System) }); + subList.Insert(new Subscription { Subject = "foo.d", Sid = "4", Client = new TestClient(ClientKind.Leaf) }); + + var local = subList.LocalSubs(includeLeafHubs: false).Select(s => s.Sid).OrderBy(x => x).ToArray(); + local.ShouldBe(["1", "3"]); + + var withLeaf = subList.LocalSubs(includeLeafHubs: true).Select(s => s.Sid).OrderBy(x => x).ToArray(); + withLeaf.ShouldBe(["1", "3", "4"]); + } + + private sealed class TestClient(ClientKind kind) : INatsClient + { + public ulong Id => 1; + public ClientKind Kind => kind; + public Account? Account => null; + public ClientOptions? ClientOpts => null; + public ClientPermissions? Permissions => null; + public void SendMessage(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) + { + } + + public bool QueueOutbound(ReadOnlyMemory data) => true; + + public void RemoveSubscription(string sid) + { + } + } +} diff --git a/tests/NATS.Server.Tests/Subscriptions/SubjectSubsetMatchParityBatch1Tests.cs b/tests/NATS.Server.Tests/Subscriptions/SubjectSubsetMatchParityBatch1Tests.cs new file mode 100644 index 0000000..c0904ff --- /dev/null +++ b/tests/NATS.Server.Tests/Subscriptions/SubjectSubsetMatchParityBatch1Tests.cs @@ -0,0 +1,40 @@ +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Subscriptions; + +public class SubjectSubsetMatchParityBatch1Tests +{ + [Theory] + [InlineData("foo.bar", "foo.bar", true)] + [InlineData("foo.bar", "foo.*", true)] + [InlineData("foo.bar", "foo.>", true)] + [InlineData("foo.bar", "*.*", true)] + [InlineData("foo.bar", ">", true)] + [InlineData("foo.bar", "foo.baz", false)] + [InlineData("foo.bar.baz", "foo.*", false)] + public void SubjectMatchesFilter_matches_go_subset_behavior(string subject, string filter, bool expected) + { + SubjectMatch.SubjectMatchesFilter(subject, filter).ShouldBe(expected); + } + + [Fact] + public void SubjectIsSubsetMatch_uses_subject_tokens_against_test_pattern() + { + SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.*").ShouldBeTrue(); + SubjectMatch.SubjectIsSubsetMatch("foo.*", "foo.bar").ShouldBeFalse(); + } + + [Fact] + public void IsSubsetMatch_tokenizes_test_subject_and_delegates_to_tokenized_matcher() + { + SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.*").ShouldBeTrue(); + SubjectMatch.IsSubsetMatch(["foo", "bar"], "foo.baz").ShouldBeFalse(); + } + + [Fact] + public void IsSubsetMatchTokenized_handles_fwc_and_rejects_empty_tokens_like_go() + { + SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ">"]).ShouldBeTrue(); + SubjectMatch.IsSubsetMatchTokenized(["foo", "bar"], ["foo", ""]).ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Subscriptions/SubjectTransformParityBatch3Tests.cs b/tests/NATS.Server.Tests/Subscriptions/SubjectTransformParityBatch3Tests.cs new file mode 100644 index 0000000..662f18a --- /dev/null +++ b/tests/NATS.Server.Tests/Subscriptions/SubjectTransformParityBatch3Tests.cs @@ -0,0 +1,83 @@ +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Subscriptions; + +public class SubjectTransformParityBatch3Tests +{ + [Fact] + public void ValidateMapping_accepts_supported_templates_and_rejects_invalid_templates() + { + SubjectTransform.ValidateMapping("dest.$1").ShouldBeTrue(); + SubjectTransform.ValidateMapping("dest.{{partition(10)}}").ShouldBeTrue(); + SubjectTransform.ValidateMapping("dest.{{random(5)}}").ShouldBeTrue(); + + SubjectTransform.ValidateMapping("dest.*").ShouldBeFalse(); + SubjectTransform.ValidateMapping("dest.{{wildcard()}}").ShouldBeFalse(); + } + + [Fact] + public void NewSubjectTransformStrict_requires_all_source_wildcards_to_be_used() + { + SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: true).ShouldBeNull(); + SubjectTransform.NewSubjectTransformWithStrict("foo.*.*", "bar.$1", strict: false).ShouldNotBeNull(); + } + + [Fact] + public void NewSubjectTransformStrict_accepts_when_all_source_wildcards_are_used() + { + var transform = SubjectTransform.NewSubjectTransformStrict("foo.*.*", "bar.$2.$1"); + transform.ShouldNotBeNull(); + transform.Apply("foo.A.B").ShouldBe("bar.B.A"); + } + + [Fact] + public void Random_transform_function_returns_bucket_in_range() + { + var transform = SubjectTransform.Create("*", "rand.{{random(3)}}"); + transform.ShouldNotBeNull(); + + for (var i = 0; i < 20; i++) + { + var output = transform.Apply("foo"); + output.ShouldNotBeNull(); + var parts = output!.Split('.'); + parts.Length.ShouldBe(2); + int.TryParse(parts[1], out var bucket).ShouldBeTrue(); + bucket.ShouldBeGreaterThanOrEqualTo(0); + bucket.ShouldBeLessThan(3); + } + } + + [Fact] + public void TransformTokenize_and_transformUntokenize_round_trip_wildcards() + { + var tokenized = SubjectTransform.TransformTokenize("foo.*.*"); + tokenized.ShouldBe("foo.$1.$2"); + + var untokenized = SubjectTransform.TransformUntokenize(tokenized); + untokenized.ShouldBe("foo.*.*"); + } + + [Fact] + public void Reverse_produces_inverse_transform_for_reordered_wildcards() + { + var forward = SubjectTransform.Create("foo.*.*", "bar.$2.$1"); + forward.ShouldNotBeNull(); + + var reverse = forward.Reverse(); + reverse.ShouldNotBeNull(); + + var mapped = forward.Apply("foo.A.B"); + mapped.ShouldBe("bar.B.A"); + reverse.Apply(mapped!).ShouldBe("foo.A.B"); + } + + [Fact] + public void TransformSubject_applies_transform_without_source_match_guard() + { + var transform = SubjectTransform.Create("foo.*", "bar.$1"); + transform.ShouldNotBeNull(); + + transform.TransformSubject("baz.qux").ShouldBe("bar.qux"); + } +} diff --git a/tests/NATS.Server.Tests/TlsHelperTests.cs b/tests/NATS.Server.Tests/TlsHelperTests.cs index c8d1cfa..7394f21 100644 --- a/tests/NATS.Server.Tests/TlsHelperTests.cs +++ b/tests/NATS.Server.Tests/TlsHelperTests.cs @@ -35,6 +35,45 @@ public class TlsHelperTests finally { File.Delete(certPath); File.Delete(keyPath); } } + [Fact] + public void LoadCaCertificates_rejects_non_certificate_pem_block() + { + var (_, key) = GenerateTestCert(); + var pemPath = Path.GetTempFileName(); + try + { + File.WriteAllText(pemPath, key.ExportPkcs8PrivateKeyPem()); + Should.Throw(() => TlsHelper.LoadCaCertificates(pemPath)); + } + finally + { + File.Delete(pemPath); + key.Dispose(); + } + } + + [Fact] + public void LoadCaCertificates_loads_multiple_certificate_blocks() + { + var (certA, keyA) = GenerateTestCert(); + var (certB, keyB) = GenerateTestCert(); + var pemPath = Path.GetTempFileName(); + try + { + File.WriteAllText(pemPath, certA.ExportCertificatePem() + certB.ExportCertificatePem()); + var collection = TlsHelper.LoadCaCertificates(pemPath); + collection.Count.ShouldBe(2); + } + finally + { + File.Delete(pemPath); + certA.Dispose(); + certB.Dispose(); + keyA.Dispose(); + keyB.Dispose(); + } + } + [Fact] public void MatchesPinnedCert_matches_correct_hash() { diff --git a/tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs b/tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs new file mode 100644 index 0000000..0bdc881 --- /dev/null +++ b/tests/NATS.Server.Tests/TlsOcspParityBatch1Tests.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using System.Text.Json; +using NATS.Server.Configuration; +using NATS.Server.Tls; + +namespace NATS.Server.Tests; + +public class TlsOcspParityBatch1Tests +{ + [Fact] + public void OCSPPeerConfig_defaults_match_go_reference() + { + var cfg = OCSPPeerConfig.NewOCSPPeerConfig(); + + cfg.Verify.ShouldBeFalse(); + cfg.Timeout.ShouldBe(2d); + cfg.ClockSkew.ShouldBe(30d); + cfg.WarnOnly.ShouldBeFalse(); + cfg.UnknownIsGood.ShouldBeFalse(); + cfg.AllowWhenCAUnreachable.ShouldBeFalse(); + cfg.TTLUnsetNextUpdate.ShouldBe(3600d); + } + + [Fact] + public void OCSPPeerConfig_parse_map_parses_supported_fields() + { + var cfg = OCSPPeerConfig.Parse(new Dictionary + { + ["verify"] = true, + ["allowed_clockskew"] = "45s", + ["ca_timeout"] = 1.5d, + ["cache_ttl_when_next_update_unset"] = 120L, + ["warn_only"] = true, + ["unknown_is_good"] = true, + ["allow_when_ca_unreachable"] = true, + }); + + cfg.Verify.ShouldBeTrue(); + cfg.ClockSkew.ShouldBe(45d); + cfg.Timeout.ShouldBe(1.5d); + cfg.TTLUnsetNextUpdate.ShouldBe(120d); + cfg.WarnOnly.ShouldBeTrue(); + cfg.UnknownIsGood.ShouldBeTrue(); + cfg.AllowWhenCAUnreachable.ShouldBeTrue(); + } + + [Fact] + public void OCSPPeerConfig_parse_unknown_field_throws() + { + var ex = Should.Throw(() => + OCSPPeerConfig.Parse(new Dictionary { ["bogus"] = true })); + + ex.Message.ShouldContain("unknown field [bogus]"); + } + + [Fact] + public void ConfigProcessor_parses_ocsp_peer_short_form() + { + var opts = ConfigProcessor.ProcessConfig(""" + tls { + ocsp_peer: true + } + """); + + opts.OcspPeerVerify.ShouldBeTrue(); + } + + [Fact] + public void ConfigProcessor_parses_ocsp_peer_long_form_verify() + { + var opts = ConfigProcessor.ProcessConfig(""" + tls { + ocsp_peer { + verify: true + ca_timeout: 2s + allowed_clockskew: 30s + } + } + """); + + opts.OcspPeerVerify.ShouldBeTrue(); + } + + [Fact] + public void GenerateFingerprint_uses_raw_certificate_sha256() + { + var (cert, _) = TlsHelperTests.GenerateTestCert(); + + var expected = Convert.ToBase64String(SHA256.HashData(cert.RawData)); + TlsHelper.GenerateFingerprint(cert).ShouldBe(expected); + } + + [Fact] + public void GetWebEndpoints_filters_non_web_uris() + { + var urls = TlsHelper.GetWebEndpoints( + ["http://a.example", "https://b.example", "ftp://bad.example", "not a uri"]); + + urls.Count.ShouldBe(2); + urls[0].Scheme.ShouldBe(Uri.UriSchemeHttp); + urls[1].Scheme.ShouldBe(Uri.UriSchemeHttps); + } + + [Fact] + public void Subject_and_issuer_dn_helpers_return_values_and_empty_for_null() + { + var (cert, _) = TlsHelperTests.GenerateTestCert(); + + TlsHelper.GetSubjectDNForm(cert).ShouldNotBeNullOrWhiteSpace(); + TlsHelper.GetIssuerDNForm(cert).ShouldNotBeNullOrWhiteSpace(); + TlsHelper.GetSubjectDNForm(null).ShouldBe(string.Empty); + TlsHelper.GetIssuerDNForm(null).ShouldBe(string.Empty); + } + + [Fact] + public void StatusAssertion_json_converter_uses_string_values_and_unknown_fallback() + { + var revokedJson = JsonSerializer.Serialize(StatusAssertion.Revoked); + revokedJson.ShouldBe("\"revoked\""); + + var unknown = JsonSerializer.Deserialize("\"nonsense\""); + unknown.ShouldBe(StatusAssertion.Unknown); + } + + [Fact] + public void OcspPeer_messages_match_go_literals() + { + OcspPeerMessages.MsgTLSClientRejectConnection.ShouldBe("client not OCSP valid"); + OcspPeerMessages.MsgTLSServerRejectConnection.ShouldBe("server not OCSP valid"); + OcspPeerMessages.MsgCacheOnline.ShouldBe("OCSP peer cache online, type [%s]"); + OcspPeerMessages.MsgCacheOffline.ShouldBe("OCSP peer cache offline, type [%s]"); + } +} diff --git a/tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs b/tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs new file mode 100644 index 0000000..7832216 --- /dev/null +++ b/tests/NATS.Server.Tests/TlsOcspParityBatch2Tests.cs @@ -0,0 +1,165 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NATS.Server.Tls; + +namespace NATS.Server.Tests; + +public class TlsOcspParityBatch2Tests +{ + [Fact] + public void CertOCSPEligible_returns_true_and_populates_endpoints_for_http_ocsp_aia() + { + using var cert = CreateLeafWithOcspAia("http://ocsp.example.test"); + var link = new ChainLink { Leaf = cert }; + + var eligible = TlsHelper.CertOCSPEligible(link); + + eligible.ShouldBeTrue(); + link.OCSPWebEndpoints.ShouldNotBeNull(); + link.OCSPWebEndpoints!.Count.ShouldBe(1); + link.OCSPWebEndpoints[0].ToString().ShouldBe("http://ocsp.example.test/"); + } + + [Fact] + public void CertOCSPEligible_returns_false_when_leaf_has_no_ocsp_servers() + { + var (leaf, _) = TlsHelperTests.GenerateTestCert(); + var link = new ChainLink { Leaf = leaf }; + + TlsHelper.CertOCSPEligible(link).ShouldBeFalse(); + } + + [Fact] + public void GetLeafIssuerCert_returns_positional_issuer_or_null() + { + using var root = CreateRootCertificate(); + using var leaf = CreateLeafSignedBy(root); + + var chain = new[] { leaf, root }; + TlsHelper.GetLeafIssuerCert(chain, 0).ShouldBe(root); + TlsHelper.GetLeafIssuerCert(chain, 1).ShouldBeNull(); + TlsHelper.GetLeafIssuerCert(chain, -1).ShouldBeNull(); + } + + [Fact] + public void GetLeafIssuer_returns_verified_issuer_from_chain() + { + using var root = CreateRootCertificate(); + using var leaf = CreateLeafSignedBy(root); + + using var issuer = TlsHelper.GetLeafIssuer(leaf, root); + + issuer.ShouldNotBeNull(); + issuer!.Thumbprint.ShouldBe(root.Thumbprint); + } + + [Fact] + public void OcspResponseCurrent_applies_skew_and_ttl_rules() + { + var opts = OCSPPeerConfig.NewOCSPPeerConfig(); + var now = DateTime.UtcNow; + + TlsHelper.OcspResponseCurrent(new OcspResponseInfo + { + ThisUpdate = now.AddMinutes(-1), + NextUpdate = now.AddMinutes(5), + }, opts).ShouldBeTrue(); + + TlsHelper.OcspResponseCurrent(new OcspResponseInfo + { + ThisUpdate = now.AddHours(-2), + NextUpdate = null, + }, opts).ShouldBeFalse(); + + TlsHelper.OcspResponseCurrent(new OcspResponseInfo + { + ThisUpdate = now.AddMinutes(2), + NextUpdate = now.AddHours(1), + }, opts).ShouldBeFalse(); + } + + [Fact] + public void ValidDelegationCheck_accepts_direct_and_ocsp_signing_delegate() + { + using var issuer = CreateRootCertificate(); + using var delegateCert = CreateOcspSigningDelegate(issuer); + + TlsHelper.ValidDelegationCheck(issuer, null).ShouldBeTrue(); + TlsHelper.ValidDelegationCheck(issuer, issuer).ShouldBeTrue(); + TlsHelper.ValidDelegationCheck(issuer, delegateCert).ShouldBeTrue(); + } + + [Fact] + public void OcspPeerMessages_exposes_error_and_debug_constants() + { + OcspPeerMessages.ErrIllegalPeerOptsConfig.ShouldContain("expected map to define OCSP peer options"); + OcspPeerMessages.ErrNoAvailOCSPServers.ShouldBe("no available OCSP servers"); + OcspPeerMessages.DbgPlugTLSForKind.ShouldBe("Plugging TLS OCSP peer for [%s]"); + OcspPeerMessages.DbgCacheSaved.ShouldBe("Saved OCSP peer cache successfully (%d bytes)"); + OcspPeerMessages.MsgFailedOCSPResponseFetch.ShouldBe("Failed OCSP response fetch"); + } + + private static X509Certificate2 CreateLeafWithOcspAia(string ocspUri) + { + using var key = RSA.Create(2048); + var req = new CertificateRequest("CN=leaf-with-ocsp", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + req.CertificateExtensions.Add(CreateOcspAiaExtension(ocspUri)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + } + + private static X509Extension CreateOcspAiaExtension(string ocspUri) + { + var writer = new AsnWriter(AsnEncodingRules.DER); + writer.PushSequence(); + writer.PushSequence(); + writer.WriteObjectIdentifier("1.3.6.1.5.5.7.48.1"); + writer.WriteCharacterString(UniversalTagNumber.IA5String, ocspUri, new Asn1Tag(TagClass.ContextSpecific, 6)); + writer.PopSequence(); + writer.PopSequence(); + return new X509Extension("1.3.6.1.5.5.7.1.1", writer.Encode(), false); + } + + private static X509Certificate2 CreateRootCertificate() + { + using var rootKey = RSA.Create(2048); + var req = new CertificateRequest("CN=Root", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5)); + } + + private static X509Certificate2 CreateLeafSignedBy(X509Certificate2 issuer) + { + using var leafKey = RSA.Create(2048); + var req = new CertificateRequest("CN=Leaf", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + + var cert = req.Create( + issuer, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1), + Guid.NewGuid().ToByteArray()); + + return cert.CopyWithPrivateKey(leafKey); + } + + private static X509Certificate2 CreateOcspSigningDelegate(X509Certificate2 issuer) + { + using var key = RSA.Create(2048); + var req = new CertificateRequest("CN=OCSP Delegate", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid("1.3.6.1.5.5.7.3.9")], true)); + + var cert = req.Create( + issuer, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1), + Guid.NewGuid().ToByteArray()); + + return cert.CopyWithPrivateKey(key); + } +} diff --git a/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs b/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs index 50c43b7..a4c9443 100644 --- a/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs +++ b/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs @@ -1,4 +1,5 @@ using Shouldly; +using NATS.Server.WebSocket; namespace NATS.Server.Tests.WebSocket; @@ -23,4 +24,27 @@ public class WebSocketOptionsTests opts.WebSocket.ShouldNotBeNull(); opts.WebSocket.Port.ShouldBe(-1); } + + [Fact] + public void WsAuthConfig_sets_auth_override_when_websocket_auth_fields_are_present() + { + var ws = new WebSocketOptions + { + Username = "u", + }; + + WsAuthConfig.Apply(ws); + + ws.AuthOverride.ShouldBeTrue(); + } + + [Fact] + public void WsAuthConfig_keeps_auth_override_false_when_no_ws_auth_fields_are_present() + { + var ws = new WebSocketOptions(); + + WsAuthConfig.Apply(ws); + + ws.AuthOverride.ShouldBeFalse(); + } } diff --git a/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsValidatorParityBatch2Tests.cs b/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsValidatorParityBatch2Tests.cs new file mode 100644 index 0000000..e5706a4 --- /dev/null +++ b/tests/NATS.Server.Tests/WebSocket/WebSocketOptionsValidatorParityBatch2Tests.cs @@ -0,0 +1,172 @@ +using NATS.Server.Auth; +using NATS.Server.WebSocket; + +namespace NATS.Server.Tests.WebSocket; + +public class WebSocketOptionsValidatorParityBatch2Tests +{ + [Fact] + public void Validate_rejects_tls_listener_without_cert_key_when_not_no_tls() + { + var opts = new NatsOptions + { + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = false, + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("TLS", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_invalid_allowed_origins() + { + var opts = new NatsOptions + { + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + AllowedOrigins = ["not-a-uri"], + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("allowed origin", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_no_auth_user_not_present_in_configured_users() + { + var opts = new NatsOptions + { + Users = [new User { Username = "alice", Password = "x" }], + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + NoAuthUser = "bob", + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("NoAuthUser", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_username_or_token_when_users_or_nkeys_are_set() + { + var opts = new NatsOptions + { + Users = [new User { Username = "alice", Password = "x" }], + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + Username = "ws-user", + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("users", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_jwt_cookie_without_trusted_operators() + { + var opts = new NatsOptions + { + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + JwtCookie = "jwt", + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("JwtCookie", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_reserved_response_headers_override() + { + var opts = new NatsOptions + { + TrustedKeys = ["OP1"], + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + Headers = new Dictionary + { + ["Sec-WebSocket-Accept"] = "bad", + }, + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("reserved", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_rejects_tls_pinned_certs_when_websocket_tls_is_disabled() + { + var opts = new NatsOptions + { + TlsPinnedCerts = ["ABCDEF0123"], + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("TLSPinnedCerts", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_accepts_valid_minimal_configuration() + { + var opts = new NatsOptions + { + TrustedKeys = ["OP1"], + Users = [new User { Username = "alice", Password = "x" }], + WebSocket = new WebSocketOptions + { + Port = 8080, + NoTls = true, + NoAuthUser = "alice", + AllowedOrigins = ["https://app.example.com"], + JwtCookie = "jwt", + Headers = new Dictionary + { + ["X-App-Version"] = "1", + }, + }, + }; + + var result = WebSocketOptionsValidator.Validate(opts); + + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } +} diff --git a/tests/NATS.Server.Tests/WebSocket/WsUpgradeHelperParityBatch1Tests.cs b/tests/NATS.Server.Tests/WebSocket/WsUpgradeHelperParityBatch1Tests.cs new file mode 100644 index 0000000..45004cf --- /dev/null +++ b/tests/NATS.Server.Tests/WebSocket/WsUpgradeHelperParityBatch1Tests.cs @@ -0,0 +1,66 @@ +using System.Text; +using NATS.Server.WebSocket; + +namespace NATS.Server.Tests.WebSocket; + +public class WsUpgradeHelperParityBatch1Tests +{ + [Fact] + public void MakeChallengeKey_returns_base64_of_16_random_bytes() + { + var key = WsUpgrade.MakeChallengeKey(); + var decoded = Convert.FromBase64String(key); + + decoded.Length.ShouldBe(16); + } + + [Fact] + public void Url_helpers_match_ws_and_wss_schemes() + { + WsUpgrade.IsWsUrl("ws://localhost:8080").ShouldBeTrue(); + WsUpgrade.IsWsUrl("wss://localhost:8443").ShouldBeFalse(); + WsUpgrade.IsWsUrl("http://localhost").ShouldBeFalse(); + + WsUpgrade.IsWssUrl("wss://localhost:8443").ShouldBeTrue(); + WsUpgrade.IsWssUrl("ws://localhost:8080").ShouldBeFalse(); + WsUpgrade.IsWssUrl("https://localhost").ShouldBeFalse(); + } + + [Fact] + public async Task RejectNoMaskingForTest_forces_no_masking_handshake_rejection() + { + var request = BuildValidRequest("/leafnode", "Nats-No-Masking: true\r\n"); + using var input = new MemoryStream(Encoding.ASCII.GetBytes(request)); + using var output = new MemoryStream(); + + try + { + WsUpgrade.RejectNoMaskingForTest = true; + var result = await WsUpgrade.TryUpgradeAsync(input, output, new WebSocketOptions { NoTls = true }); + + result.Success.ShouldBeFalse(); + output.Position = 0; + var response = Encoding.ASCII.GetString(output.ToArray()); + response.ShouldContain("400 Bad Request"); + response.ShouldContain("invalid value for no-masking"); + } + finally + { + WsUpgrade.RejectNoMaskingForTest = false; + } + } + + private static string BuildValidRequest(string path = "/", string extraHeaders = "") + { + var sb = new StringBuilder(); + sb.Append($"GET {path} HTTP/1.1\r\n"); + sb.Append("Host: localhost:8080\r\n"); + sb.Append("Upgrade: websocket\r\n"); + sb.Append("Connection: Upgrade\r\n"); + sb.Append("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"); + sb.Append("Sec-WebSocket-Version: 13\r\n"); + sb.Append(extraHeaders); + sb.Append("\r\n"); + return sb.ToString(); + } +}