Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//-----------------------------------------------------------------------
// <copyright file="DotNettyTlsHandshakeFailureSpec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Configuration;
using Akka.TestKit;
using Xunit;
using Xunit.Abstractions;

namespace Akka.Remote.Tests.Transport
{
public class DotNettyTlsHandshakeFailureSpec : AkkaSpec
{
private const string ValidCertPath = "Resources/akka-validcert.pfx";
private const string Password = "password";
private static readonly string NoKeyCertPath = Path.Combine("Resources", "handshake-no-key.cer");

public DotNettyTlsHandshakeFailureSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output)
{
}

private static Config CreateConfig(bool enableSsl, string certPath, string certPassword, bool suppressValidation = true)
{
var baseConfig = ConfigurationFactory.ParseString(@"akka {
loglevel = DEBUG
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
remote.dot-netty.tcp {
port = 0
hostname = ""127.0.0.1""
enable-ssl = " + (enableSsl ? "on" : "off") + @"
log-transport = off
}
}");

if (!enableSsl || string.IsNullOrEmpty(certPath))
return baseConfig;

var escapedPath = certPath.Replace("\\", "\\\\");
var ssl = $@"akka.remote.dot-netty.tcp.ssl {{
suppress-validation = {(suppressValidation ? "on" : "off")}
certificate {{
path = ""{escapedPath}""
password = ""{certPassword ?? string.Empty}""
}}
}}";
return baseConfig.WithFallback(ssl);
}

private static void CreateCertificateWithoutPrivateKey()
{
var fullCert = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.Exportable);
var publicKeyBytes = fullCert.Export(X509ContentType.Cert);
var dir = Path.GetDirectoryName(NoKeyCertPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(NoKeyCertPath, publicKeyBytes);
}

[Fact]
public async Task Tls_handshake_failure_should_be_logged_and_detected()
{
CreateCertificateWithoutPrivateKey();

ActorSystem server = null;
ActorSystem client = null;

try
{
// Start TLS server with a cert that has no private key
var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true);

server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

// Server started - add an echo actor and subscribe to errors
server.ActorOf(Props.Create(() => new EchoActor()), "echo");

var errorProbe = CreateTestProbe(server);
server.EventStream.Subscribe(errorProbe.Ref, typeof(Event.Error));

// Start client with valid TLS cert
var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

var serverAddress = RARP.For(server).Provider.DefaultAddress;
var echoPath = new RootActorPath(serverAddress) / "user" / "echo";
var echoSel = client.ActorSelection(echoPath);

// Trigger association attempt
var probe = CreateTestProbe(client);
echoSel.Tell("ping", probe.Ref);

// Expect server to log TLS handshake failure promptly
var err = errorProbe.ExpectMsg<Event.Error>(TimeSpan.FromSeconds(10));
var msg = err.ToString();
Assert.Contains("TLS handshake failed", msg, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
try
{
if (File.Exists(NoKeyCertPath))
File.Delete(NoKeyCertPath);
} catch { /* ignore */ }
}
await Task.CompletedTask;
}

private sealed class EchoActor : ReceiveActor
{
public EchoActor()
{
ReceiveAny(msg => Sender.Tell(msg));
}
}
}
}
58 changes: 57 additions & 1 deletion src/core/Akka.Remote/Transport/DotNetty/TcpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Akka.Event;
using DotNetty.Buffers;
using DotNetty.Common.Utilities;
using DotNetty.Handlers.Tls;
using DotNetty.Transport.Channels;
using Google.Protobuf;

Expand Down Expand Up @@ -63,6 +64,25 @@ public override void ChannelRead(IChannelHandlerContext context, object message)
ReferenceCountUtil.SafeRelease(message);
}

public override void UserEventTriggered(IChannelHandlerContext context, object evt)
{
if (evt is TlsHandshakeCompletionEvent { IsSuccessful: false } tlsEvent)
{
var ex = tlsEvent.Exception ?? new Exception("TLS handshake failed.");
Log.Error(ex, "TLS handshake failed. Channel [{0}->{1}](Id={2})",
context.Channel.LocalAddress, context.Channel.RemoteAddress, context.Channel.Id);

// Best-effort surface to higher layers if listener already registered
NotifyListener(new UnderlyingTransportError(ex,
$"TLS handshake failed on channel [{context.Channel.LocalAddress}->{context.Channel.RemoteAddress}](Id={context.Channel.Id})"));

context.CloseAsync();
return; // don't pass to next handlers
}

base.UserEventTriggered(context, evt);
}
Comment on lines +67 to +84
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server side fix, listen for TLS handshake failure event and log/propagate it.


/// <summary>
/// TBD
/// </summary>
Expand Down Expand Up @@ -133,9 +153,11 @@ void InitInbound(IChannel channel, IPEndPoint socketAddress, object msg)
internal sealed class TcpClientHandler : TcpHandlers
{
private readonly TaskCompletionSource<AssociationHandle> _statusPromise = new();
private readonly TaskCompletionSource<bool> _tlsHandshakePromise = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Address _remoteAddress;

public Task<AssociationHandle> StatusFuture => _statusPromise.Task;
public Task TlsHandshakeTask => _tlsHandshakePromise.Task;

public TcpClientHandler(DotNettyTransport transport, ILoggingAdapter log, Address remoteAddress)
: base(transport, log)
Expand All @@ -150,6 +172,24 @@ public override void ChannelActive(IChannelHandlerContext context)

}

public override void UserEventTriggered(IChannelHandlerContext context, object evt)
{
if (evt is TlsHandshakeCompletionEvent tlsEvent)
{
if (tlsEvent.IsSuccessful)
{
_tlsHandshakePromise.TrySetResult(true);
}
else
{
var ex = tlsEvent.Exception ?? new Exception("TLS handshake failed.");
_tlsHandshakePromise.TrySetException(ex);
}
}

base.UserEventTriggered(context, evt);
}
Comment on lines +175 to +191
Copy link
Contributor Author

@Arkatufus Arkatufus Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client side fix, listen for TLS handshake failure and log/propagate it.


private void InitOutbound(IChannel channel, IPEndPoint socketAddress, object msg)
{
Init(channel, socketAddress, _remoteAddress, msg, out var handle);
Expand Down Expand Up @@ -207,7 +247,23 @@ protected override async Task<AssociationHandle> AssociateInternal(Address remot
socketAddress = await MapEndpointAsync(socketAddress).ConfigureAwait(false);
var associate = await clientBootstrap.ConnectAsync(socketAddress).ConfigureAwait(false);
var handler = (TcpClientHandler)associate.Pipeline.Last();
return await handler.StatusFuture.ConfigureAwait(false);
// Wait for channel activation (socket connect)
var handle = await handler.StatusFuture.ConfigureAwait(false);

if (!Settings.EnableSsl)
return handle;

// If SSL is enabled, ensure the TLS handshake has completed successfully
try
{
await handler.TlsHandshakeTask.ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidAssociationException($"TLS handshake failed for {remoteAddress}: {ex.Message}", ex);
}

return handle;
}
catch (ConnectException c)
{
Expand Down
Loading