diff --git a/src/NATS.Server/WebSocket/WebSocketTlsConfig.cs b/src/NATS.Server/WebSocket/WebSocketTlsConfig.cs new file mode 100644 index 0000000..c7404aa --- /dev/null +++ b/src/NATS.Server/WebSocket/WebSocketTlsConfig.cs @@ -0,0 +1,78 @@ +namespace NATS.Server.WebSocket; + +/// +/// Separate TLS configuration for the WebSocket listener, +/// allowing different certificates than the main NATS listener. +/// Go reference: server/websocket.go — wsTLSConfig. +/// +public sealed class WebSocketTlsConfig +{ + public string? CertFile { get; set; } + public string? KeyFile { get; set; } + public string? CaFile { get; set; } + public bool RequireClientCert { get; set; } + public bool InsecureSkipVerify { get; set; } + + /// Returns true when a certificate file has been specified. + public bool IsConfigured => !string.IsNullOrWhiteSpace(CertFile); + + /// + /// Validates the TLS configuration. + /// An empty configuration (no cert, no key) is valid and means no TLS. + /// When either cert or key is specified, both must be provided. + /// + public WebSocketTlsValidationResult Validate() + { + var errors = new List(); + + bool hasCert = !string.IsNullOrWhiteSpace(CertFile); + bool hasKey = !string.IsNullOrWhiteSpace(KeyFile); + + if (hasKey && !hasCert) + errors.Add("CertFile must be specified when KeyFile is set."); + + if (hasCert && !hasKey) + errors.Add("KeyFile must be specified when CertFile is set."); + + return new WebSocketTlsValidationResult(errors.Count == 0, errors); + } + + /// + /// Returns true when this configuration differs from . + /// Used for hot-reload diff detection. + /// + public bool HasChangedFrom(WebSocketTlsConfig? other) + { + if (other is null) + return true; + + return CertFile != other.CertFile + || KeyFile != other.KeyFile + || CaFile != other.CaFile + || RequireClientCert != other.RequireClientCert + || InsecureSkipVerify != other.InsecureSkipVerify; + } + + /// + /// Creates a from the WebSocket-specific TLS fields + /// in , or returns null if no WebSocket TLS is configured. + /// + public static WebSocketTlsConfig? FromOptions(NatsOptions options) + { + var ws = options.WebSocket; + + if (string.IsNullOrWhiteSpace(ws.TlsCert) && string.IsNullOrWhiteSpace(ws.TlsKey)) + return null; + + return new WebSocketTlsConfig + { + CertFile = ws.TlsCert, + KeyFile = ws.TlsKey, + }; + } +} + +/// Result of . +public sealed record WebSocketTlsValidationResult( + bool IsValid, + IReadOnlyList Errors); diff --git a/tests/NATS.Server.Tests/WebSocket/WebSocketTlsTests.cs b/tests/NATS.Server.Tests/WebSocket/WebSocketTlsTests.cs new file mode 100644 index 0000000..2c99e54 --- /dev/null +++ b/tests/NATS.Server.Tests/WebSocket/WebSocketTlsTests.cs @@ -0,0 +1,119 @@ +// Go reference: server/websocket.go — wsTLSConfig and related TLS handling. + +using NATS.Server.WebSocket; +using Shouldly; + +namespace NATS.Server.Tests.WebSocket; + +/// +/// Tests for — WebSocket-specific TLS configuration +/// with separate cert/key from the main NATS listener (Gap 15.1). +/// +public class WebSocketTlsTests +{ + // ── IsConfigured ────────────────────────────────────────────────────────── + + [Fact] + public void IsConfigured_WithCert_ReturnsTrue() + { + var cfg = new WebSocketTlsConfig { CertFile = "server.pem", KeyFile = "server-key.pem" }; + cfg.IsConfigured.ShouldBeTrue(); + } + + [Fact] + public void IsConfigured_WithoutCert_ReturnsFalse() + { + var cfg = new WebSocketTlsConfig(); + cfg.IsConfigured.ShouldBeFalse(); + } + + // ── Validate ────────────────────────────────────────────────────────────── + + [Fact] + public void Validate_ValidConfig_NoErrors() + { + var cfg = new WebSocketTlsConfig { CertFile = "server.pem", KeyFile = "server-key.pem" }; + var result = cfg.Validate(); + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_KeyWithoutCert_HasError() + { + var cfg = new WebSocketTlsConfig { KeyFile = "server-key.pem" }; + var result = cfg.Validate(); + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldNotBeEmpty(); + } + + [Fact] + public void Validate_CertWithoutKey_HasError() + { + var cfg = new WebSocketTlsConfig { CertFile = "server.pem" }; + var result = cfg.Validate(); + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldNotBeEmpty(); + } + + [Fact] + public void Validate_EmptyConfig_Valid() + { + // An empty configuration means "no TLS" — that is a valid state. + var cfg = new WebSocketTlsConfig(); + var result = cfg.Validate(); + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + // ── HasChangedFrom ──────────────────────────────────────────────────────── + + [Fact] + public void HasChangedFrom_SameConfig_ReturnsFalse() + { + var a = new WebSocketTlsConfig + { + CertFile = "server.pem", + KeyFile = "server-key.pem", + CaFile = "ca.pem", + RequireClientCert = true, + InsecureSkipVerify = false, + }; + + var b = new WebSocketTlsConfig + { + CertFile = "server.pem", + KeyFile = "server-key.pem", + CaFile = "ca.pem", + RequireClientCert = true, + InsecureSkipVerify = false, + }; + + a.HasChangedFrom(b).ShouldBeFalse(); + } + + [Fact] + public void HasChangedFrom_DifferentCert_ReturnsTrue() + { + var a = new WebSocketTlsConfig { CertFile = "old.pem", KeyFile = "old-key.pem" }; + var b = new WebSocketTlsConfig { CertFile = "new.pem", KeyFile = "new-key.pem" }; + + a.HasChangedFrom(b).ShouldBeTrue(); + } + + [Fact] + public void HasChangedFrom_NullOther_ReturnsTrue() + { + var cfg = new WebSocketTlsConfig { CertFile = "server.pem", KeyFile = "server-key.pem" }; + cfg.HasChangedFrom(null).ShouldBeTrue(); + } + + // ── Defaults ────────────────────────────────────────────────────────────── + + [Fact] + public void RequireClientCert_Default_False() + { + var cfg = new WebSocketTlsConfig(); + cfg.RequireClientCert.ShouldBeFalse(); + } +}