From ce573b813d286f6c3ba6c8dc28314d165076c9fb Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Thu, 26 Mar 2026 13:42:28 +1000 Subject: [PATCH 01/10] Always use system TLS defaults --- .../Transport/SecureClientFixture.cs | 3 +- .../Transport/SecureListenerFixture.cs | 3 +- source/Halibut/HalibutRuntime.cs | 12 +++---- source/Halibut/HalibutRuntimeBuilder.cs | 11 +------ .../DefaultSslConfigurationProvider.cs | 27 --------------- source/Halibut/Transport/DiscoveryClient.cs | 10 +----- .../Transport/ISslConfigurationProvider.cs | 23 ------------- .../LegacySslConfigurationProvider.cs | 33 ------------------- source/Halibut/Transport/SecureListener.cs | 7 ++-- source/Halibut/Transport/SslConfiguration.cs | 9 ++--- .../Streams/SslStreamExtensionMethods.cs | 3 +- .../Halibut/Transport/TcpConnectionFactory.cs | 9 ++--- 12 files changed, 17 insertions(+), 133 deletions(-) delete mode 100644 source/Halibut/Transport/DefaultSslConfigurationProvider.cs delete mode 100644 source/Halibut/Transport/ISslConfigurationProvider.cs delete mode 100644 source/Halibut/Transport/LegacySslConfigurationProvider.cs diff --git a/source/Halibut.Tests/Transport/SecureClientFixture.cs b/source/Halibut.Tests/Transport/SecureClientFixture.cs index d6df4e782..c6aa33f7f 100644 --- a/source/Halibut.Tests/Transport/SecureClientFixture.cs +++ b/source/Halibut.Tests/Transport/SecureClientFixture.cs @@ -84,8 +84,7 @@ public async Task SecureClientClearsPoolWhenAllConnectionsCorrupt() Certificates.Octopus, halibutTimeoutsAndLimits, new StreamFactory(), - NoOpSecureConnectionObserver.Instance, - SslConfiguration.Default + NoOpSecureConnectionObserver.Instance ); var secureClient = new SecureListeningClient(GetProtocol, endpoint, Certificates.Octopus, log, connectionManager, tcpConnectionFactory); ResponseMessage response = null!; diff --git a/source/Halibut.Tests/Transport/SecureListenerFixture.cs b/source/Halibut.Tests/Transport/SecureListenerFixture.cs index 314daf5ed..69775b77e 100644 --- a/source/Halibut.Tests/Transport/SecureListenerFixture.cs +++ b/source/Halibut.Tests/Transport/SecureListenerFixture.cs @@ -74,8 +74,7 @@ public async Task SecureListenerDoesNotCreateHundredsOfIoEventsPerSecondOnWindow timeoutsAndLimits, new StreamFactory(), NoOpConnectionsObserver.Instance, - NoOpSecureConnectionObserver.Instance, - SslConfiguration.Default + NoOpSecureConnectionObserver.Instance ); var idleAverage = CollectCounterValues(opsPerSec) diff --git a/source/Halibut/HalibutRuntime.cs b/source/Halibut/HalibutRuntime.cs index 7233b29ec..1588239e0 100644 --- a/source/Halibut/HalibutRuntime.cs +++ b/source/Halibut/HalibutRuntime.cs @@ -47,7 +47,6 @@ public class HalibutRuntime : IHalibutRuntime readonly ISecureConnectionObserver secureConnectionObserver; readonly IActiveTcpConnectionsLimiter activeTcpConnectionsLimiter; readonly IControlMessageObserver controlMessageObserver; - readonly ISslConfigurationProvider sslConfigurationProvider; internal HalibutRuntime( IServiceFactory serviceFactory, @@ -63,8 +62,7 @@ internal HalibutRuntime( IRpcObserver rpcObserver, IConnectionsObserver connectionsObserver, IControlMessageObserver controlMessageObserver, - ISecureConnectionObserver secureConnectionObserver, - ISslConfigurationProvider sslConfigurationProvider + ISecureConnectionObserver secureConnectionObserver ) { this.serverCertificate = serverCertificate; @@ -81,10 +79,9 @@ ISslConfigurationProvider sslConfigurationProvider this.connectionsObserver = connectionsObserver; this.secureConnectionObserver = secureConnectionObserver; this.controlMessageObserver = controlMessageObserver; - this.sslConfigurationProvider = sslConfigurationProvider; connectionManager = new ConnectionManagerAsync(); - tcpConnectionFactory = new TcpConnectionFactory(serverCertificate, TimeoutsAndLimits, streamFactory, secureConnectionObserver, sslConfigurationProvider); + tcpConnectionFactory = new TcpConnectionFactory(serverCertificate, TimeoutsAndLimits, streamFactory, secureConnectionObserver); activeTcpConnectionsLimiter = new ActiveTcpConnectionsLimiter(TimeoutsAndLimits); } @@ -139,8 +136,7 @@ public int Listen(IPEndPoint endpoint) TimeoutsAndLimits, streamFactory, connectionsObserver, - secureConnectionObserver, - sslConfigurationProvider + secureConnectionObserver ); listeners.DoWithExclusiveAccess(l => @@ -256,7 +252,7 @@ public async Task DiscoverAsync(Uri uri, CancellationToken canc public async Task DiscoverAsync(ServiceEndPoint endpoint, CancellationToken cancellationToken) { - var client = new DiscoveryClient(streamFactory, sslConfigurationProvider); + var client = new DiscoveryClient(streamFactory); return await client.DiscoverAsync(endpoint, TimeoutsAndLimits, cancellationToken); } diff --git a/source/Halibut/HalibutRuntimeBuilder.cs b/source/Halibut/HalibutRuntimeBuilder.cs index acd85cc65..6bb3e7313 100644 --- a/source/Halibut/HalibutRuntimeBuilder.cs +++ b/source/Halibut/HalibutRuntimeBuilder.cs @@ -32,7 +32,6 @@ public class HalibutRuntimeBuilder ISecureConnectionObserver? secureConnectionObserver; IControlMessageObserver? controlMessageObserver; MessageStreamWrappers queueMessageStreamWrappers = new(); - ISslConfigurationProvider? sslConfigurationProvider; public HalibutRuntimeBuilder WithQueueMessageStreamWrappers(MessageStreamWrappers queueMessageStreamWrappers) { @@ -52,12 +51,6 @@ public HalibutRuntimeBuilder WithSecureConnectionObserver(ISecureConnectionObser return this; } - public HalibutRuntimeBuilder WithSslConfigurationProvider(ISslConfigurationProvider sslConfigurationProvider) - { - this.sslConfigurationProvider = sslConfigurationProvider; - return this; - } - internal HalibutRuntimeBuilder WithStreamFactory(IStreamFactory streamFactory) { this.streamFactory = streamFactory; @@ -193,7 +186,6 @@ public HalibutRuntime Build() var secureConnectionObserver = this.secureConnectionObserver ?? NoOpSecureConnectionObserver.Instance; var rpcObserver = this.rpcObserver ?? new NoRpcObserver(); var controlMessageObserver = this.controlMessageObserver ?? new NoOpControlMessageObserver(); - var sslConfigurationProvider = this.sslConfigurationProvider ?? SslConfiguration.Default; var halibutRuntime = new HalibutRuntime( serviceFactory, @@ -209,8 +201,7 @@ public HalibutRuntime Build() rpcObserver, connectionsObserver, controlMessageObserver, - secureConnectionObserver, - sslConfigurationProvider + secureConnectionObserver ); if (onUnauthorizedClientConnect is not null) diff --git a/source/Halibut/Transport/DefaultSslConfigurationProvider.cs b/source/Halibut/Transport/DefaultSslConfigurationProvider.cs deleted file mode 100644 index ad354eddc..000000000 --- a/source/Halibut/Transport/DefaultSslConfigurationProvider.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2012-2013 Octopus Deploy Pty. Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Security.Authentication; - -namespace Halibut.Transport -{ - /// - /// Provides a default implementation of ISslConfigurationProvider that uses the system defaults. - /// - public class DefaultSslConfigurationProvider : ISslConfigurationProvider - { - public SslProtocols SupportedProtocols => SslProtocols.None; - } -} \ No newline at end of file diff --git a/source/Halibut/Transport/DiscoveryClient.cs b/source/Halibut/Transport/DiscoveryClient.cs index a631c32ec..c92280702 100644 --- a/source/Halibut/Transport/DiscoveryClient.cs +++ b/source/Halibut/Transport/DiscoveryClient.cs @@ -16,17 +16,10 @@ public class DiscoveryClient readonly LogFactory logs = new (); readonly IStreamFactory streamFactory; - readonly ISslConfigurationProvider sslConfigurationProvider; public DiscoveryClient(IStreamFactory streamFactory) - : this(streamFactory, SslConfiguration.Default) - { - } - - public DiscoveryClient(IStreamFactory streamFactory, ISslConfigurationProvider sslConfigurationProvider) { this.streamFactory = streamFactory; - this.sslConfigurationProvider = sslConfigurationProvider; } public async Task DiscoverAsync(ServiceEndPoint serviceEndpoint, HalibutTimeoutsAndLimits halibutTimeoutsAndLimits, CancellationToken cancellationToken) @@ -52,13 +45,12 @@ public async Task DiscoverAsync(ServiceEndPoint serviceEndpoint await ssl.AuthenticateAsClientAsync( serviceEndpoint.BaseUri.Host, new X509Certificate2Collection(), - sslConfigurationProvider.SupportedProtocols, + SslConfiguration.SupportedProtocols, false); #else await ssl.AuthenticateAsClientEnforcingTimeout( serviceEndpoint, new X509Certificate2Collection(), - sslConfigurationProvider, cancellationToken ); #endif diff --git a/source/Halibut/Transport/ISslConfigurationProvider.cs b/source/Halibut/Transport/ISslConfigurationProvider.cs deleted file mode 100644 index 952b45e51..000000000 --- a/source/Halibut/Transport/ISslConfigurationProvider.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2012-2013 Octopus Deploy Pty. Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System.Security.Authentication; - -namespace Halibut.Transport -{ - public interface ISslConfigurationProvider - { - public SslProtocols SupportedProtocols { get; } - } -} \ No newline at end of file diff --git a/source/Halibut/Transport/LegacySslConfigurationProvider.cs b/source/Halibut/Transport/LegacySslConfigurationProvider.cs deleted file mode 100644 index 67c92fa90..000000000 --- a/source/Halibut/Transport/LegacySslConfigurationProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2012-2013 Octopus Deploy Pty. Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Security.Authentication; - -namespace Halibut.Transport -{ - /// - /// An implementation of ISslConfigurationProvider that uses legacy TLS protocols (1.0 and 1.1) - /// in addition to modern ones. Protocols are explicitly specified rather than using system - /// defaults. - /// - public class LegacySslConfigurationProvider : ISslConfigurationProvider - { -#pragma warning disable SYSLIB0039 - // See https://learn.microsoft.com/en-us/dotnet/fundamentals/syslib-diagnostics/syslib0039 - // TLS 1.0 and 1.1 are obsolete from .NET 7 - public SslProtocols SupportedProtocols => SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13; -#pragma warning restore SYSLIB0039 - } -} \ No newline at end of file diff --git a/source/Halibut/Transport/SecureListener.cs b/source/Halibut/Transport/SecureListener.cs index 6a48bde2a..5132e1a4e 100644 --- a/source/Halibut/Transport/SecureListener.cs +++ b/source/Halibut/Transport/SecureListener.cs @@ -49,7 +49,6 @@ public class SecureListener : IAsyncDisposable readonly IStreamFactory streamFactory; readonly IConnectionsObserver connectionsObserver; readonly ISecureConnectionObserver secureConnectionObserver; - readonly ISslConfigurationProvider sslConfigurationProvider; ILog log; TcpListener listener; Thread? backgroundThread; @@ -70,8 +69,7 @@ public SecureListener( HalibutTimeoutsAndLimits halibutTimeoutsAndLimits, IStreamFactory streamFactory, IConnectionsObserver connectionsObserver, - ISecureConnectionObserver secureConnectionObserver, - ISslConfigurationProvider sslConfigurationProvider + ISecureConnectionObserver secureConnectionObserver ) { this.endPoint = endPoint; @@ -87,7 +85,6 @@ ISslConfigurationProvider sslConfigurationProvider this.streamFactory = streamFactory; this.connectionsObserver = connectionsObserver; this.secureConnectionObserver = secureConnectionObserver; - this.sslConfigurationProvider = sslConfigurationProvider; this.cts = new CancellationTokenSource(); this.cancellationToken = cts.Token; @@ -312,7 +309,7 @@ await ssl .AuthenticateAsServerAsync( serverCertificate, true, - sslConfigurationProvider.SupportedProtocols, + SslConfiguration.SupportedProtocols, false) .ConfigureAwait(false); diff --git a/source/Halibut/Transport/SslConfiguration.cs b/source/Halibut/Transport/SslConfiguration.cs index 254a7c41c..0626651bd 100644 --- a/source/Halibut/Transport/SslConfiguration.cs +++ b/source/Halibut/Transport/SslConfiguration.cs @@ -1,12 +1,9 @@ +using System.Security.Authentication; + namespace Halibut.Transport { public static class SslConfiguration { - public static ISslConfigurationProvider Default { get; } -#if NETFRAMEWORK // .NET4.8 exhibited inconsistent behavior when using the default configuration - = new LegacySslConfigurationProvider(); -#else - = new DefaultSslConfigurationProvider(); -#endif + public static SslProtocols SupportedProtocols => SslProtocols.None; // None means system defaults } } \ No newline at end of file diff --git a/source/Halibut/Transport/Streams/SslStreamExtensionMethods.cs b/source/Halibut/Transport/Streams/SslStreamExtensionMethods.cs index aceddd438..857899c09 100644 --- a/source/Halibut/Transport/Streams/SslStreamExtensionMethods.cs +++ b/source/Halibut/Transport/Streams/SslStreamExtensionMethods.cs @@ -13,7 +13,6 @@ internal static async Task AuthenticateAsClientEnforcingTimeout( this SslStream ssl, ServiceEndPoint serviceEndpoint, X509Certificate2Collection clientCertificates, - ISslConfigurationProvider sslConfigurationProvider, CancellationToken cancellationToken) { using var timeoutCts = new CancellationTokenSource(ssl.ReadTimeout); @@ -23,7 +22,7 @@ internal static async Task AuthenticateAsClientEnforcingTimeout( { TargetHost = serviceEndpoint.BaseUri.Host, ClientCertificates = clientCertificates, - EnabledSslProtocols = sslConfigurationProvider.SupportedProtocols, + EnabledSslProtocols = SslConfiguration.SupportedProtocols, CertificateRevocationCheckMode = X509RevocationMode.NoCheck }; diff --git a/source/Halibut/Transport/TcpConnectionFactory.cs b/source/Halibut/Transport/TcpConnectionFactory.cs index b61e190e5..10750ee97 100644 --- a/source/Halibut/Transport/TcpConnectionFactory.cs +++ b/source/Halibut/Transport/TcpConnectionFactory.cs @@ -22,21 +22,18 @@ public class TcpConnectionFactory : IConnectionFactory readonly HalibutTimeoutsAndLimits halibutTimeoutsAndLimits; readonly IStreamFactory streamFactory; readonly ISecureConnectionObserver secureConnectionObserver; - readonly ISslConfigurationProvider sslConfigurationProvider; public TcpConnectionFactory( X509Certificate2 clientCertificate, HalibutTimeoutsAndLimits halibutTimeoutsAndLimits, IStreamFactory streamFactory, - ISecureConnectionObserver secureConnectionObserver, - ISslConfigurationProvider sslConfigurationProvider + ISecureConnectionObserver secureConnectionObserver ) { this.clientCertificate = clientCertificate; this.halibutTimeoutsAndLimits = halibutTimeoutsAndLimits; this.streamFactory = streamFactory; this.secureConnectionObserver = secureConnectionObserver; - this.sslConfigurationProvider = sslConfigurationProvider; } public async Task EstablishNewConnectionAsync(ExchangeProtocolBuilder exchangeProtocolBuilder, ServiceEndPoint serviceEndpoint, ILog log, CancellationToken cancellationToken) @@ -61,10 +58,10 @@ public async Task EstablishNewConnectionAsync(ExchangeProtocolBuild await ssl.AuthenticateAsClientAsync( serviceEndpoint.BaseUri.Host, new X509Certificate2Collection(clientCertificate), - sslConfigurationProvider.SupportedProtocols, + SslConfiguration.SupportedProtocols, false); #else - await ssl.AuthenticateAsClientEnforcingTimeout(serviceEndpoint, new X509Certificate2Collection(clientCertificate), sslConfigurationProvider, cancellationToken); + await ssl.AuthenticateAsClientEnforcingTimeout(serviceEndpoint, new X509Certificate2Collection(clientCertificate), cancellationToken); #endif await ssl.WriteAsync(MxLine, 0, MxLine.Length, cancellationToken); From 3d39af82920c070ea1ffe687cd43ad75639e66a9 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Wed, 1 Apr 2026 10:46:05 +1000 Subject: [PATCH 02/10] Add extra tests to confirm SChannel cache is clean --- ...estUtils.CompatBinary.SchannelProbe.csproj | 23 +++ .../Program.cs | 13 ++ source/Halibut.Tests/Halibut.Tests.csproj | 1 + .../SchannelSessionCacheFixture.cs | 172 ++++++++++++++++++ .../HalibutTestBinaryPath.cs | 16 +- .../SchannelProbeBinaryRunner.cs | 128 +++++++++++++ source/Halibut.sln | 14 ++ 7 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj create mode 100644 source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs create mode 100644 source/Halibut.Tests/SchannelSessionCacheFixture.cs create mode 100644 source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs diff --git a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj new file mode 100644 index 000000000..34156a448 --- /dev/null +++ b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj @@ -0,0 +1,23 @@ + + + + Exe + 9.0 + Halibut.TestUtils.SampleProgram.SchannelProbe + enable + true + + + + net48;net8.0 + + + net8.0 + + + + + + + + diff --git a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs new file mode 100644 index 000000000..dd623f5d8 --- /dev/null +++ b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using Halibut.TestUtils.SampleProgram.Base; + +namespace Halibut.TestUtils.SampleProgram.SchannelProbe +{ + public class Program + { + public static async Task Main() + { + return await BackwardsCompatProgramBase.Main(); + } + } +} diff --git a/source/Halibut.Tests/Halibut.Tests.csproj b/source/Halibut.Tests/Halibut.Tests.csproj index 602280ad8..d5d3051aa 100644 --- a/source/Halibut.Tests/Halibut.Tests.csproj +++ b/source/Halibut.Tests/Halibut.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/source/Halibut.Tests/SchannelSessionCacheFixture.cs b/source/Halibut.Tests/SchannelSessionCacheFixture.cs new file mode 100644 index 000000000..e8369c5e8 --- /dev/null +++ b/source/Halibut.Tests/SchannelSessionCacheFixture.cs @@ -0,0 +1,172 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Halibut.Diagnostics.LogCreators; +using Halibut.Tests.Support; +using Halibut.Tests.Support.BackwardsCompatibility; +using Halibut.Tests.Support.Logging; +using Halibut.Tests.TestServices.Async; +using Halibut.Tests.Util; +using Halibut.TestUtils.Contracts; +using NUnit.Framework; + +namespace Halibut.Tests +{ + /// + /// Proves that process isolation prevents SChannel session cache collisions. + /// + /// In-process, a single acting as both TLS server and TLS + /// client to localhost with two different certificates can collide in the SChannel session + /// cache (Windows). Running the tentacle in a separate process avoids this because the + /// SChannel session cache is per-process. + /// + /// These tests verify that two separate processes — one listening tentacle and one polling + /// tentacle — both using the same certificate and both connecting via localhost, can + /// simultaneously communicate successfully with an in-process Octopus server. + /// + [TestFixture] + [NonParallelizable] + public class SchannelSessionCacheFixture : BaseTest + { + [Test] + public async Task ListeningTentacleInSeparateProcessCanCommunicateWithOctopus() + { + using var tmpDirectory = new TmpDirectory(); + var octopusCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tentacleCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + + var octopus = new HalibutRuntimeBuilder() + .WithServerCertificate(octopusCert.Certificate2) + .WithLogFactory(new TestContextLogCreator("Octopus", Logging.LogLevel.Trace).ToCachingLogFactory()) + .WithHalibutTimeoutsAndLimits(new HalibutTimeoutsAndLimitsForTestsBuilder().Build()) + .Build(); + + await using var _ = new AsyncDisposableAction(async () => await octopus.DisposeAsync()); + + octopus.Trust(tentacleCert.Thumbprint); + + using var runningTentacle = await new SchannelProbeBinaryRunner( + ServiceConnectionType.Listening, + clientListenPort: null, + clientCertAndThumbprint: octopusCert, + serviceCertAndThumbprint: tentacleCert, + logger: Logger).Run(); + + runningTentacle.ServiceListenPort.Should().NotBeNull("listening tentacle should have reported its port"); + + var serviceUri = new Uri($"https://localhost:{runningTentacle.ServiceListenPort}"); + var serviceEndPoint = new ServiceEndPoint(serviceUri, tentacleCert.Thumbprint, octopus.TimeoutsAndLimits); + + var echo = octopus.CreateAsyncClient(serviceEndPoint); + var result = await echo.SayHelloAsync("world"); + + result.Should().Be("world..."); + } + + [Test] + public async Task PollingTentacleInSeparateProcessCanCommunicateWithOctopus() + { + using var tmpDirectory = new TmpDirectory(); + var octopusCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tentacleCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + + var octopus = new HalibutRuntimeBuilder() + .WithServerCertificate(octopusCert.Certificate2) + .WithLogFactory(new TestContextLogCreator("Octopus", Logging.LogLevel.Trace).ToCachingLogFactory()) + .WithHalibutTimeoutsAndLimits(new HalibutTimeoutsAndLimitsForTestsBuilder().Build()) + .Build(); + + await using var _ = new AsyncDisposableAction(async () => await octopus.DisposeAsync()); + + octopus.Trust(tentacleCert.Thumbprint); + var pollingListenPort = octopus.Listen(); + + using var runningTentacle = await new SchannelProbeBinaryRunner( + ServiceConnectionType.Polling, + clientListenPort: pollingListenPort, + clientCertAndThumbprint: octopusCert, + serviceCertAndThumbprint: tentacleCert, + logger: Logger).Run(); + + var serviceUri = new Uri("poll://SQ-TENTAPOLL"); + var serviceEndPoint = new ServiceEndPoint(serviceUri, tentacleCert.Thumbprint, octopus.TimeoutsAndLimits); + + var echo = octopus.CreateAsyncClient(serviceEndPoint); + var result = await echo.SayHelloAsync("world"); + + result.Should().Be("world..."); + } + + [Test] + public async Task ListeningAndPollingTentaclesInSeparateProcessesCanSimultaneouslyCommunicateWithOctopus() + { + using var tmpDirectory = new TmpDirectory(); + var octopusCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + // Both tentacles intentionally share the same certificate to maximise the chance of + // triggering an SChannel session-cache collision if process isolation were absent. + var sharedTentacleCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + + var octopus = new HalibutRuntimeBuilder() + .WithServerCertificate(octopusCert.Certificate2) + .WithLogFactory(new TestContextLogCreator("Octopus", Logging.LogLevel.Trace).ToCachingLogFactory()) + .WithHalibutTimeoutsAndLimits(new HalibutTimeoutsAndLimitsForTestsBuilder().Build()) + .Build(); + + await using var _ = new AsyncDisposableAction(async () => await octopus.DisposeAsync()); + + octopus.Trust(sharedTentacleCert.Thumbprint); + var pollingListenPort = octopus.Listen(); + + // Start listening tentacle + using var listeningTentacle = await new SchannelProbeBinaryRunner( + ServiceConnectionType.Listening, + clientListenPort: null, + clientCertAndThumbprint: octopusCert, + serviceCertAndThumbprint: sharedTentacleCert, + logger: Logger).Run(); + + listeningTentacle.ServiceListenPort.Should().NotBeNull("listening tentacle should have reported its port"); + + // Start polling tentacle + using var pollingTentacle = await new SchannelProbeBinaryRunner( + ServiceConnectionType.Polling, + clientListenPort: pollingListenPort, + clientCertAndThumbprint: octopusCert, + serviceCertAndThumbprint: sharedTentacleCert, + logger: Logger).Run(); + + var listeningServiceUri = new Uri($"https://localhost:{listeningTentacle.ServiceListenPort}"); + var listeningEndPoint = new ServiceEndPoint(listeningServiceUri, sharedTentacleCert.Thumbprint, octopus.TimeoutsAndLimits); + + var pollingServiceUri = new Uri("poll://SQ-TENTAPOLL"); + var pollingEndPoint = new ServiceEndPoint(pollingServiceUri, sharedTentacleCert.Thumbprint, octopus.TimeoutsAndLimits); + + var listeningEcho = octopus.CreateAsyncClient(listeningEndPoint); + var pollingEcho = octopus.CreateAsyncClient(pollingEndPoint); + + // Call both simultaneously + var listeningTask = listeningEcho.SayHelloAsync("from-listening"); + var pollingTask = pollingEcho.SayHelloAsync("from-polling"); + + var results = await Task.WhenAll(listeningTask, pollingTask); + + results[0].Should().Be("from-listening..."); + results[1].Should().Be("from-polling..."); + } + } + + class AsyncDisposableAction : IAsyncDisposable + { + readonly Func action; + + public AsyncDisposableAction(Func action) + { + this.action = action; + } + + public async ValueTask DisposeAsync() + { + await action(); + } + } +} diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryPath.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryPath.cs index 3860e7173..b7a0675ce 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryPath.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryPath.cs @@ -9,15 +9,25 @@ public class HalibutTestBinaryPath public string BinPath(string version) { var onDiskVersion = version.Replace(".", "_"); + var projectName = $"Halibut.TestUtils.CompatBinary.v{onDiskVersion}"; + return ResolveProjectBinPath(projectName); + } + + public string SchannelProbeBinPath() + { + return ResolveProjectBinPath("Halibut.TestUtils.CompatBinary.SchannelProbe"); + } + + string ResolveProjectBinPath(string projectName) + { var assemblyDir = new DirectoryInfo(Path.GetDirectoryName(typeof(HalibutTestBinaryRunner).Assembly.Location)!); var upAt = assemblyDir.Parent!.Parent!.Parent!.Parent!; - var projectName = $"Halibut.TestUtils.CompatBinary.v{onDiskVersion}"; var executable = Path.Combine(upAt.FullName, projectName, assemblyDir.Parent.Parent.Name, assemblyDir.Parent.Name, assemblyDir.Name, projectName); executable = AddExeForWindows(executable); if (!File.Exists(executable)) { - throw new Exception("Could not executable at path:\n" + + throw new Exception("Could not find executable at path:\n" + executable + "\n" + $"Did you forget to update the csproj to depend on {projectName}\n" + "If testing a previously untested version of Halibut a new project may be required."); @@ -25,7 +35,7 @@ public string BinPath(string version) return executable; } - + string AddExeForWindows(string path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return path + ".exe"; diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs new file mode 100644 index 000000000..ea0248fe7 --- /dev/null +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using CliWrap; +using Halibut.Logging; +using Serilog; + +namespace Halibut.Tests.Support.BackwardsCompatibility +{ + public class SchannelProbeBinaryRunner + { + readonly ServiceConnectionType serviceConnectionType; + readonly int? clientListenPort; + readonly CertAndThumbprint clientCertAndThumbprint; + readonly CertAndThumbprint serviceCertAndThumbprint; + readonly ILogger logger; + + /// + /// Launches the SchannelProbe binary as a listening tentacle (server dials it) or a + /// polling tentacle (it dials the server). Uses the current version of Halibut. + /// + public SchannelProbeBinaryRunner( + ServiceConnectionType serviceConnectionType, + int? clientListenPort, + CertAndThumbprint clientCertAndThumbprint, + CertAndThumbprint serviceCertAndThumbprint, + ILogger logger) + { + this.serviceConnectionType = serviceConnectionType; + this.clientListenPort = clientListenPort; + this.clientCertAndThumbprint = clientCertAndThumbprint; + this.serviceCertAndThumbprint = serviceCertAndThumbprint; + this.logger = logger.ForContext(); + } + + public async Task Run() + { + var compatBinaryStayAlive = new CompatBinaryStayAlive(logger); + + var settings = new Dictionary + { + { "mode", "serviceonly" }, + { "tentaclecertpath", serviceCertAndThumbprint.CertificatePfxPath }, + { "octopusthumbprint", clientCertAndThumbprint.Thumbprint }, + { "halibutloglevel", LogLevel.Info.ToString() }, + { CompatBinaryStayAlive.StayAliveFilePathEnvVarKey, compatBinaryStayAlive.LockFile }, + { "WithStandardServices", true.ToString() }, + { "WithCachingService", false.ToString() }, + { "WithTentacleServices", false.ToString() }, + { "ServiceConnectionType", serviceConnectionType.ToString() }, + }; + + if (serviceConnectionType == ServiceConnectionType.Polling && clientListenPort.HasValue) + settings.Add("octopusservercommsport", "https://localhost:" + clientListenPort.Value); + + var cts = new CancellationTokenSource(); + var hasStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int? serviceListenPort = null; + + var runningTask = Task.Run(async () => + { + try + { + await Cli.Wrap(new HalibutTestBinaryPath().SchannelProbeBinPath()) + .WithEnvironmentVariables(settings) + .WithStandardOutputPipe(PipeTarget.ToDelegate((line, _) => + { + logger.Information(line); + if (line.StartsWith("Listening on port: ")) + serviceListenPort = int.Parse(Regex.Match(line, @"\d+").Value); + if (line.Contains("RunningAndReady")) + hasStarted.TrySetResult(true); + return Task.CompletedTask; + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate((line, _) => + { + logger.Information(line); + return Task.CompletedTask; + })) + .ExecuteAsync(cts.Token); + } + catch (OperationCanceledException) { } + catch (Exception e) + { + hasStarted.TrySetException(e); + throw; + } + }); + + var winner = await Task.WhenAny(runningTask, hasStarted.Task, Task.Delay(TimeSpan.FromSeconds(30))); + + if (winner == runningTask || !hasStarted.Task.IsCompleted) + { + cts.Cancel(); + cts.Dispose(); + compatBinaryStayAlive.Dispose(); + if (winner == runningTask) await runningTask; // re-throw startup exception + throw new Exception("SchannelProbe binary did not start within 30 seconds"); + } + + return new RunningSchannelProbe(cts, serviceListenPort, compatBinaryStayAlive); + } + + public class RunningSchannelProbe : IDisposable + { + readonly CancellationTokenSource cts; + readonly CompatBinaryStayAlive compatBinaryStayAlive; + + public int? ServiceListenPort { get; } + + public RunningSchannelProbe(CancellationTokenSource cts, int? serviceListenPort, CompatBinaryStayAlive compatBinaryStayAlive) + { + this.cts = cts; + this.compatBinaryStayAlive = compatBinaryStayAlive; + ServiceListenPort = serviceListenPort; + } + + public void Dispose() + { + cts.Cancel(); + cts.Dispose(); + compatBinaryStayAlive.Dispose(); + } + } + } +} diff --git a/source/Halibut.sln b/source/Halibut.sln index 05e2e010b..fcb21f36a 100644 --- a/source/Halibut.sln +++ b/source/Halibut.sln @@ -41,6 +41,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Halibut.TestUtils.CompatBinary.SchannelProbe", "Halibut.TestUtils.CompatBinary.SchannelProbe\Halibut.TestUtils.CompatBinary.SchannelProbe.csproj", "{4D26A9FA-B316-4BE3-8780-23E9136492DB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -177,6 +179,18 @@ Global {80042E24-9461-47D1-9AC5-E414E4DBF821}.Release|Mixed Platforms.Build.0 = Release|Any CPU {80042E24-9461-47D1-9AC5-E414E4DBF821}.Release|x86.ActiveCfg = Release|Any CPU {80042E24-9461-47D1-9AC5-E414E4DBF821}.Release|x86.Build.0 = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Debug|x86.Build.0 = Debug|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|Any CPU.Build.0 = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|x86.ActiveCfg = Release|Any CPU + {4D26A9FA-B316-4BE3-8780-23E9136492DB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From e7a052db1679b45837cbe90ec817d01dc44cdab5 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Wed, 1 Apr 2026 11:37:16 +1000 Subject: [PATCH 03/10] Fix test to run on Windows --- ...estUtils.CompatBinary.SchannelProbe.csproj | 2 +- .../Program.cs | 128 +++++++++++++++++- .../ISayHelloService.cs | 15 ++ .../SchannelSessionCacheFixture.cs | 8 +- .../Async/IAsyncClientSayHelloService.cs | 9 ++ 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 source/Halibut.TestUtils.Contracts/ISayHelloService.cs create mode 100644 source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs diff --git a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj index 34156a448..e5382f162 100644 --- a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj +++ b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj @@ -17,7 +17,7 @@ - + diff --git a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs index dd623f5d8..8adbeee15 100644 --- a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs +++ b/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs @@ -1,5 +1,12 @@ +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; -using Halibut.TestUtils.SampleProgram.Base; +using Halibut; +using Halibut.Diagnostics; +using Halibut.ServiceModel; +using Halibut.TestUtils.Contracts; namespace Halibut.TestUtils.SampleProgram.SchannelProbe { @@ -7,7 +14,124 @@ public class Program { public static async Task Main() { - return await BackwardsCompatProgramBase.Main(); + using var cts = new CancellationTokenSource(GetTestTimeout()); + using var _ = cts.Token.Register(() => Environment.Exit(-10060)); + + var mode = GetSetting("mode"); + Console.WriteLine($"Mode is: {mode}"); + + if (mode.Equals("serviceonly", StringComparison.OrdinalIgnoreCase)) + { + await RunExternalService(cts.Token); + } + else + { + Console.WriteLine($"Unknown mode: {mode}"); + throw new Exception($"Unknown mode: {mode}"); + } + + return 1; + } + + static async Task RunExternalService(CancellationToken cancellationToken) + { + var serviceCert = new X509Certificate2(GetSetting("tentaclecertpath")); + var octopusThumbprint = GetSetting("octopusthumbprint"); + var serviceConnectionType = ParseServiceConnectionType(GetSetting("ServiceConnectionType")); + + var services = new DelegateServiceFactory(); + services.Register(() => new SayHelloServiceImpl()); + + using var tentacle = new HalibutRuntimeBuilder() + .WithServiceFactory(services) + .WithServerCertificate(serviceCert) + .WithLogFactory(new LogFactory()) + .Build(); + + switch (serviceConnectionType) + { + case ServiceConnectionType.Polling: + var addressToPoll = GetSetting("octopusservercommsport"); + tentacle.Poll( + new Uri("poll://SQ-TENTAPOLL"), + new ServiceEndPoint(new Uri(addressToPoll), octopusThumbprint, null, new HalibutTimeoutsAndLimits()), + cancellationToken); + break; + case ServiceConnectionType.Listening: + var port = tentacle.Listen(); + Console.WriteLine($"Listening on port: {port}"); + tentacle.Trust(octopusThumbprint); + break; + default: + throw new ArgumentOutOfRangeException(nameof(serviceConnectionType)); + } + + Console.WriteLine("RunningAndReady"); + await Console.Out.FlushAsync(); + await WaitUntilSignaledToDie(); + } + + static async Task WaitUntilSignaledToDie() + { + var stayAliveFile = GetSetting("CompatBinaryStayAliveFilePath"); + while (true) + { + try + { + using (new FileStream(stayAliveFile, FileMode.Open, FileAccess.Read, FileShare.None)) + { + } + + try + { + File.Delete(stayAliveFile); + } + finally + { + Environment.Exit(0); + } + } + catch (Exception) + { + } + + if (!File.Exists(stayAliveFile)) + { + Environment.Exit(0); + } + + await Task.Delay(2000); + } + } + + static ServiceConnectionType ParseServiceConnectionType(string s) + { + if (Enum.TryParse(s, out ServiceConnectionType result)) + return result; + throw new Exception($"Unknown service connection type '{s}'"); + } + + static TimeSpan GetTestTimeout() + { + var timeoutString = GetSetting("TestTimeout"); + return string.IsNullOrWhiteSpace(timeoutString) ? TimeSpan.FromMinutes(15) : TimeSpan.Parse(timeoutString); + } + + static string GetSetting(string name) => Environment.GetEnvironmentVariable(name) ?? string.Empty; + } + + enum ServiceConnectionType + { + Polling, + Listening + } + + class SayHelloServiceImpl : IAsyncSayHelloService + { + public async Task SayHelloAsync(string name, CancellationToken cancellationToken) + { + await Task.CompletedTask; + return name + "..."; } } } diff --git a/source/Halibut.TestUtils.Contracts/ISayHelloService.cs b/source/Halibut.TestUtils.Contracts/ISayHelloService.cs new file mode 100644 index 000000000..76ad6aee7 --- /dev/null +++ b/source/Halibut.TestUtils.Contracts/ISayHelloService.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Halibut.TestUtils.Contracts +{ + public interface ISayHelloService + { + string SayHello(string name); + } + + public interface IAsyncSayHelloService + { + Task SayHelloAsync(string name, CancellationToken cancellationToken); + } +} diff --git a/source/Halibut.Tests/SchannelSessionCacheFixture.cs b/source/Halibut.Tests/SchannelSessionCacheFixture.cs index e8369c5e8..a26a964bb 100644 --- a/source/Halibut.Tests/SchannelSessionCacheFixture.cs +++ b/source/Halibut.Tests/SchannelSessionCacheFixture.cs @@ -57,7 +57,7 @@ public async Task ListeningTentacleInSeparateProcessCanCommunicateWithOctopus() var serviceUri = new Uri($"https://localhost:{runningTentacle.ServiceListenPort}"); var serviceEndPoint = new ServiceEndPoint(serviceUri, tentacleCert.Thumbprint, octopus.TimeoutsAndLimits); - var echo = octopus.CreateAsyncClient(serviceEndPoint); + var echo = octopus.CreateAsyncClient(serviceEndPoint); var result = await echo.SayHelloAsync("world"); result.Should().Be("world..."); @@ -91,7 +91,7 @@ public async Task PollingTentacleInSeparateProcessCanCommunicateWithOctopus() var serviceUri = new Uri("poll://SQ-TENTAPOLL"); var serviceEndPoint = new ServiceEndPoint(serviceUri, tentacleCert.Thumbprint, octopus.TimeoutsAndLimits); - var echo = octopus.CreateAsyncClient(serviceEndPoint); + var echo = octopus.CreateAsyncClient(serviceEndPoint); var result = await echo.SayHelloAsync("world"); result.Should().Be("world..."); @@ -141,8 +141,8 @@ public async Task ListeningAndPollingTentaclesInSeparateProcessesCanSimultaneous var pollingServiceUri = new Uri("poll://SQ-TENTAPOLL"); var pollingEndPoint = new ServiceEndPoint(pollingServiceUri, sharedTentacleCert.Thumbprint, octopus.TimeoutsAndLimits); - var listeningEcho = octopus.CreateAsyncClient(listeningEndPoint); - var pollingEcho = octopus.CreateAsyncClient(pollingEndPoint); + var listeningEcho = octopus.CreateAsyncClient(listeningEndPoint); + var pollingEcho = octopus.CreateAsyncClient(pollingEndPoint); // Call both simultaneously var listeningTask = listeningEcho.SayHelloAsync("from-listening"); diff --git a/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs b/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs new file mode 100644 index 000000000..69c4d7b03 --- /dev/null +++ b/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Halibut.Tests.TestServices.Async +{ + public interface IAsyncClientSayHelloService + { + Task SayHelloAsync(string name); + } +} From d70285140747f20c0e525e7775dda439ca2dea5a Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Thu, 2 Apr 2026 15:48:00 +1000 Subject: [PATCH 04/10] MemoryFixture workaround --- .../Halibut.Tests.DotMemory/MemoryFixture.cs | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/source/Halibut.Tests.DotMemory/MemoryFixture.cs b/source/Halibut.Tests.DotMemory/MemoryFixture.cs index ca55418ab..7f4c1cf3d 100644 --- a/source/Halibut.Tests.DotMemory/MemoryFixture.cs +++ b/source/Halibut.Tests.DotMemory/MemoryFixture.cs @@ -59,11 +59,26 @@ public void TcpClientsAreDisposedCorrectly() .WriteTo.NUnitOutput() .CreateLogger(); + // Two separate HalibutRuntime instances are used to avoid an SChannel session cache + // collision on Windows (.NET Framework / SslProtocols.None). SChannel's TLS session + // cache is per-process and keyed on certificate + host. If a single runtime acts as + // both a TLS server (accepting inbound connections) and a TLS client (making outbound + // polling connections) using the same certificate, SChannel can incorrectly reuse a + // server-side session for a client-side handshake, causing SSPI/TLS failures. + // + // - server: pure TLS server — accepts inbound connections from listening tentacles. + // Uses Certificates.Octopus. + // - pollingServer: pure TLS client — only makes outbound polling connections to tentacles. + // Must use a DIFFERENT certificate (Certificates.TentacleListening) so + // that SChannel never sees the same cert in both server and client roles + // within this process. HalibutRuntime? server = null; + HalibutRuntime? pollingServer = null; try { server = RunServer(Certificates.Octopus, out var port); + pollingServer = RunPollingServer(Certificates.TentacleListening); var expectedTcpClientCount = 1; //server listen = 1 tcpclient //valid requests @@ -75,13 +90,15 @@ public void TcpClientsAreDisposedCorrectly() for (var i = 0; i < NumberOfClients; i++) { expectedTcpClientCount++; // each time the server polls, it keeps a tcpclient (as we dont have support to say StopPolling) - RunPollingClient(server, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint).GetAwaiter().GetResult(); + RunPollingClient(pollingServer, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint).GetAwaiter().GetResult(); } #if SUPPORTS_WEB_SOCKET_CLIENT + //setup polling websocket + AddSslCertToLocalStoreAndRegisterFor("0.0.0.0:8434"); for (var i = 0; i < NumberOfClients; i++) { - RunWebSocketPollingClient(server, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint, Certificates.OctopusPublicThumbprint).GetAwaiter().GetResult(); + RunWebSocketPollingClient(pollingServer, Certificates.TentaclePolling, Certificates.TentaclePollingPublicThumbprint, Certificates.TentacleListeningPublicThumbprint).GetAwaiter().GetResult(); } #endif @@ -106,6 +123,7 @@ public void TcpClientsAreDisposedCorrectly() finally { server?.DisposeAsync().GetAwaiter().GetResult(); + pollingServer?.DisposeAsync().GetAwaiter().GetResult(); } } @@ -142,16 +160,33 @@ static HalibutRuntime RunServer(X509Certificate2 serverCertificate, out int port .WithLogFactory(new TestContextLogFactory("client", LogLevel.Info)) .Build(); - //set up listening + // Trust the listening tentacle certificate for inbound connections. + // This runtime only accepts connections — it never makes outbound polling connections — + // keeping it in a pure TLS server role (see declaration comment above). server.Trust(Certificates.TentacleListeningPublicThumbprint); port = server.Listen(); - //setup polling websocket - AddSslCertToLocalStoreAndRegisterFor("0.0.0.0:8434"); - return server; } + // pollingServer intentionally uses Certificates.TentacleListening rather than + // Certificates.Octopus (which server uses). This keeps the two certificates in distinct + // TLS roles within this process: Octopus is used only as a TLS server cert (by server), + // and TentacleListening is used only as a TLS client cert (here, and in RunListeningClient). + // Using the same cert in both roles would trigger an SChannel session-cache collision on + // Windows with SslProtocols.None (see declaration comment above). + static HalibutRuntime RunPollingServer(X509Certificate2 serverCertificate) + { + var services = new DelegateServiceFactory(); + services.Register(() => new AsyncCalculatorService()); + + return new HalibutRuntimeBuilder() + .WithServerCertificate(serverCertificate) + .WithServiceFactory(services) + .WithLogFactory(new TestContextLogFactory("polling-server", LogLevel.Info)) + .Build(); + } + static async Task RunListeningClient(X509Certificate2 clientCertificate, int port, string remoteThumbprint, bool expectSuccess = true) { await using (var runtime = new HalibutRuntimeBuilder().WithServerCertificate(clientCertificate).Build()) @@ -169,9 +204,13 @@ static async Task RunPollingClient(HalibutRuntime server, X509Certificate2 clien .Build()) { runtime.Listen(new IPEndPoint(IPAddress.IPv6Any, 8433)); - runtime.Trust(Certificates.OctopusPublicThumbprint); + // Trust the thumbprint of pollingServer's certificate (TentacleListening), which is + // the cert pollingServer presents when it dials in to establish the polling connection. + runtime.Trust(Certificates.TentacleListeningPublicThumbprint); //setup polling + // The remote thumbprint here is this runtime's own certificate (TentaclePolling), + // which pollingServer verifies when it connects to port 8433. var serverEndpoint = new ServiceEndPoint(new Uri("https://localhost:8433"), Certificates.TentaclePollingPublicThumbprint, runtime.TimeoutsAndLimits) { TcpClientConnectTimeout = TimeSpan.FromSeconds(5) From bdac20880e7a7c3bbe7c7cc2b66b052d8e097b55 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Wed, 15 Apr 2026 09:41:16 +1000 Subject: [PATCH 05/10] Generate unique certificates per test to fix SChannel session-cache collisions on net48 With SslProtocols.None on .NET Framework 4.8, Windows SChannel uses a per-process TLS session cache keyed on certificate + hostname. Reusing the same static certs (Octopus, TentacleListening, TentaclePolling) across tests in the same process causes SChannel to incorrectly reuse session entries when connecting to localhost, producing AuthenticationExceptions that cause ~202 test failures on net48. Fix by generating fresh unique certificates per test in: - LatestClientAndLatestServiceBuilder (Listening/Polling/PollingOverWebSocket factories) - SecureClientFixture (SetUp + SecureClientClearsPoolWhenAllConnectionsCorrupt) - ClientServerLifecycleTests (ListeningConfiguration/PollingConfiguration/ListeningThenPollingConfiguration) Tests that explicitly call WithCertificates(...) are unaffected. --- .../ClientServerLifecycleTests.cs | 43 ++++++++++++++----- .../LatestClientAndLatestServiceBuilder.cs | 32 +++++++++++--- .../Transport/SecureClientFixture.cs | 19 +++++--- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/source/Halibut.Tests/ClientServerLifecycleTests.cs b/source/Halibut.Tests/ClientServerLifecycleTests.cs index 730e4bf98..9b4680797 100644 --- a/source/Halibut.Tests/ClientServerLifecycleTests.cs +++ b/source/Halibut.Tests/ClientServerLifecycleTests.cs @@ -19,19 +19,40 @@ using Halibut.Diagnostics; using Halibut.ServiceModel; using Halibut.Tests.Support; +using Halibut.Tests.Util; using NUnit.Framework; namespace Halibut.Tests { public class ClientServerLifecycleTests : BaseTest { + TmpDirectory tmpDirectory = null!; + CertAndThumbprint serverCert = null!; + CertAndThumbprint listenerCert = null!; + CertAndThumbprint pollerCert = null!; + + [SetUp] + public void SetUpCerts() + { + tmpDirectory = new TmpDirectory(); + serverCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + listenerCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + pollerCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + } + + [TearDown] + public void TearDownCerts() + { + tmpDirectory?.Dispose(); + } + [Test] public async Task ListeningConfiguration() { await using var server = RunServer(out var serverPort); await using var runtime = CreateRuntimeForListener(); - var client = CreateClient(runtime, serverPort); + var client = CreateClient(runtime, serverPort, serverCert); var result = await client.AddAsync(2, 2); result.Should().Be(4); } @@ -56,7 +77,7 @@ public async Task ListeningThenPollingConfiguration() HalibutRuntime CreateRuntimeForListener() { var runtime = new HalibutRuntimeBuilder() - .WithServerCertificate(Certificates.TentacleListening) + .WithServerCertificate(listenerCert.Certificate2) .WithLogFactory(new TestLogFactory(HalibutLog)) .Build(); return runtime; @@ -65,15 +86,15 @@ HalibutRuntime CreateRuntimeForListener() HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncClientCalculatorService client) { var runtime = new HalibutRuntimeBuilder() - .WithServerCertificate(Certificates.TentaclePolling) + .WithServerCertificate(pollerCert.Certificate2) .WithLogFactory(new TestLogFactory(HalibutLog)) .Build(); var port = runtime.Listen(); - runtime.Trust(Certificates.OctopusPublicThumbprint); + runtime.Trust(serverCert.Thumbprint); var pollEndpoint = new ServiceEndPoint( baseUri: new Uri($"https://localhost:{port}/"), - remoteThumbprint: Certificates.TentaclePollingPublicThumbprint, + remoteThumbprint: pollerCert.Thumbprint, halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits ) { @@ -83,7 +104,7 @@ HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncCl serverRuntime.Poll(pollingUri, pollEndpoint, CancellationToken); var clientEndpoint = new ServiceEndPoint( baseUri: pollingUri, - remoteThumbprint: Certificates.OctopusPublicThumbprint, + remoteThumbprint: serverCert.Thumbprint, halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits ); client = runtime.CreateAsyncClient(clientEndpoint); @@ -91,11 +112,11 @@ HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncCl return runtime; } - static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port) + static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port, CertAndThumbprint serverCertAndThumbprint) { var endpoint = new ServiceEndPoint( baseUri: $"https://localhost:{port}", - remoteThumbprint: Certificates.OctopusPublicThumbprint, + remoteThumbprint: serverCertAndThumbprint.Thumbprint, halibutTimeoutsAndLimits: runtime.TimeoutsAndLimits ); var client = runtime @@ -115,13 +136,13 @@ HalibutRuntime RunServer(out int port) var services = CreateServiceFactory(); var runtime = new HalibutRuntimeBuilder() - .WithServerCertificate(Certificates.Octopus) + .WithServerCertificate(serverCert.Certificate2) .WithServiceFactory(services) .WithLogFactory(new TestLogFactory(HalibutLog)) .Build(); - runtime.Trust(Certificates.TentacleListeningPublicThumbprint); - runtime.Trust(Certificates.TentaclePollingPublicThumbprint); + runtime.Trust(listenerCert.Thumbprint); + runtime.Trust(pollerCert.Thumbprint); port = runtime.Listen(); return runtime; diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index b1d37fd0d..3385e9c27 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -9,6 +9,7 @@ using Halibut.TestProxy; using Halibut.Tests.Support.TestAttributes; using Halibut.Tests.TestServices; +using Halibut.Tests.Util; using Halibut.TestUtils.Contracts; using Halibut.TestUtils.Contracts.Tentacle.Services; using Halibut.Transport.Observability; @@ -31,6 +32,8 @@ public class LatestClientAndLatestServiceBuilder : IClientAndServiceBuilder readonly LatestClientBuilder clientBuilder; readonly LatestServiceBuilder serviceBuilder; + TmpDirectory? tmpDirectory; + ProxyFactory? proxyFactory; Reference? proxyServiceReference; @@ -52,17 +55,32 @@ public LatestClientAndLatestServiceBuilder( public static LatestClientAndLatestServiceBuilder Polling(PollingQueueTestCase pollingQueueTestCase) { - return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.Octopus, CertAndThumbprint.TentaclePolling, pollingQueueTestCase); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Polling, clientCert, serviceCert, pollingQueueTestCase); + builder.tmpDirectory = tmpDirectory; + return builder; } public static LatestClientAndLatestServiceBuilder PollingOverWebSocket(PollingQueueTestCase pollingQueueTestCase) { - return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.Ssl, CertAndThumbprint.TentaclePolling, pollingQueueTestCase); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, clientCert, serviceCert, pollingQueueTestCase); + builder.tmpDirectory = tmpDirectory; + return builder; } public static LatestClientAndLatestServiceBuilder Listening() { - return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.Octopus, CertAndThumbprint.TentacleListening, null); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Listening, clientCert, serviceCert, null); + builder.tmpDirectory = tmpDirectory; + return builder; } public static LatestClientAndLatestServiceBuilder ForServiceConnectionType(ServiceConnectionType serviceConnectionType, PollingQueueTestCase? pollingQueueTestCase = null) @@ -360,7 +378,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } } - return new ClientAndService(client, service, httpProxy); + return new ClientAndService(client, service, httpProxy, tmpDirectory); } public class ClientAndService : IClientAndService @@ -368,14 +386,17 @@ public class ClientAndService : IClientAndService readonly LatestClient client; readonly LatestService service; readonly HttpProxyService? httpProxy; + readonly TmpDirectory? tmpDirectory; public ClientAndService( LatestClient client, LatestService service, - HttpProxyService? proxy) + HttpProxyService? proxy, + TmpDirectory? tmpDirectory) { this.client = client; this.service = service; + this.tmpDirectory = tmpDirectory; httpProxy = proxy; } @@ -413,6 +434,7 @@ public async ValueTask DisposeAsync() void LogError(Exception e) => logger.Warning(e, "Ignoring error in dispose"); Try.CatchingError(() => httpProxy?.Dispose(), LogError); + Try.CatchingError(() => tmpDirectory?.Dispose(), LogError); } } } diff --git a/source/Halibut.Tests/Transport/SecureClientFixture.cs b/source/Halibut.Tests/Transport/SecureClientFixture.cs index c6aa33f7f..a5ffb18a0 100644 --- a/source/Halibut.Tests/Transport/SecureClientFixture.cs +++ b/source/Halibut.Tests/Transport/SecureClientFixture.cs @@ -10,6 +10,7 @@ using Halibut.Tests.Support; using Halibut.Tests.Support.Logging; using Halibut.Tests.TestServices; +using Halibut.Tests.Util; using Halibut.TestUtils.Contracts; using Halibut.Transport; using Halibut.Transport.Observability; @@ -27,21 +28,28 @@ public class SecureClientFixture : IAsyncDisposable ServiceEndPoint endpoint; HalibutRuntime tentacle; ILog log; + TmpDirectory tmpDirectory; + CertAndThumbprint tentacleCert; + CertAndThumbprint octopusCert; #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. [SetUp] public void SetUp() { + tmpDirectory = new TmpDirectory(); + tentacleCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + octopusCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var services = new DelegateServiceFactory(); services.Register(() => new AsyncEchoService()); tentacle = new HalibutRuntimeBuilder() - .WithServerCertificate(Certificates.TentacleListening) + .WithServerCertificate(tentacleCert.Certificate2) .WithServiceFactory(services) .WithHalibutTimeoutsAndLimits(new HalibutTimeoutsAndLimitsForTestsBuilder().Build()) .Build(); var tentaclePort = tentacle.Listen(); - tentacle.Trust(Certificates.OctopusPublicThumbprint); - endpoint = new ServiceEndPoint("https://localhost:" + tentaclePort, Certificates.TentacleListeningPublicThumbprint, tentacle.TimeoutsAndLimits) + tentacle.Trust(octopusCert.Thumbprint); + endpoint = new ServiceEndPoint("https://localhost:" + tentaclePort, tentacleCert.Thumbprint, tentacle.TimeoutsAndLimits) { ConnectionErrorRetryTimeout = TimeSpan.MaxValue }; @@ -51,6 +59,7 @@ public void SetUp() public async ValueTask DisposeAsync() { await tentacle.DisposeAsync(); + tmpDirectory?.Dispose(); } [Test] @@ -81,12 +90,12 @@ public async Task SecureClientClearsPoolWhenAllConnectionsCorrupt() }; var tcpConnectionFactory = new TcpConnectionFactory( - Certificates.Octopus, + octopusCert.Certificate2, halibutTimeoutsAndLimits, new StreamFactory(), NoOpSecureConnectionObserver.Instance ); - var secureClient = new SecureListeningClient(GetProtocol, endpoint, Certificates.Octopus, log, connectionManager, tcpConnectionFactory); + var secureClient = new SecureListeningClient(GetProtocol, endpoint, octopusCert.Certificate2, log, connectionManager, tcpConnectionFactory); ResponseMessage response = null!; await secureClient.ExecuteTransactionAsync(async (mep, ct) => response = await mep.ExchangeAsClientAsync(request, ct), CancellationToken.None); From 433d5714590e42cb8536ddc5c3998dc1dd587391 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Wed, 15 Apr 2026 10:53:59 +1000 Subject: [PATCH 06/10] Extend unique-cert-per-test fix to backwards compat builders and bad-cert tests Add ServiceThumbprint property to ClientAndService so bad-certificate tests can reference the service's actual cert thumbprint rather than the client's configured trusted thumbprint (which differs in WithClientTrustingTheWrongCertificate tests). Also fixes DiscoveryClientFixture and backwards compatibility builders. --- source/Halibut.Tests/BadCertificatesTests.cs | 27 ++++++++++++------- ...tClientAndPreviousServiceVersionBuilder.cs | 27 ++++++++++++++----- ...ousClientVersionAndLatestServiceBuilder.cs | 27 ++++++++++++++----- .../LatestClientAndLatestServiceBuilder.cs | 18 ++++++++++--- .../Support/LatestServiceBuilder.cs | 2 ++ .../Transport/DiscoveryClientFixture.cs | 2 +- 6 files changed, 76 insertions(+), 27 deletions(-) diff --git a/source/Halibut.Tests/BadCertificatesTests.cs b/source/Halibut.Tests/BadCertificatesTests.cs index ee65c2dac..6b9ecfea7 100644 --- a/source/Halibut.Tests/BadCertificatesTests.cs +++ b/source/Halibut.Tests/BadCertificatesTests.cs @@ -29,6 +29,7 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI var clientTrustProvider = new DefaultTrustProvider(); var unauthorizedThumbprint = ""; var firstCall = true; + var serviceThumbprint = ""; var unauthorizedClientHasConnected = new TaskCompletionSource(); CancellationToken.Register(() => unauthorizedClientHasConnected.TrySetCanceled()); // backup to fail the test in case it never connects @@ -42,7 +43,7 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI { if (firstCall) { - clientTrustProvider.IsTrusted(CertAndThumbprint.TentaclePolling.Thumbprint).Should().BeFalse(); + clientTrustProvider.IsTrusted(serviceThumbprint).Should().BeFalse(); firstCall = false; } @@ -52,6 +53,8 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI }) .Build(CancellationToken)) { + serviceThumbprint = clientAndBuilder.ServiceThumbprint; + // Act var clientCountingService = clientAndBuilder.CreateAsyncClient(); await clientCountingService.IncrementAsync(); @@ -61,8 +64,8 @@ public async Task SucceedsWhenPollingServicePresentsWrongCertificate_ButServiceI // Assert countingService.CurrentValue().Should().Be(1); - clientTrustProvider.IsTrusted(CertAndThumbprint.TentaclePolling.Thumbprint).Should().BeTrue(); - unauthorizedThumbprint.Should().Be(CertAndThumbprint.TentaclePolling.Thumbprint); + clientTrustProvider.IsTrusted(serviceThumbprint).Should().BeTrue(); + unauthorizedThumbprint.Should().Be(serviceThumbprint); } } @@ -92,6 +95,8 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon }) .Build(CancellationToken)) { + var serviceThumbprint = clientAndBuilder.ServiceThumbprint; + using var cts = new CancellationTokenSource(); var clientCountingService = clientAndBuilder.CreateAsyncClient(point => { @@ -104,7 +109,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon // Interestingly the message exchange error is logged to a non polling looking URL, perhaps because it has not been identified? Wait.UntilActionSucceeds(() => { AllLogs(serviceLoggers).Select(l => l.FormattedMessage).ToArray() - .Should().Contain(s => s.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint '4098EC3A2FC2B92B97339D3831BA230CC1DD590F' which is not in the list of thumbprints that we trust")); + .Should().Contain(s => s.Contains($"and attempted a message exchange, but it presented a client certificate with the thumbprint '{serviceThumbprint}' which is not in the list of thumbprints that we trust")); }, TimeSpan.FromSeconds(10), Logger, @@ -123,7 +128,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate_ButServiceIsCon // Assert countingService.CurrentValue().Should().Be(0, "With a bad certificate the request never should have been made"); - unauthorizedThumbprint.Should().Be(CertAndThumbprint.TentaclePolling.Thumbprint); + unauthorizedThumbprint.Should().Be(serviceThumbprint); } } @@ -194,8 +199,8 @@ public async Task FailWhenClientPresentsWrongCertificateToListeningService(Clien serviceLoggers[serviceLoggers.Keys.First(x => x != nameof(MessageSerializer))].GetLogs().Should() .Contain(log => log.FormattedMessage - .Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint " + - "'76225C0717A16C1D0BA4A7FFA76519D286D8A248' which is not in the list of thumbprints that we trust")); + .Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint") + && log.FormattedMessage.Contains("which is not in the list of thumbprints that we trust")); } } @@ -253,11 +258,13 @@ public async Task FailWhenListeningServicePresentsWrongCertificate(ClientAndServ .WithCountingService(countingService) .Build(CancellationToken)) { + var serviceThumbprint = clientAndBuilder.ServiceThumbprint; + var clientCountingService = clientAndBuilder.CreateAsyncClient(); (await AssertionExtensions.Should(() => clientCountingService.IncrementAsync()).ThrowAsync()) .And.Message.Should().Contain("" + "We expected the server to present a certificate with the thumbprint 'EC32122053C6BFF582F8246F5697633D06F0F97F'. " + - "Instead, it presented a certificate with a thumbprint of '36F35047CE8B000CF4C671819A2DD1AFCDE3403D'"); + $"Instead, it presented a certificate with a thumbprint of '{serviceThumbprint}'"); countingService.CurrentValue().Should().Be(0, "With a bad certificate the request never should have been made"); } } @@ -274,6 +281,8 @@ public async Task FailWhenPollingServicePresentsWrongCertificate(ClientAndServic .RecordingClientLogs(out var serviceLoggers) .Build(CancellationToken)) { + var serviceThumbprint = clientAndBuilder.ServiceThumbprint; + using var cts = new CancellationTokenSource(); var clientCountingService = clientAndBuilder.CreateAsyncClient(point => { @@ -284,7 +293,7 @@ public async Task FailWhenPollingServicePresentsWrongCertificate(ClientAndServic // Interestingly the message exchange error is logged to a non polling looking URL, perhaps because it has not been identified? Wait.UntilActionSucceeds(() => { AllLogs(serviceLoggers).Select(l => l.FormattedMessage).ToArray() - .Should().Contain(s => s.Contains("and attempted a message exchange, but it presented a client certificate with the thumbprint '4098EC3A2FC2B92B97339D3831BA230CC1DD590F' which is not in the list of thumbprints that we trust")); }, + .Should().Contain(s => s.Contains($"and attempted a message exchange, but it presented a client certificate with the thumbprint '{serviceThumbprint}' which is not in the list of thumbprints that we trust")); }, TimeSpan.FromSeconds(10), Logger, CancellationToken); diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs index 563ec4133..be2cd6553 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs @@ -7,6 +7,7 @@ using Halibut.Logging; using Halibut.TestProxy; using Halibut.Tests.Support.Logging; +using Halibut.Tests.Util; using Halibut.Transport.Proxy; using Octopus.TestPortForwarder; using ILog = Halibut.Diagnostics.ILog; @@ -17,7 +18,8 @@ public class LatestClientAndPreviousServiceVersionBuilder : IClientAndServiceBui { readonly ServiceConnectionType serviceConnectionType; CertAndThumbprint serviceCertAndThumbprint; - CertAndThumbprint clientCertAndThumbprint = CertAndThumbprint.Octopus; + CertAndThumbprint clientCertAndThumbprint; + TmpDirectory? tmpDirectory; Version? version; Func? portForwarderFactory; Reference? portForwarderReference; @@ -27,10 +29,11 @@ public class LatestClientAndPreviousServiceVersionBuilder : IClientAndServiceBui ConcurrentDictionary? clientInMemoryLoggers; readonly OldServiceAvailableServices availableServices = new(false, false); - LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint) + LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint, CertAndThumbprint clientCertAndThumbprint) { this.serviceConnectionType = serviceConnectionType; this.serviceCertAndThumbprint = serviceCertAndThumbprint; + this.clientCertAndThumbprint = clientCertAndThumbprint; } public LatestClientAndPreviousServiceVersionBuilder WithCertificates( @@ -44,17 +47,23 @@ public LatestClientAndPreviousServiceVersionBuilder WithCertificates( public static LatestClientAndPreviousServiceVersionBuilder WithPollingService() { - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static LatestClientAndPreviousServiceVersionBuilder WithPollingOverWebSocketService() { - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static LatestClientAndPreviousServiceVersionBuilder WithListeningService() { - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; } public static LatestClientAndPreviousServiceVersionBuilder ForServiceConnectionType(ServiceConnectionType connectionType) @@ -287,7 +296,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } - return new ClientAndService(client, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, portForwarder, disposableCollection, proxy, proxyDetails, cancellationTokenSource); + return new ClientAndService(client, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, portForwarder, disposableCollection, proxy, proxyDetails, cancellationTokenSource, tmpDirectory); } public class ClientAndService : IClientAndService @@ -300,6 +309,7 @@ public class ClientAndService : IClientAndService readonly CancellationTokenSource cancellationTokenSource; readonly PortForwarder? portForwarder; readonly HttpProxyService? httpProxy; + readonly TmpDirectory? tmpDirectory; public ClientAndService( HalibutRuntime client, @@ -310,7 +320,8 @@ public ClientAndService( DisposableCollection disposableCollection, HttpProxyService? httpProxy, ProxyDetails? proxyDetails, - CancellationTokenSource cancellationTokenSource) + CancellationTokenSource cancellationTokenSource, + TmpDirectory? tmpDirectory) { Client = client; this.runningOldHalibutBinary = runningOldHalibutBinary; @@ -321,6 +332,7 @@ public ClientAndService( this.disposableCollection = disposableCollection; this.proxyDetails = proxyDetails; this.cancellationTokenSource = cancellationTokenSource; + this.tmpDirectory = tmpDirectory; } public HalibutRuntime Client { get; } @@ -356,6 +368,7 @@ public async ValueTask DisposeAsync() Try.CatchingError(() => portForwarder?.Dispose(), LogError); Try.CatchingError(() => disposableCollection.Dispose(), LogError); Try.CatchingError(() => cancellationTokenSource.Dispose(), LogError); + Try.CatchingError(() => tmpDirectory?.Dispose(), LogError); } } diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs index ab03a3a01..5d0b7f666 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs @@ -7,6 +7,7 @@ using Halibut.Tests.Builders; using Halibut.Tests.Support.Logging; using Halibut.Tests.TestServices; +using Halibut.Tests.Util; using Halibut.TestUtils.Contracts.Tentacle.Services; using Halibut.Transport.Proxy; using Octopus.Tentacle.Contracts; @@ -28,7 +29,8 @@ public class PreviousClientVersionAndLatestServiceBuilder: IClientAndServiceBuil readonly ServiceFactoryBuilder serviceFactoryBuilder = new(); readonly CertAndThumbprint serviceCertAndThumbprint; - readonly CertAndThumbprint clientCertAndThumbprint = CertAndThumbprint.Octopus; + readonly CertAndThumbprint clientCertAndThumbprint; + TmpDirectory? tmpDirectory; Version? version; ProxyFactory? proxyFactory; Reference? proxyServiceReference; @@ -36,25 +38,32 @@ public class PreviousClientVersionAndLatestServiceBuilder: IClientAndServiceBuil Reference? portForwarderReference; LogLevel halibutLogLevel = LogLevel.Trace; - PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint) + PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint, CertAndThumbprint clientCertAndThumbprint) { this.serviceConnectionType = serviceConnectionType; this.serviceCertAndThumbprint = serviceCertAndThumbprint; + this.clientCertAndThumbprint = clientCertAndThumbprint; } public static PreviousClientVersionAndLatestServiceBuilder WithPollingService() { - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static PreviousClientVersionAndLatestServiceBuilder WithPollingOverWebSocketsService() { - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static PreviousClientVersionAndLatestServiceBuilder WithListeningService() { - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening); + var tmpDirectory = new TmpDirectory(); + var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; } public static PreviousClientVersionAndLatestServiceBuilder ForServiceConnectionType(ServiceConnectionType connectionType) @@ -312,7 +321,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } - return new ClientAndService(proxyClient, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, service, disposableCollection, cancellationTokenSource, portForwarder, httpProxy, logger); + return new ClientAndService(proxyClient, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, service, disposableCollection, cancellationTokenSource, portForwarder, httpProxy, logger, tmpDirectory); } public class ClientAndService : IClientAndService @@ -326,6 +335,7 @@ public class ClientAndService : IClientAndService readonly ILogger logger; readonly PortForwarder? portForwarder; readonly HttpProxyService? httpProxy; + readonly TmpDirectory? tmpDirectory; public ClientAndService( HalibutRuntime proxyClient, @@ -337,7 +347,8 @@ public ClientAndService( CancellationTokenSource cancellationTokenSource, PortForwarder? portForwarder, HttpProxyService? httpProxy, - ILogger logger) + ILogger logger, + TmpDirectory? tmpDirectory) { Client = proxyClient; this.httpProxy = httpProxy; @@ -349,6 +360,7 @@ public ClientAndService( this.disposableCollection = disposableCollection; this.cancellationTokenSource = cancellationTokenSource; this.logger = logger.ForContext();; + this.tmpDirectory = tmpDirectory; } /// @@ -386,6 +398,7 @@ public async ValueTask DisposeAsync() Try.CatchingError(() => portForwarder?.Dispose(), LogError); Try.CatchingError(disposableCollection.Dispose, LogError); ; Try.CatchingError(() => cancellationTokenSource.Dispose(), LogError); + Try.CatchingError(() => tmpDirectory?.Dispose(), LogError); } } } diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index 3385e9c27..16faa1f17 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -66,7 +66,9 @@ public static LatestClientAndLatestServiceBuilder Polling(PollingQueueTestCase p public static LatestClientAndLatestServiceBuilder PollingOverWebSocket(PollingQueueTestCase pollingQueueTestCase) { var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + // For WebSocket, the client cert must be CertAndThumbprint.Ssl because it is bound to the port + // via netsh http add sslcert and must match the cert registered in the Windows local machine cert store. + var clientCert = CertAndThumbprint.Ssl; var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, clientCert, serviceCert, pollingQueueTestCase); builder.tmpDirectory = tmpDirectory; @@ -378,7 +380,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } } - return new ClientAndService(client, service, httpProxy, tmpDirectory); + return new ClientAndService(client, service, httpProxy, tmpDirectory, serviceBuilder.ServiceCertAndThumbprint.Thumbprint); } public class ClientAndService : IClientAndService @@ -392,11 +394,13 @@ public ClientAndService( LatestClient client, LatestService service, HttpProxyService? proxy, - TmpDirectory? tmpDirectory) + TmpDirectory? tmpDirectory, + string serviceThumbprint) { this.client = client; this.service = service; this.tmpDirectory = tmpDirectory; + ServiceThumbprint = serviceThumbprint; httpProxy = proxy; } @@ -405,6 +409,14 @@ public ClientAndService( public HalibutRuntime Client => client.Client; public HalibutRuntime Service => service.Service; + /// + /// The actual thumbprint of the certificate the service is presenting. + /// Use this instead of .RemoteThumbprint when verifying + /// the service cert, as RemoteThumbprint reflects what the client is configured to trust + /// (which may differ, e.g. in bad-certificate tests). + /// + public string ServiceThumbprint { get; } + public ServiceEndPoint GetServiceEndPoint() { return client.GetServiceEndPoint(ServiceUri); diff --git a/source/Halibut.Tests/Support/LatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestServiceBuilder.cs index 19caa44b5..151d72481 100644 --- a/source/Halibut.Tests/Support/LatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestServiceBuilder.cs @@ -83,6 +83,8 @@ public LatestServiceBuilder WithListeningClients(IEnumerable listeningClien return this; } + public CertAndThumbprint ServiceCertAndThumbprint => serviceCertAndThumbprint; + public LatestServiceBuilder WithCertificate(CertAndThumbprint serviceCertAndThumbprint) { this.serviceCertAndThumbprint = serviceCertAndThumbprint; diff --git a/source/Halibut.Tests/Transport/DiscoveryClientFixture.cs b/source/Halibut.Tests/Transport/DiscoveryClientFixture.cs index b395aa909..0bf512b60 100644 --- a/source/Halibut.Tests/Transport/DiscoveryClientFixture.cs +++ b/source/Halibut.Tests/Transport/DiscoveryClientFixture.cs @@ -51,7 +51,7 @@ public async Task OctopusCanDiscoverTentacle(ClientAndServiceTestCase clientAndS { var info = await clientAndService.Client.DiscoverAsync(clientAndService.ServiceUri, CancellationToken); - info.RemoteThumbprint.Should().Be(Certificates.TentacleListeningPublicThumbprint); + info.RemoteThumbprint.Should().Be(clientAndService.GetServiceEndPoint().RemoteThumbprint); } } From 0238b2e5ba4966b39a36a61d4f303b0b9d6f6ed8 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Mon, 15 Jun 2026 10:43:55 +1000 Subject: [PATCH 07/10] Scope per-test unique certificates to net48 The unique-cert-per-test fix (added to work around SChannel session-cache collisions under SslProtocols.None on net48) was applied to all target frameworks. On net80 this defeats SChannel TLS session resumption, so every handshake becomes a slow full handshake. With a short receive timeout that bleeds into the handshake (ssl.ReadTimeout), this made ReceiveResponseTimeoutTests.WhenRpcExecutionIsWithinReceiveResponseTimeout_ButSubsequentDataIsDelayed fail consistently on net80 Windows. Move all of the conditional logic into a single TestCertificates helper that generates fresh certs per test only on net48, and otherwise returns the shared static certs (Octopus/TentacleListening/TentaclePolling) used on main. This restores fast resumed handshakes on net80/Linux while keeping the net48 collision fix intact. --- .../ClientServerLifecycleTests.cs | 11 ++-- ...tClientAndPreviousServiceVersionBuilder.cs | 13 +++-- ...ousClientVersionAndLatestServiceBuilder.cs | 13 +++-- .../LatestClientAndLatestServiceBuilder.cs | 17 +++---- .../Halibut.Tests/Support/TestCertificates.cs | 51 +++++++++++++++++++ .../Transport/SecureClientFixture.cs | 9 ++-- 6 files changed, 80 insertions(+), 34 deletions(-) create mode 100644 source/Halibut.Tests/Support/TestCertificates.cs diff --git a/source/Halibut.Tests/ClientServerLifecycleTests.cs b/source/Halibut.Tests/ClientServerLifecycleTests.cs index 9b4680797..c7ac17df0 100644 --- a/source/Halibut.Tests/ClientServerLifecycleTests.cs +++ b/source/Halibut.Tests/ClientServerLifecycleTests.cs @@ -19,14 +19,13 @@ using Halibut.Diagnostics; using Halibut.ServiceModel; using Halibut.Tests.Support; -using Halibut.Tests.Util; using NUnit.Framework; namespace Halibut.Tests { public class ClientServerLifecycleTests : BaseTest { - TmpDirectory tmpDirectory = null!; + TmpDirectory? tmpDirectory; CertAndThumbprint serverCert = null!; CertAndThumbprint listenerCert = null!; CertAndThumbprint pollerCert = null!; @@ -34,10 +33,10 @@ public class ClientServerLifecycleTests : BaseTest [SetUp] public void SetUpCerts() { - tmpDirectory = new TmpDirectory(); - serverCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - listenerCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - pollerCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + serverCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + listenerCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, tmpDirectory); + pollerCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, tmpDirectory); } [TearDown] diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs index be2cd6553..859f65996 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs @@ -7,7 +7,6 @@ using Halibut.Logging; using Halibut.TestProxy; using Halibut.Tests.Support.Logging; -using Halibut.Tests.Util; using Halibut.Transport.Proxy; using Octopus.TestPortForwarder; using ILog = Halibut.Diagnostics.ILog; @@ -47,22 +46,22 @@ public LatestClientAndPreviousServiceVersionBuilder WithCertificates( public static LatestClientAndPreviousServiceVersionBuilder WithPollingService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static LatestClientAndPreviousServiceVersionBuilder WithPollingOverWebSocketService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static LatestClientAndPreviousServiceVersionBuilder WithListeningService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; } diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs index 5d0b7f666..4d581dfd5 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs @@ -7,7 +7,6 @@ using Halibut.Tests.Builders; using Halibut.Tests.Support.Logging; using Halibut.Tests.TestServices; -using Halibut.Tests.Util; using Halibut.TestUtils.Contracts.Tentacle.Services; using Halibut.Transport.Proxy; using Octopus.Tentacle.Contracts; @@ -47,22 +46,22 @@ public class PreviousClientVersionAndLatestServiceBuilder: IClientAndServiceBuil public static PreviousClientVersionAndLatestServiceBuilder WithPollingService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static PreviousClientVersionAndLatestServiceBuilder WithPollingOverWebSocketsService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; } public static PreviousClientVersionAndLatestServiceBuilder WithListeningService() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; } diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index 897fad8bd..46f9db137 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -9,7 +9,6 @@ using Halibut.TestProxy; using Halibut.Tests.Support.TestAttributes; using Halibut.Tests.TestServices; -using Halibut.Tests.Util; using Halibut.TestUtils.Contracts; using Halibut.TestUtils.Contracts.Tentacle.Services; using Halibut.Transport.Observability; @@ -55,9 +54,9 @@ public LatestClientAndLatestServiceBuilder( public static LatestClientAndLatestServiceBuilder Polling(PollingQueueTestCase pollingQueueTestCase) { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, tmpDirectory); var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Polling, clientCert, serviceCert, pollingQueueTestCase); builder.tmpDirectory = tmpDirectory; return builder; @@ -65,11 +64,11 @@ public static LatestClientAndLatestServiceBuilder Polling(PollingQueueTestCase p public static LatestClientAndLatestServiceBuilder PollingOverWebSocket(PollingQueueTestCase pollingQueueTestCase) { - var tmpDirectory = new TmpDirectory(); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); // For WebSocket, the client cert must be CertAndThumbprint.Ssl because it is bound to the port // via netsh http add sslcert and must match the cert registered in the Windows local machine cert store. var clientCert = CertAndThumbprint.Ssl; - var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, tmpDirectory); var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, clientCert, serviceCert, pollingQueueTestCase); builder.tmpDirectory = tmpDirectory; return builder; @@ -77,9 +76,9 @@ public static LatestClientAndLatestServiceBuilder PollingOverWebSocket(PollingQu public static LatestClientAndLatestServiceBuilder Listening() { - var tmpDirectory = new TmpDirectory(); - var clientCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - var serviceCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, tmpDirectory); var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Listening, clientCert, serviceCert, null); builder.tmpDirectory = tmpDirectory; return builder; diff --git a/source/Halibut.Tests/Support/TestCertificates.cs b/source/Halibut.Tests/Support/TestCertificates.cs new file mode 100644 index 000000000..1bf296c70 --- /dev/null +++ b/source/Halibut.Tests/Support/TestCertificates.cs @@ -0,0 +1,51 @@ +#if NETFRAMEWORK +using Halibut.Tests.Util; +#endif + +namespace Halibut.Tests.Support +{ + /// + /// Decides which certificate a test builder should use, based on the target framework. + /// + /// On .NET Framework 4.8, causes Windows + /// SChannel to use a per-process TLS session cache keyed on certificate + hostname. Reusing the same static + /// certificates across tests in one process causes incorrect session reuse when connecting to localhost, + /// producing AuthenticationExceptions. To avoid this we generate a fresh, unique certificate per test. + /// + /// On other frameworks the static certificates are safe to reuse and, crucially, sharing them enables + /// SChannel TLS session resumption (fast resumed handshakes). Generating unique certs there would defeat + /// resumption and slow down every handshake, which can break tests that enforce short receive timeouts. + /// + /// All of the #if NETFRAMEWORK logic lives here so call sites can route through a single helper. + /// + public static class TestCertificates + { + /// + /// Returns a new to hold generated certificates on .NET Framework, or + /// null on other frameworks (where no certificates are generated). The returned directory, when + /// non-null, must be disposed by the caller. + /// + public static TmpDirectory? NewTmpDirectoryIfNeeded() + { +#if NETFRAMEWORK + return new TmpDirectory(); +#else + return null; +#endif + } + + /// + /// On .NET Framework, generates a fresh unique self-signed certificate into . + /// On other frameworks, returns the supplied so static certificates are + /// shared (enabling TLS session resumption). + /// + public static CertAndThumbprint CertFor(CertAndThumbprint staticCert, TmpDirectory? tmpDirectory) + { +#if NETFRAMEWORK + return CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory!.FullPath); +#else + return staticCert; +#endif + } + } +} diff --git a/source/Halibut.Tests/Transport/SecureClientFixture.cs b/source/Halibut.Tests/Transport/SecureClientFixture.cs index a5ffb18a0..124c083d7 100644 --- a/source/Halibut.Tests/Transport/SecureClientFixture.cs +++ b/source/Halibut.Tests/Transport/SecureClientFixture.cs @@ -10,7 +10,6 @@ using Halibut.Tests.Support; using Halibut.Tests.Support.Logging; using Halibut.Tests.TestServices; -using Halibut.Tests.Util; using Halibut.TestUtils.Contracts; using Halibut.Transport; using Halibut.Transport.Observability; @@ -28,7 +27,7 @@ public class SecureClientFixture : IAsyncDisposable ServiceEndPoint endpoint; HalibutRuntime tentacle; ILog log; - TmpDirectory tmpDirectory; + TmpDirectory? tmpDirectory; CertAndThumbprint tentacleCert; CertAndThumbprint octopusCert; #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. @@ -36,9 +35,9 @@ public class SecureClientFixture : IAsyncDisposable [SetUp] public void SetUp() { - tmpDirectory = new TmpDirectory(); - tentacleCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - octopusCert = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + tentacleCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, tmpDirectory); + octopusCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); var services = new DelegateServiceFactory(); services.Register(() => new AsyncEchoService()); From 3b12d2442c4afb1d882ced3b00e38df694629512 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Mon, 15 Jun 2026 13:26:05 +1000 Subject: [PATCH 08/10] Extend unique-cert-per-test fix to client-only builder The client-only path (LatestClientBuilder.ForServiceConnectionType, used by CreateClientOnlyTestCaseBuilder) still used the shared static Octopus cert on net48. Under SslProtocols.None this intermittently triggers the same SChannel session-cache collision as before, surfacing as an AuthenticationException ('a call to SSPI failed ... they do not possess a common algorithm'). That is classified as UnknownError rather than IsNetworkError, causing ExceptionReturnedByHalibutProxyExtensionMethodFixture.BecauseTheListeningTentacleIsNotResponding to flake on net48 Windows. Generate a fresh per-test client certificate on net48 (Polling/Listening) and dispose its TmpDirectory with the client. PollingOverWebSocket keeps the Ssl cert (bound via netsh). No-op on net80/Linux. --- .../Support/LatestClientBuilder.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/source/Halibut.Tests/Support/LatestClientBuilder.cs b/source/Halibut.Tests/Support/LatestClientBuilder.cs index 1de31111f..660ee2361 100644 --- a/source/Halibut.Tests/Support/LatestClientBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientBuilder.cs @@ -24,6 +24,8 @@ public class LatestClientBuilder : IClientBuilder CertAndThumbprint clientCertAndThumbprint; readonly PollingQueueTestCase? pollingQueueTestCase; + TmpDirectory? tmpDirectory; + string clientTrustsThumbprint; bool clientTrustsNoThumbprints; IRpcObserver? clientRpcObserver; @@ -65,10 +67,20 @@ public static LatestClientBuilder ForServiceConnectionType(ServiceConnectionType switch (serviceConnectionType) { case ServiceConnectionType.Polling: - return new LatestClientBuilder(ServiceConnectionType.Polling, CertAndThumbprint.Octopus, CertAndThumbprint.TentaclePolling, pollingQueueTestCase); + { + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + return new LatestClientBuilder(ServiceConnectionType.Polling, clientCert, CertAndThumbprint.TentaclePolling, pollingQueueTestCase) { tmpDirectory = tmpDirectory }; + } case ServiceConnectionType.Listening: - return new LatestClientBuilder(ServiceConnectionType.Listening, CertAndThumbprint.Octopus, CertAndThumbprint.TentacleListening, pollingQueueTestCase); + { + var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + return new LatestClientBuilder(ServiceConnectionType.Listening, clientCert, CertAndThumbprint.TentacleListening, pollingQueueTestCase) { tmpDirectory = tmpDirectory }; + } case ServiceConnectionType.PollingOverWebSocket: + // For WebSocket, the client cert must be CertAndThumbprint.Ssl because it is bound to the port + // via netsh http add sslcert and must match the cert registered in the Windows local machine cert store. return new LatestClientBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.Ssl, CertAndThumbprint.TentaclePolling, pollingQueueTestCase); default: throw new ArgumentOutOfRangeException(nameof(serviceConnectionType), serviceConnectionType, null); @@ -228,6 +240,11 @@ public async Task Build(CancellationToken cancellationToken) } var disposableCollection = new DisposableCollection(); + if (tmpDirectory is not null) + { + disposableCollection.Add(tmpDirectory); + } + PortForwarder? portForwarder = null; Uri? clientListeningUri = null; From fbb2bc4ada01b2ddceb333934e55afba57f9282e Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Tue, 16 Jun 2026 08:14:33 +1000 Subject: [PATCH 09/10] Trust each polling client's own certificate WhenPollingMultipleClientsWithOneService builds two client-only polling clients plus one service that polls both. The service builder trusted a single static thumbprint and dialled every listening client expecting it, which only worked while all client-only builders shared the static Octopus cert. Generating a unique client cert per test on net48 broke that contract, failing RequestsShouldBeTakenFromAnyClient on every net48 build. Carry each client's own certificate thumbprint from the client-only builder through to the service builder so the polling service dials each listening client with that client's thumbprint: - expose LatestClient.ClientThumbprint (via IClient) - WithListeningClient/WithListeningClients now take (uri, thumbprint) Identical behaviour on net80/Linux (every client resolves to the shared Octopus thumbprint); on net48 the service trusts each unique client cert. --- source/Halibut.Tests/Support/IClient.cs | 1 + source/Halibut.Tests/Support/LatestClient.cs | 5 +++++ .../LatestClientAndLatestServiceBuilder.cs | 2 +- .../Support/LatestClientBuilder.cs | 2 +- .../Support/LatestServiceBuilder.cs | 18 +++++++++--------- ...WhenPollingMultipleClientsWithOneService.cs | 5 ++--- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/source/Halibut.Tests/Support/IClient.cs b/source/Halibut.Tests/Support/IClient.cs index c0c1b3824..c11ee8061 100644 --- a/source/Halibut.Tests/Support/IClient.cs +++ b/source/Halibut.Tests/Support/IClient.cs @@ -6,6 +6,7 @@ public interface IClient : IAsyncDisposable { HalibutRuntime Client { get; } Uri? ListeningUri { get; } + string ClientThumbprint { get; } TAsyncClientService CreateClient(Uri serviceEndPoint); TAsyncClientService CreateClientWithoutService(); TAsyncClientService CreateClientWithoutService(Action modifyServiceEndpoint); diff --git a/source/Halibut.Tests/Support/LatestClient.cs b/source/Halibut.Tests/Support/LatestClient.cs index d632e10f2..05994e170 100644 --- a/source/Halibut.Tests/Support/LatestClient.cs +++ b/source/Halibut.Tests/Support/LatestClient.cs @@ -20,6 +20,7 @@ public LatestClient( HalibutRuntime client, Uri? listeningUri, string thumbprint, + string clientThumbprint, PortForwarder? portForwarder, ProxyDetails? proxyDetails, ServiceConnectionType serviceConnectionType, @@ -28,6 +29,7 @@ public LatestClient( Client = client; ListeningUri = listeningUri; this.thumbprint = thumbprint; + ClientThumbprint = clientThumbprint; this.portForwarder = portForwarder; this.proxyDetails = proxyDetails; this.serviceConnectionType = serviceConnectionType; @@ -37,6 +39,9 @@ public LatestClient( public HalibutRuntime Client { get; } public Uri? ListeningUri { get; } + /// The thumbprint of this client's own certificate (what a polling service must trust). + public string ClientThumbprint { get; } + public TAsyncClientService CreateClient(Uri serviceUri) { var serviceEndPoint = GetServiceEndPoint(serviceUri); diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index 46f9db137..6b4fc97ef 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -371,7 +371,7 @@ public async Task Build(CancellationToken cancellationToken) var client = await clientBuilder.Build(cancellationToken); if (client.ListeningUri is not null) { - serviceBuilder.WithListeningClient(client.ListeningUri); + serviceBuilder.WithListeningClient(client.ListeningUri, client.ClientThumbprint); } var service = await serviceBuilder.Build(cancellationToken); diff --git a/source/Halibut.Tests/Support/LatestClientBuilder.cs b/source/Halibut.Tests/Support/LatestClientBuilder.cs index 660ee2361..d43966063 100644 --- a/source/Halibut.Tests/Support/LatestClientBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientBuilder.cs @@ -284,7 +284,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } - return new LatestClient(client, clientListeningUri, clientTrustsThumbprint, portForwarder, proxyDetails, serviceConnectionType, disposableCollection); + return new LatestClient(client, clientListeningUri, clientTrustsThumbprint, clientCertAndThumbprint.Thumbprint, portForwarder, proxyDetails, serviceConnectionType, disposableCollection); } IPendingRequestQueueFactory CreatePendingRequestQueueFactory(QueueMessageSerializer queueMessageSerializer, ILogFactory octopusLogFactory) diff --git a/source/Halibut.Tests/Support/LatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestServiceBuilder.cs index 151d72481..4d64d8767 100644 --- a/source/Halibut.Tests/Support/LatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestServiceBuilder.cs @@ -31,7 +31,7 @@ public class LatestServiceBuilder : IServiceBuilder IServiceFactory? serviceFactory; string serviceTrustsThumbprint; - readonly List listeningClientUris = new(); + readonly List<(Uri ListeningUri, string Thumbprint)> listeningClients = new(); Func? portForwarderFactory; Reference? portForwarderReference; Func? pollingReconnectRetryPolicy; @@ -69,16 +69,16 @@ public static LatestServiceBuilder ForServiceConnectionType(ServiceConnectionTyp } } - public LatestServiceBuilder WithListeningClient(Uri listeningClient) + public LatestServiceBuilder WithListeningClient(Uri listeningClientUri, string clientThumbprint) { - listeningClientUris.Add(listeningClient); + listeningClients.Add((listeningClientUri, clientThumbprint)); return this; } - public LatestServiceBuilder WithListeningClients(IEnumerable listeningClientUris) + public LatestServiceBuilder WithListeningClients(IEnumerable<(Uri ListeningUri, string Thumbprint)> listeningClients) { - this.listeningClientUris.AddRange(listeningClientUris); + this.listeningClients.AddRange(listeningClients); return this; } @@ -222,13 +222,13 @@ public async Task Build(CancellationToken cancellationToken) { serviceUri = PollingTentacleServiceUri; - foreach (var listeningClientUri in listeningClientUris) + foreach (var (listeningClientUri, clientThumbprint) in listeningClients) { for (var i = 0; i < pollingConnectionCount; i++) { service.Poll( serviceUri, - new ServiceEndPoint(listeningClientUri, serviceTrustsThumbprint, proxyDetails, service.TimeoutsAndLimits), + new ServiceEndPoint(listeningClientUri, clientThumbprint, proxyDetails, service.TimeoutsAndLimits), cancellationToken); } } @@ -237,11 +237,11 @@ public async Task Build(CancellationToken cancellationToken) { serviceUri = PollingOverWebSocketTentacleServiceUri; - foreach (var listeningClientUri in listeningClientUris) + foreach (var (listeningClientUri, clientThumbprint) in listeningClients) { service.Poll( serviceUri, - new ServiceEndPoint(listeningClientUri, serviceTrustsThumbprint, proxyDetails, service.TimeoutsAndLimits), + new ServiceEndPoint(listeningClientUri, clientThumbprint, proxyDetails, service.TimeoutsAndLimits), cancellationToken); } } diff --git a/source/Halibut.Tests/WhenPollingMultipleClientsWithOneService.cs b/source/Halibut.Tests/WhenPollingMultipleClientsWithOneService.cs index 86d1dc895..a4c60f2b5 100644 --- a/source/Halibut.Tests/WhenPollingMultipleClientsWithOneService.cs +++ b/source/Halibut.Tests/WhenPollingMultipleClientsWithOneService.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; using FluentAssertions; using Halibut.Tests.Support; @@ -23,8 +22,8 @@ public async Task RequestsShouldBeTakenFromAnyClient(ClientAndServiceTestCase cl { var clients = new[] { - clientOnly1.ListeningUri!, - clientOnly2.ListeningUri! + (clientOnly1.ListeningUri!, clientOnly1.ClientThumbprint), + (clientOnly2.ListeningUri!, clientOnly2.ClientThumbprint) }; await using (var service = await clientAndServiceTestCase.CreateServiceOnlyTestCaseBuilder() From bec45089544e44dd3a6b8fd87ba6b2e96399fac2 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Tue, 16 Jun 2026 09:39:59 +1000 Subject: [PATCH 10/10] Make listening-client trust thumbprint optional The previous change had the combined builder dial each listening client with that client's actual certificate thumbprint. That overrode the service's configured serviceTrustsThumbprint, which the wrong-certificate test helpers deliberately set to a non-matching value. As a result the polling service accepted connections it should have rejected, breaking the bad-certificate negative tests on every platform: - BadCertificatesTests.FailWhenClientPresentsWrongCertificateToPollingService - ConnectionObserverFixture.ObserveUnauthorizedPollingWebSocketConnections Make the per-client thumbprint optional. The combined builder passes none, so the polling loop falls back to serviceTrustsThumbprint (its previous, known- good behaviour). Only the standalone multi-client test, which has two clients with distinct certs and no wrong-certificate setup, passes explicit per-client thumbprints via WithListeningClients. --- .../LatestClientAndLatestServiceBuilder.cs | 2 +- .../Support/LatestServiceBuilder.cs | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index 6b4fc97ef..46f9db137 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -371,7 +371,7 @@ public async Task Build(CancellationToken cancellationToken) var client = await clientBuilder.Build(cancellationToken); if (client.ListeningUri is not null) { - serviceBuilder.WithListeningClient(client.ListeningUri, client.ClientThumbprint); + serviceBuilder.WithListeningClient(client.ListeningUri); } var service = await serviceBuilder.Build(cancellationToken); diff --git a/source/Halibut.Tests/Support/LatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestServiceBuilder.cs index 4d64d8767..df92e43d6 100644 --- a/source/Halibut.Tests/Support/LatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestServiceBuilder.cs @@ -31,7 +31,7 @@ public class LatestServiceBuilder : IServiceBuilder IServiceFactory? serviceFactory; string serviceTrustsThumbprint; - readonly List<(Uri ListeningUri, string Thumbprint)> listeningClients = new(); + readonly List<(Uri ListeningUri, string? Thumbprint)> listeningClients = new(); Func? portForwarderFactory; Reference? portForwarderReference; Func? pollingReconnectRetryPolicy; @@ -69,16 +69,21 @@ public static LatestServiceBuilder ForServiceConnectionType(ServiceConnectionTyp } } - public LatestServiceBuilder WithListeningClient(Uri listeningClientUri, string clientThumbprint) + public LatestServiceBuilder WithListeningClient(Uri listeningClientUri) { - listeningClients.Add((listeningClientUri, clientThumbprint)); + // No explicit thumbprint: the service dials this client using its configured + // serviceTrustsThumbprint (which the wrong-certificate test helpers manipulate). + listeningClients.Add((listeningClientUri, null)); return this; } public LatestServiceBuilder WithListeningClients(IEnumerable<(Uri ListeningUri, string Thumbprint)> listeningClients) { - this.listeningClients.AddRange(listeningClients); + foreach (var listeningClient in listeningClients) + { + this.listeningClients.Add((listeningClient.ListeningUri, listeningClient.Thumbprint)); + } return this; } @@ -228,7 +233,7 @@ public async Task Build(CancellationToken cancellationToken) { service.Poll( serviceUri, - new ServiceEndPoint(listeningClientUri, clientThumbprint, proxyDetails, service.TimeoutsAndLimits), + new ServiceEndPoint(listeningClientUri, clientThumbprint ?? serviceTrustsThumbprint, proxyDetails, service.TimeoutsAndLimits), cancellationToken); } } @@ -241,7 +246,7 @@ public async Task Build(CancellationToken cancellationToken) { service.Poll( serviceUri, - new ServiceEndPoint(listeningClientUri, clientThumbprint, proxyDetails, service.TimeoutsAndLimits), + new ServiceEndPoint(listeningClientUri, clientThumbprint ?? serviceTrustsThumbprint, proxyDetails, service.TimeoutsAndLimits), cancellationToken); } }