From ce573b813d286f6c3ba6c8dc28314d165076c9fb Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Thu, 26 Mar 2026 13:42:28 +1000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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); } } From 69d8bc7dd9f247b95a0906d5f21ffdb0fd65208b Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Mon, 22 Jun 2026 14:05:23 +1000 Subject: [PATCH 11/14] Extract ICertAndThumbprint, remove SchannelProbe binary CertAndThumbprint now implements ICertAndThumbprint, allowing builders to hold either static certs or disposable per-test certs behind a common interface. All builders replace their TmpDirectory? nullable field with a DisposableCollection, making lifetime management uniform regardless of whether per-test certs are generated. CertificateGenerator is removed in favour of TempDisposableCertAndThumbprint.CreateSelfSigned, which manages its own temp directory and registers with the caller's DisposableCollection. The SchannelProbe compat binary and SchannelSessionCacheFixture are deleted. They proved that process isolation avoids SChannel session- cache collisions; with unique per-test certs that problem is solved in-process and the out-of-process probe is no longer needed. --- ...estUtils.CompatBinary.SchannelProbe.csproj | 23 --- .../Program.cs | 137 -------------- .../ISayHelloService.cs | 15 -- .../ClientServerLifecycleTests.cs | 20 +- source/Halibut.Tests/Halibut.Tests.csproj | 1 - .../SchannelSessionCacheFixture.cs | 172 ------------------ .../HalibutTestBinaryRunner.cs | 16 +- ...tClientAndPreviousServiceVersionBuilder.cs | 48 ++--- ...ousClientVersionAndLatestServiceBuilder.cs | 44 ++--- .../ProxyHalibutTestBinaryRunner.cs | 8 +- .../SchannelProbeBinaryRunner.cs | 128 ------------- .../Support/CertAndThumbprint.cs | 5 +- .../Support/ICertAndThumbprint.cs | 26 +++ .../Support/IDisposableCertAndThumbprint.cs | 22 +++ .../LatestClientAndLatestServiceBuilder.cs | 55 +++--- .../Support/LatestClientBuilder.cs | 46 +++-- .../Support/LatestServiceBuilder.cs | 10 +- .../TempDisposableCertAndThumbprint.cs | 57 ++++++ .../Halibut.Tests/Support/TestCertificates.cs | 18 +- .../Support/WebSocketSslCertificateBuilder.cs | 4 +- .../Support/WebSocketSslCertificateHelper.cs | 2 +- .../Async/IAsyncClientSayHelloService.cs | 9 - source/Halibut.Tests/TlsFixture.cs | 13 +- .../Transport/SecureClientFixture.cs | 14 +- .../Util/CertificateGenerator.cs | 23 --- source/Halibut.sln | 14 -- 26 files changed, 255 insertions(+), 675 deletions(-) delete mode 100644 source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj delete mode 100644 source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs delete mode 100644 source/Halibut.TestUtils.Contracts/ISayHelloService.cs delete mode 100644 source/Halibut.Tests/SchannelSessionCacheFixture.cs delete mode 100644 source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs create mode 100644 source/Halibut.Tests/Support/ICertAndThumbprint.cs create mode 100644 source/Halibut.Tests/Support/IDisposableCertAndThumbprint.cs create mode 100644 source/Halibut.Tests/Support/TempDisposableCertAndThumbprint.cs delete mode 100644 source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs delete mode 100644 source/Halibut.Tests/Util/CertificateGenerator.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 deleted file mode 100644 index e5382f162..000000000 --- a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Halibut.TestUtils.CompatBinary.SchannelProbe.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - 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 deleted file mode 100644 index 8adbeee15..000000000 --- a/source/Halibut.TestUtils.CompatBinary.SchannelProbe/Program.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.IO; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using Halibut; -using Halibut.Diagnostics; -using Halibut.ServiceModel; -using Halibut.TestUtils.Contracts; - -namespace Halibut.TestUtils.SampleProgram.SchannelProbe -{ - public class Program - { - public static async Task 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 deleted file mode 100644 index 76ad6aee7..000000000 --- a/source/Halibut.TestUtils.Contracts/ISayHelloService.cs +++ /dev/null @@ -1,15 +0,0 @@ -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/ClientServerLifecycleTests.cs b/source/Halibut.Tests/ClientServerLifecycleTests.cs index c7ac17df0..bfd03141b 100644 --- a/source/Halibut.Tests/ClientServerLifecycleTests.cs +++ b/source/Halibut.Tests/ClientServerLifecycleTests.cs @@ -25,24 +25,24 @@ namespace Halibut.Tests { public class ClientServerLifecycleTests : BaseTest { - TmpDirectory? tmpDirectory; - CertAndThumbprint serverCert = null!; - CertAndThumbprint listenerCert = null!; - CertAndThumbprint pollerCert = null!; + DisposableCollection disposables = null!; + ICertAndThumbprint serverCert = null!; + ICertAndThumbprint listenerCert = null!; + ICertAndThumbprint pollerCert = null!; [SetUp] public void SetUpCerts() { - tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - serverCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - listenerCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, tmpDirectory); - pollerCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, tmpDirectory); + disposables = new DisposableCollection(); + serverCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + listenerCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, disposedBy: disposables); + pollerCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, disposedBy: disposables); } [TearDown] public void TearDownCerts() { - tmpDirectory?.Dispose(); + disposables?.Dispose(); } [Test] @@ -111,7 +111,7 @@ HalibutRuntime CreateRuntimeForPoller(HalibutRuntime serverRuntime, out IAsyncCl return runtime; } - static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port, CertAndThumbprint serverCertAndThumbprint) + static IAsyncClientCalculatorService CreateClient(HalibutRuntime runtime, int port, ICertAndThumbprint serverCertAndThumbprint) { var endpoint = new ServiceEndPoint( baseUri: $"https://localhost:{port}", diff --git a/source/Halibut.Tests/Halibut.Tests.csproj b/source/Halibut.Tests/Halibut.Tests.csproj index d5d3051aa..602280ad8 100644 --- a/source/Halibut.Tests/Halibut.Tests.csproj +++ b/source/Halibut.Tests/Halibut.Tests.csproj @@ -31,7 +31,6 @@ - diff --git a/source/Halibut.Tests/SchannelSessionCacheFixture.cs b/source/Halibut.Tests/SchannelSessionCacheFixture.cs deleted file mode 100644 index a26a964bb..000000000 --- a/source/Halibut.Tests/SchannelSessionCacheFixture.cs +++ /dev/null @@ -1,172 +0,0 @@ -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/HalibutTestBinaryRunner.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryRunner.cs index fb79b9616..35cdaa459 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryRunner.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/HalibutTestBinaryRunner.cs @@ -17,8 +17,8 @@ public class HalibutTestBinaryRunner // The port the binary should poll. readonly ServiceConnectionType serviceConnectionType; readonly int? clientServicePort; - readonly CertAndThumbprint clientCertAndThumbprint; - readonly CertAndThumbprint serviceCertAndThumbprint; + readonly ICertAndThumbprint clientCertAndThumbprint; + readonly ICertAndThumbprint serviceCertAndThumbprint; readonly string? version; readonly ProxyDetails? proxyDetails; readonly LogLevel halibutLogLevel; @@ -28,8 +28,8 @@ public class HalibutTestBinaryRunner public HalibutTestBinaryRunner( ServiceConnectionType serviceConnectionType, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, string? version, ProxyDetails? proxyDetails, LogLevel halibutLogLevel, @@ -48,8 +48,8 @@ public HalibutTestBinaryRunner( public HalibutTestBinaryRunner( ServiceConnectionType serviceConnectionType, int? clientServicePort, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, string? version, ProxyDetails proxyDetails, LogLevel halibutLoggingLevel, @@ -63,8 +63,8 @@ public HalibutTestBinaryRunner( public HalibutTestBinaryRunner( ServiceConnectionType serviceConnectionType, Uri webSocketServiceEndpointUri, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, string? version, ProxyDetails? proxyDetails, LogLevel halibutLoggingLevel, diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs index 859f65996..f63f79d3c 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/LatestClientAndPreviousServiceVersionBuilder.cs @@ -16,9 +16,9 @@ namespace Halibut.Tests.Support.BackwardsCompatibility public class LatestClientAndPreviousServiceVersionBuilder : IClientAndServiceBuilder { readonly ServiceConnectionType serviceConnectionType; - CertAndThumbprint serviceCertAndThumbprint; - CertAndThumbprint clientCertAndThumbprint; - TmpDirectory? tmpDirectory; + ICertAndThumbprint serviceCertAndThumbprint; + ICertAndThumbprint clientCertAndThumbprint; + DisposableCollection disposables; Version? version; Func? portForwarderFactory; Reference? portForwarderReference; @@ -28,16 +28,21 @@ public class LatestClientAndPreviousServiceVersionBuilder : IClientAndServiceBui ConcurrentDictionary? clientInMemoryLoggers; readonly OldServiceAvailableServices availableServices = new(false, false); - LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint, CertAndThumbprint clientCertAndThumbprint) + LatestClientAndPreviousServiceVersionBuilder( + ServiceConnectionType serviceConnectionType, + ICertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + DisposableCollection disposables) { this.serviceConnectionType = serviceConnectionType; this.serviceCertAndThumbprint = serviceCertAndThumbprint; this.clientCertAndThumbprint = clientCertAndThumbprint; + this.disposables = disposables; } public LatestClientAndPreviousServiceVersionBuilder WithCertificates( - CertAndThumbprint serviceCertAndThumbprint, - CertAndThumbprint clientCertAndThumbprint) + ICertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint) { this.serviceCertAndThumbprint = serviceCertAndThumbprint; this.clientCertAndThumbprint = clientCertAndThumbprint; @@ -46,23 +51,23 @@ public LatestClientAndPreviousServiceVersionBuilder WithCertificates( public static LatestClientAndPreviousServiceVersionBuilder WithPollingService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert, disposables); } public static LatestClientAndPreviousServiceVersionBuilder WithPollingOverWebSocketService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert, disposables); } public static LatestClientAndPreviousServiceVersionBuilder WithListeningService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new LatestClientAndPreviousServiceVersionBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert, disposables); } public static LatestClientAndPreviousServiceVersionBuilder ForServiceConnectionType(ServiceConnectionType connectionType) @@ -194,6 +199,7 @@ public async Task Build(CancellationToken cancellationToken) HalibutTestBinaryRunner.RunningOldHalibutBinary? runningOldHalibutBinary = null; var disposableCollection = new DisposableCollection(); + disposableCollection.Add(disposables); PortForwarder? portForwarder = null; var proxy = proxyFactory?.Build(); @@ -295,32 +301,30 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } - return new ClientAndService(client, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, portForwarder, disposableCollection, proxy, proxyDetails, cancellationTokenSource, tmpDirectory); + return new ClientAndService(client, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, portForwarder, disposableCollection, proxy, proxyDetails, cancellationTokenSource); } public class ClientAndService : IClientAndService { readonly HalibutTestBinaryRunner.RunningOldHalibutBinary? runningOldHalibutBinary; readonly Uri serviceUri; - readonly CertAndThumbprint serviceCertAndThumbprint; // for creating a client + readonly ICertAndThumbprint serviceCertAndThumbprint; // for creating a client readonly DisposableCollection disposableCollection; readonly ProxyDetails? proxyDetails; readonly CancellationTokenSource cancellationTokenSource; readonly PortForwarder? portForwarder; readonly HttpProxyService? httpProxy; - readonly TmpDirectory? tmpDirectory; public ClientAndService( HalibutRuntime client, HalibutTestBinaryRunner.RunningOldHalibutBinary? runningOldHalibutBinary, Uri serviceUri, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, PortForwarder? portForwarder, DisposableCollection disposableCollection, HttpProxyService? httpProxy, ProxyDetails? proxyDetails, - CancellationTokenSource cancellationTokenSource, - TmpDirectory? tmpDirectory) + CancellationTokenSource cancellationTokenSource) { Client = client; this.runningOldHalibutBinary = runningOldHalibutBinary; @@ -331,7 +335,6 @@ public ClientAndService( this.disposableCollection = disposableCollection; this.proxyDetails = proxyDetails; this.cancellationTokenSource = cancellationTokenSource; - this.tmpDirectory = tmpDirectory; } public HalibutRuntime Client { get; } @@ -367,7 +370,6 @@ 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 4d581dfd5..1db2732b2 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/PreviousClientVersionAndLatestServiceBuilder.cs @@ -27,9 +27,9 @@ public class PreviousClientVersionAndLatestServiceBuilder: IClientAndServiceBuil readonly ServiceConnectionType serviceConnectionType; readonly ServiceFactoryBuilder serviceFactoryBuilder = new(); - readonly CertAndThumbprint serviceCertAndThumbprint; - readonly CertAndThumbprint clientCertAndThumbprint; - TmpDirectory? tmpDirectory; + readonly ICertAndThumbprint serviceCertAndThumbprint; + readonly ICertAndThumbprint clientCertAndThumbprint; + DisposableCollection disposables; Version? version; ProxyFactory? proxyFactory; Reference? proxyServiceReference; @@ -37,32 +37,37 @@ public class PreviousClientVersionAndLatestServiceBuilder: IClientAndServiceBuil Reference? portForwarderReference; LogLevel halibutLogLevel = LogLevel.Trace; - PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType serviceConnectionType, CertAndThumbprint serviceCertAndThumbprint, CertAndThumbprint clientCertAndThumbprint) + PreviousClientVersionAndLatestServiceBuilder( + ServiceConnectionType serviceConnectionType, + ICertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + DisposableCollection disposables) { this.serviceConnectionType = serviceConnectionType; this.serviceCertAndThumbprint = serviceCertAndThumbprint; this.clientCertAndThumbprint = clientCertAndThumbprint; + this.disposables = disposables; } public static PreviousClientVersionAndLatestServiceBuilder WithPollingService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Polling, CertAndThumbprint.TentaclePolling, clientCert, disposables); } public static PreviousClientVersionAndLatestServiceBuilder WithPollingOverWebSocketsService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, CertAndThumbprint.TentaclePolling, clientCert, disposables); } public static PreviousClientVersionAndLatestServiceBuilder WithListeningService() { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new PreviousClientVersionAndLatestServiceBuilder(ServiceConnectionType.Listening, CertAndThumbprint.TentacleListening, clientCert, disposables); } public static PreviousClientVersionAndLatestServiceBuilder ForServiceConnectionType(ServiceConnectionType connectionType) @@ -210,6 +215,7 @@ public async Task Build(CancellationToken cancellationToken) PortForwarder? portForwarder = null; var disposableCollection = new DisposableCollection(); + disposableCollection.Add(disposables); ProxyHalibutTestBinaryRunner.RoundTripRunningOldHalibutBinary runningOldHalibutBinary; Uri serviceUri; var httpProxy = proxyFactory?.Build(); @@ -320,34 +326,32 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } - return new ClientAndService(proxyClient, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, service, disposableCollection, cancellationTokenSource, portForwarder, httpProxy, logger, tmpDirectory); + return new ClientAndService(proxyClient, runningOldHalibutBinary, serviceUri, serviceCertAndThumbprint, service, disposableCollection, cancellationTokenSource, portForwarder, httpProxy, logger); } public class ClientAndService : IClientAndService { readonly ProxyHalibutTestBinaryRunner.RoundTripRunningOldHalibutBinary runningOldHalibutBinary; readonly Uri serviceUri; - readonly CertAndThumbprint serviceCertAndThumbprint; // for creating a client + readonly ICertAndThumbprint serviceCertAndThumbprint; // for creating a client readonly HalibutRuntime service; readonly DisposableCollection disposableCollection; readonly CancellationTokenSource cancellationTokenSource; readonly ILogger logger; readonly PortForwarder? portForwarder; readonly HttpProxyService? httpProxy; - readonly TmpDirectory? tmpDirectory; public ClientAndService( HalibutRuntime proxyClient, ProxyHalibutTestBinaryRunner.RoundTripRunningOldHalibutBinary runningOldHalibutBinary, Uri serviceUri, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, HalibutRuntime service, DisposableCollection disposableCollection, CancellationTokenSource cancellationTokenSource, PortForwarder? portForwarder, HttpProxyService? httpProxy, - ILogger logger, - TmpDirectory? tmpDirectory) + ILogger logger) { Client = proxyClient; this.httpProxy = httpProxy; @@ -359,7 +363,6 @@ public ClientAndService( this.disposableCollection = disposableCollection; this.cancellationTokenSource = cancellationTokenSource; this.logger = logger.ForContext();; - this.tmpDirectory = tmpDirectory; } /// @@ -397,7 +400,6 @@ 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/ProxyHalibutTestBinaryRunner.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/ProxyHalibutTestBinaryRunner.cs index 66cc9b340..0fd3a98a2 100644 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/ProxyHalibutTestBinaryRunner.cs +++ b/source/Halibut.Tests/Support/BackwardsCompatibility/ProxyHalibutTestBinaryRunner.cs @@ -15,8 +15,8 @@ public class ProxyHalibutTestBinaryRunner { readonly ServiceConnectionType serviceConnectionType; readonly int? proxyClientListeningPort; - readonly CertAndThumbprint clientCertAndThumbprint; - readonly CertAndThumbprint serviceCertAndThumbprint; + readonly ICertAndThumbprint clientCertAndThumbprint; + readonly ICertAndThumbprint serviceCertAndThumbprint; readonly string? version; readonly ProxyDetails? proxyDetails; readonly string? webSocketPath; @@ -27,8 +27,8 @@ public class ProxyHalibutTestBinaryRunner public ProxyHalibutTestBinaryRunner( ServiceConnectionType serviceConnectionType, int? proxyClientListeningPort, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, Uri? realServiceListenAddress, string? version, ProxyDetails? proxyDetails, diff --git a/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs b/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs deleted file mode 100644 index ea0248fe7..000000000 --- a/source/Halibut.Tests/Support/BackwardsCompatibility/SchannelProbeBinaryRunner.cs +++ /dev/null @@ -1,128 +0,0 @@ -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.Tests/Support/CertAndThumbprint.cs b/source/Halibut.Tests/Support/CertAndThumbprint.cs index 0eb72da5e..e5f5c1c47 100644 --- a/source/Halibut.Tests/Support/CertAndThumbprint.cs +++ b/source/Halibut.Tests/Support/CertAndThumbprint.cs @@ -3,7 +3,8 @@ namespace Halibut.Tests.Support { - public class CertAndThumbprint + public sealed class CertAndThumbprint + : ICertAndThumbprint { /// /// CN=Halibut Alice @@ -36,7 +37,7 @@ public class CertAndThumbprint /// public static CertAndThumbprint Ssl = new(Certificates.sslPfxPath, Certificates.Ssl); - public CertAndThumbprint(string certificatePfxPath, X509Certificate2 certificate2) + CertAndThumbprint(string certificatePfxPath, X509Certificate2 certificate2) { Certificate2 = certificate2; CertificatePfxPath = certificatePfxPath; diff --git a/source/Halibut.Tests/Support/ICertAndThumbprint.cs b/source/Halibut.Tests/Support/ICertAndThumbprint.cs new file mode 100644 index 000000000..0fa9b9d53 --- /dev/null +++ b/source/Halibut.Tests/Support/ICertAndThumbprint.cs @@ -0,0 +1,26 @@ +// 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.Cryptography.X509Certificates; + +namespace Halibut.Tests.Support +{ + public interface ICertAndThumbprint + { + X509Certificate2 Certificate2 { get; } + string CertificatePfxPath { get; } + string Thumbprint { get; } + } +} \ No newline at end of file diff --git a/source/Halibut.Tests/Support/IDisposableCertAndThumbprint.cs b/source/Halibut.Tests/Support/IDisposableCertAndThumbprint.cs new file mode 100644 index 000000000..e8bd25573 --- /dev/null +++ b/source/Halibut.Tests/Support/IDisposableCertAndThumbprint.cs @@ -0,0 +1,22 @@ +// 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; + +namespace Halibut.Tests.Support +{ + public interface IDisposableCertAndThumbprint : ICertAndThumbprint, IDisposable + { + } +} \ No newline at end of file diff --git a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs index 46f9db137..670811758 100644 --- a/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientAndLatestServiceBuilder.cs @@ -30,8 +30,7 @@ public class LatestClientAndLatestServiceBuilder : IClientAndServiceBuilder readonly LatestClientBuilder clientBuilder; readonly LatestServiceBuilder serviceBuilder; - - TmpDirectory? tmpDirectory; + DisposableCollection disposables; ProxyFactory? proxyFactory; Reference? proxyServiceReference; @@ -40,48 +39,44 @@ public class LatestClientAndLatestServiceBuilder : IClientAndServiceBuilder Reference? servicePortForwarderReference; Reference? portForwarderReference; - public LatestClientAndLatestServiceBuilder( + LatestClientAndLatestServiceBuilder( ServiceConnectionType serviceConnectionType, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, - PollingQueueTestCase? pollingQueueTestCase) + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, + PollingQueueTestCase? pollingQueueTestCase, + DisposableCollection disposables) { ServiceConnectionType = serviceConnectionType; + this.disposables = disposables; clientBuilder = new LatestClientBuilder(serviceConnectionType, clientCertAndThumbprint, serviceCertAndThumbprint, pollingQueueTestCase); serviceBuilder = new LatestServiceBuilder(serviceConnectionType, clientCertAndThumbprint, serviceCertAndThumbprint); } public static LatestClientAndLatestServiceBuilder Polling(PollingQueueTestCase pollingQueueTestCase) { - 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; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, disposedBy: disposables); + return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Polling, clientCert, serviceCert, pollingQueueTestCase, disposables); } public static LatestClientAndLatestServiceBuilder PollingOverWebSocket(PollingQueueTestCase pollingQueueTestCase) { - 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 disposables = new DisposableCollection(); var clientCert = CertAndThumbprint.Ssl; - var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, tmpDirectory); - var builder = new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, clientCert, serviceCert, pollingQueueTestCase); - builder.tmpDirectory = tmpDirectory; - return builder; + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentaclePolling, disposedBy: disposables); + return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.PollingOverWebSocket, clientCert, serviceCert, pollingQueueTestCase, disposables); } public static LatestClientAndLatestServiceBuilder Listening() { - 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; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + var serviceCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, disposedBy: disposables); + return new LatestClientAndLatestServiceBuilder(ServiceConnectionType.Listening, clientCert, serviceCert, null, disposables); } public static LatestClientAndLatestServiceBuilder ForServiceConnectionType(ServiceConnectionType serviceConnectionType, PollingQueueTestCase? pollingQueueTestCase = null) @@ -100,8 +95,8 @@ public static LatestClientAndLatestServiceBuilder ForServiceConnectionType(Servi } public LatestClientAndLatestServiceBuilder WithCertificates( - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint) + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint) { clientBuilder.WithCertificate(clientCertAndThumbprint); clientBuilder.WithTrustedThumbprint(serviceCertAndThumbprint.Thumbprint); @@ -385,7 +380,7 @@ public async Task Build(CancellationToken cancellationToken) portForwarderReference.Value = portForwarder; } } - return new ClientAndService(client, service, httpProxy, tmpDirectory, serviceBuilder.ServiceCertAndThumbprint.Thumbprint); + return new ClientAndService(client, service, httpProxy, disposables, serviceBuilder.ServiceCertAndThumbprint.Thumbprint); } public class ClientAndService : IClientAndService @@ -393,18 +388,18 @@ public class ClientAndService : IClientAndService readonly LatestClient client; readonly LatestService service; readonly HttpProxyService? httpProxy; - readonly TmpDirectory? tmpDirectory; + readonly DisposableCollection disposables; public ClientAndService( LatestClient client, LatestService service, HttpProxyService? proxy, - TmpDirectory? tmpDirectory, + DisposableCollection disposables, string serviceThumbprint) { this.client = client; this.service = service; - this.tmpDirectory = tmpDirectory; + this.disposables = disposables; ServiceThumbprint = serviceThumbprint; httpProxy = proxy; @@ -451,7 +446,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); + Try.CatchingError(() => disposables.Dispose(), LogError); } } } diff --git a/source/Halibut.Tests/Support/LatestClientBuilder.cs b/source/Halibut.Tests/Support/LatestClientBuilder.cs index d43966063..1f3fc3b3c 100644 --- a/source/Halibut.Tests/Support/LatestClientBuilder.cs +++ b/source/Halibut.Tests/Support/LatestClientBuilder.cs @@ -21,10 +21,10 @@ public class LatestClientBuilder : IClientBuilder { readonly ServiceConnectionType serviceConnectionType; - CertAndThumbprint clientCertAndThumbprint; + ICertAndThumbprint clientCertAndThumbprint; readonly PollingQueueTestCase? pollingQueueTestCase; - TmpDirectory? tmpDirectory; + DisposableCollection disposables; string clientTrustsThumbprint; bool clientTrustsNoThumbprints; @@ -45,13 +45,29 @@ public class LatestClientBuilder : IClientBuilder public LatestClientBuilder( ServiceConnectionType serviceConnectionType, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint, - PollingQueueTestCase? pollingQueueTestCase) + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, + PollingQueueTestCase? pollingQueueTestCase + ) : this( + serviceConnectionType, + clientCertAndThumbprint, + serviceCertAndThumbprint, + pollingQueueTestCase, + new DisposableCollection()) + { + } + + LatestClientBuilder( + ServiceConnectionType serviceConnectionType, + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint, + PollingQueueTestCase? pollingQueueTestCase, + DisposableCollection disposables) { this.serviceConnectionType = serviceConnectionType; this.clientCertAndThumbprint = clientCertAndThumbprint; this.pollingQueueTestCase = pollingQueueTestCase; + this.disposables = disposables; clientTrustsThumbprint = serviceCertAndThumbprint.Thumbprint; if (serviceConnectionType is ServiceConnectionType.Polling or ServiceConnectionType.PollingOverWebSocket) { @@ -68,15 +84,15 @@ public static LatestClientBuilder ForServiceConnectionType(ServiceConnectionType { case ServiceConnectionType.Polling: { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new LatestClientBuilder(ServiceConnectionType.Polling, clientCert, CertAndThumbprint.TentaclePolling, pollingQueueTestCase) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new LatestClientBuilder(ServiceConnectionType.Polling, clientCert, CertAndThumbprint.TentaclePolling, pollingQueueTestCase, disposables); } case ServiceConnectionType.Listening: { - var tmpDirectory = TestCertificates.NewTmpDirectoryIfNeeded(); - var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); - return new LatestClientBuilder(ServiceConnectionType.Listening, clientCert, CertAndThumbprint.TentacleListening, pollingQueueTestCase) { tmpDirectory = tmpDirectory }; + var disposables = new DisposableCollection(); + var clientCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); + return new LatestClientBuilder(ServiceConnectionType.Listening, clientCert, CertAndThumbprint.TentacleListening, pollingQueueTestCase); } case ServiceConnectionType.PollingOverWebSocket: // For WebSocket, the client cert must be CertAndThumbprint.Ssl because it is bound to the port @@ -87,9 +103,9 @@ public static LatestClientBuilder ForServiceConnectionType(ServiceConnectionType } } - public LatestClientBuilder WithCertificate(CertAndThumbprint clientCertAndThumprint) + public LatestClientBuilder WithCertificate(ICertAndThumbprint clientCertAndThumbprint) { - this.clientCertAndThumbprint = clientCertAndThumprint; + this.clientCertAndThumbprint = clientCertAndThumbprint; return this; } @@ -240,10 +256,6 @@ public async Task Build(CancellationToken cancellationToken) } var disposableCollection = new DisposableCollection(); - if (tmpDirectory is not null) - { - disposableCollection.Add(tmpDirectory); - } PortForwarder? portForwarder = null; Uri? clientListeningUri = null; diff --git a/source/Halibut.Tests/Support/LatestServiceBuilder.cs b/source/Halibut.Tests/Support/LatestServiceBuilder.cs index df92e43d6..343167294 100644 --- a/source/Halibut.Tests/Support/LatestServiceBuilder.cs +++ b/source/Halibut.Tests/Support/LatestServiceBuilder.cs @@ -26,7 +26,7 @@ public class LatestServiceBuilder : IServiceBuilder readonly ServiceConnectionType serviceConnectionType; readonly ServiceFactoryBuilder serviceFactoryBuilder = new(); - CertAndThumbprint serviceCertAndThumbprint; + ICertAndThumbprint serviceCertAndThumbprint; IServiceFactory? serviceFactory; string serviceTrustsThumbprint; @@ -46,8 +46,8 @@ public class LatestServiceBuilder : IServiceBuilder public LatestServiceBuilder( ServiceConnectionType serviceConnectionType, - CertAndThumbprint clientCertAndThumbprint, - CertAndThumbprint serviceCertAndThumbprint) + ICertAndThumbprint clientCertAndThumbprint, + ICertAndThumbprint serviceCertAndThumbprint) { this.serviceConnectionType = serviceConnectionType; this.serviceCertAndThumbprint = serviceCertAndThumbprint; @@ -88,9 +88,9 @@ public LatestServiceBuilder WithListeningClients(IEnumerable<(Uri ListeningUri, return this; } - public CertAndThumbprint ServiceCertAndThumbprint => serviceCertAndThumbprint; + public ICertAndThumbprint ServiceCertAndThumbprint => serviceCertAndThumbprint; - public LatestServiceBuilder WithCertificate(CertAndThumbprint serviceCertAndThumbprint) + public LatestServiceBuilder WithCertificate(ICertAndThumbprint serviceCertAndThumbprint) { this.serviceCertAndThumbprint = serviceCertAndThumbprint; return this; diff --git a/source/Halibut.Tests/Support/TempDisposableCertAndThumbprint.cs b/source/Halibut.Tests/Support/TempDisposableCertAndThumbprint.cs new file mode 100644 index 000000000..2c952fec0 --- /dev/null +++ b/source/Halibut.Tests/Support/TempDisposableCertAndThumbprint.cs @@ -0,0 +1,57 @@ +// 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.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Halibut.Tests.Support +{ + public sealed class TempDisposableCertAndThumbprint : IDisposableCertAndThumbprint + { + readonly TemporaryDirectory tempDirectory; + TempDisposableCertAndThumbprint(TemporaryDirectory directory, string certificatePfxPath, X509Certificate2 certificate2) + { + tempDirectory = directory; + Certificate2 = certificate2; + CertificatePfxPath = certificatePfxPath; + } + + public X509Certificate2 Certificate2 { get; } + public string CertificatePfxPath { get; } + public string Thumbprint => Certificate2.Thumbprint; + + void IDisposable.Dispose() + { + Certificate2.Dispose(); + tempDirectory.Dispose(); + } + + public static ICertAndThumbprint CreateSelfSigned(DisposableCollection disposedBy) + { + var tempDir = new TemporaryDirectory(); + var name = Guid.NewGuid().ToString(); + using var rsa = RSA.Create(2048); + var request = new CertificateRequest($"CN={name}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var certificate = request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); + var bytes = certificate.Export(X509ContentType.Pfx); + var filePath = Path.Combine(tempDir.DirectoryPath, $"{name}.pfx"); + File.WriteAllBytes(filePath, bytes); + var disposableCert = new TempDisposableCertAndThumbprint(tempDir, filePath, new X509Certificate2(bytes)); + disposedBy.Add(disposableCert); + return disposableCert; + } + } +} \ No newline at end of file diff --git a/source/Halibut.Tests/Support/TestCertificates.cs b/source/Halibut.Tests/Support/TestCertificates.cs index 1bf296c70..c04857d3a 100644 --- a/source/Halibut.Tests/Support/TestCertificates.cs +++ b/source/Halibut.Tests/Support/TestCertificates.cs @@ -20,29 +20,15 @@ namespace Halibut.Tests.Support /// 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) + public static ICertAndThumbprint CertFor(CertAndThumbprint staticCert, DisposableCollection disposedBy) { #if NETFRAMEWORK - return CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory!.FullPath); + return TempCertAndThumbprint.GenerateSelfSignedCertificate(disposedBy); #else return staticCert; #endif diff --git a/source/Halibut.Tests/Support/WebSocketSslCertificateBuilder.cs b/source/Halibut.Tests/Support/WebSocketSslCertificateBuilder.cs index 83b3ce3e9..94b2281dd 100644 --- a/source/Halibut.Tests/Support/WebSocketSslCertificateBuilder.cs +++ b/source/Halibut.Tests/Support/WebSocketSslCertificateBuilder.cs @@ -6,14 +6,14 @@ namespace Halibut.Tests.Support class WebSocketSslCertificateBuilder { readonly string bindingAddress; - CertAndThumbprint certAndThumbprint = CertAndThumbprint.Ssl; + ICertAndThumbprint certAndThumbprint = CertAndThumbprint.Ssl; public WebSocketSslCertificateBuilder(string bindingAddress) { this.bindingAddress = bindingAddress; } - public WebSocketSslCertificateBuilder WithCertificate(CertAndThumbprint certAndThumbprint) + public WebSocketSslCertificateBuilder WithCertificate(ICertAndThumbprint certAndThumbprint) { this.certAndThumbprint = certAndThumbprint; return this; diff --git a/source/Halibut.Tests/Support/WebSocketSslCertificateHelper.cs b/source/Halibut.Tests/Support/WebSocketSslCertificateHelper.cs index f157ea536..467bab845 100644 --- a/source/Halibut.Tests/Support/WebSocketSslCertificateHelper.cs +++ b/source/Halibut.Tests/Support/WebSocketSslCertificateHelper.cs @@ -16,7 +16,7 @@ internal static void AddSslCertToLocalStore() store.Close(); } - internal static void AddSslCertBindingFor(string address, CertAndThumbprint certAndThumbprint) + internal static void AddSslCertBindingFor(string address, ICertAndThumbprint certAndThumbprint) { if (certAndThumbprint.Thumbprint != CertAndThumbprint.Ssl.Thumbprint) { diff --git a/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs b/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs deleted file mode 100644 index 69c4d7b03..000000000 --- a/source/Halibut.Tests/TestServices/Async/IAsyncClientSayHelloService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Threading.Tasks; - -namespace Halibut.Tests.TestServices.Async -{ - public interface IAsyncClientSayHelloService - { - Task SayHelloAsync(string name); - } -} diff --git a/source/Halibut.Tests/TlsFixture.cs b/source/Halibut.Tests/TlsFixture.cs index 3c3053a13..433fc274c 100644 --- a/source/Halibut.Tests/TlsFixture.cs +++ b/source/Halibut.Tests/TlsFixture.cs @@ -8,7 +8,6 @@ using Halibut.Tests.Support.TestAttributes; using Halibut.Tests.Support.TestCases; using Halibut.Tests.TestServices.Async; -using Halibut.Tests.Util; using Halibut.TestUtils.Contracts; using NUnit.Framework; @@ -22,9 +21,9 @@ public async Task LatestClientAndServiceUseBestAvailableSslProtocol(ClientAndSer { // We need to avoid the use of cached SSL sessions to ensure that correct SSL protocol is chosen, so we use // unique certificates for each test. - using var tmpDirectory = new TmpDirectory(); - var clientCertAndThumbprint = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - var serviceCertAndThumbprint = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + using var disposables = new DisposableCollection(); + var clientCertAndThumbprint = TempDisposableCertAndThumbprint.CreateSelfSigned(disposedBy: disposables); + var serviceCertAndThumbprint = TempDisposableCertAndThumbprint.CreateSelfSigned(disposedBy: disposables); await using var clientAndService = await clientAndServiceTestCase.CreateTestCaseBuilder() .WithStandardServices() @@ -57,9 +56,9 @@ public async Task LatestClientAndPreviousServiceFallBackOnTls12(ClientAndService { // We need to avoid the use of cached SSL sessions to ensure that correct SSL protocol is chosen, so we use // unique certificates for each test. - using var tmpDirectory = new TmpDirectory(); - var clientCertAndThumbprint = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); - var serviceCertAndThumbprint = CertificateGenerator.GenerateSelfSignedCertificate(tmpDirectory.FullPath); + using var disposables = new DisposableCollection(); + var clientCertAndThumbprint = TempDisposableCertAndThumbprint.CreateSelfSigned(disposedBy: disposables); + var serviceCertAndThumbprint = TempDisposableCertAndThumbprint.CreateSelfSigned(disposedBy: disposables); await using var clientAndService = await clientAndServiceTestCase.CreateTestCaseBuilder() .WithStandardServices() diff --git a/source/Halibut.Tests/Transport/SecureClientFixture.cs b/source/Halibut.Tests/Transport/SecureClientFixture.cs index 124c083d7..0df6e4398 100644 --- a/source/Halibut.Tests/Transport/SecureClientFixture.cs +++ b/source/Halibut.Tests/Transport/SecureClientFixture.cs @@ -27,17 +27,17 @@ public class SecureClientFixture : IAsyncDisposable ServiceEndPoint endpoint; HalibutRuntime tentacle; ILog log; - TmpDirectory? tmpDirectory; - CertAndThumbprint tentacleCert; - CertAndThumbprint octopusCert; + DisposableCollection disposables; + ICertAndThumbprint tentacleCert; + ICertAndThumbprint 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 = TestCertificates.NewTmpDirectoryIfNeeded(); - tentacleCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, tmpDirectory); - octopusCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, tmpDirectory); + disposables = new DisposableCollection(); + tentacleCert = TestCertificates.CertFor(CertAndThumbprint.TentacleListening, disposedBy: disposables); + octopusCert = TestCertificates.CertFor(CertAndThumbprint.Octopus, disposedBy: disposables); var services = new DelegateServiceFactory(); services.Register(() => new AsyncEchoService()); @@ -58,7 +58,7 @@ public void SetUp() public async ValueTask DisposeAsync() { await tentacle.DisposeAsync(); - tmpDirectory?.Dispose(); + disposables?.Dispose(); } [Test] diff --git a/source/Halibut.Tests/Util/CertificateGenerator.cs b/source/Halibut.Tests/Util/CertificateGenerator.cs deleted file mode 100644 index 5c56c8596..000000000 --- a/source/Halibut.Tests/Util/CertificateGenerator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.IO; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Halibut.Tests.Support; - -namespace Halibut.Tests.Util -{ - public static class CertificateGenerator - { - public static CertAndThumbprint GenerateSelfSignedCertificate(string folderPath) - { - var name = Guid.NewGuid().ToString(); - using var rsa = RSA.Create(2048); - var request = new CertificateRequest($"CN={name}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var certificate = request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); - var bytes = certificate.Export(X509ContentType.Pfx); - var filePath = Path.Combine(folderPath, $"{name}.pfx"); - File.WriteAllBytes(filePath, bytes); - return new CertAndThumbprint(filePath, new X509Certificate2(bytes)); - } - } -} \ No newline at end of file diff --git a/source/Halibut.sln b/source/Halibut.sln index fcb21f36a..05e2e010b 100644 --- a/source/Halibut.sln +++ b/source/Halibut.sln @@ -41,8 +41,6 @@ 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 @@ -179,18 +177,6 @@ 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 2e296467b6cb0e52746b8cbd155d6c09cb4f233d Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Mon, 22 Jun 2026 14:14:18 +1000 Subject: [PATCH 12/14] Remove redundant comments from certificate setup methods The rationale for using separate runtimes and distinct certificates is already documented in detail at the test method level. These helper method comments were duplicating that explanation, so remove them to reduce clutter. --- source/Halibut.Tests.DotMemory/MemoryFixture.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/source/Halibut.Tests.DotMemory/MemoryFixture.cs b/source/Halibut.Tests.DotMemory/MemoryFixture.cs index 7f4c1cf3d..78bc0e18e 100644 --- a/source/Halibut.Tests.DotMemory/MemoryFixture.cs +++ b/source/Halibut.Tests.DotMemory/MemoryFixture.cs @@ -160,21 +160,12 @@ static HalibutRuntime RunServer(X509Certificate2 serverCertificate, out int port .WithLogFactory(new TestContextLogFactory("client", LogLevel.Info)) .Build(); - // 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(); 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(); From ca32ebd12c32d1db5da3116e3ea803a4f24fff5f Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Mon, 22 Jun 2026 14:25:04 +1000 Subject: [PATCH 13/14] Fix net48 build --- source/Halibut.Tests/Support/TestCertificates.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/source/Halibut.Tests/Support/TestCertificates.cs b/source/Halibut.Tests/Support/TestCertificates.cs index c04857d3a..696b12db4 100644 --- a/source/Halibut.Tests/Support/TestCertificates.cs +++ b/source/Halibut.Tests/Support/TestCertificates.cs @@ -1,7 +1,3 @@ -#if NETFRAMEWORK -using Halibut.Tests.Util; -#endif - namespace Halibut.Tests.Support { /// @@ -28,7 +24,7 @@ public static class TestCertificates public static ICertAndThumbprint CertFor(CertAndThumbprint staticCert, DisposableCollection disposedBy) { #if NETFRAMEWORK - return TempCertAndThumbprint.GenerateSelfSignedCertificate(disposedBy); + return TempDisposableCertAndThumbprint.CreateSelfSigned(disposedBy); #else return staticCert; #endif From 679e564cb3584b06c42c18ff038a69250d033b22 Mon Sep 17 00:00:00 2001 From: Rhys Parry Date: Thu, 25 Jun 2026 08:59:34 +1000 Subject: [PATCH 14/14] empty commit to force build