Skip to content

Expose per-attempt connection outcomes with the selected TLS client certificate #3252

Description

@tlupes

Summary

Please add supported Tunnel hooks for the outcome of every physical connection attempt.

The hooks should:

  • observe initial connections and reconnects;
  • report successful establishment after the Redis handshake completes;
  • report every failed attempt, including reconnect failures suppressed by the public ConnectionFailed event;
  • include an identifier for the client certificate selected for that physical attempt;
  • include the exception and failure stage when unsuccessful.

Motivation

We maintain a managed client-certificate integration for StackExchange.Redis. It selects a client certificate for each physical TLS connection and reports whether that exact certificate succeeded or failed, allowing fallback to a previously known-good certificate.

In short:

I want to provide a client certificate and know whether the physical connection using that certificate succeeded.

Tunnel is already a configuration-time extension point and is available before ConnectionMultiplexer.Connect returns, so it can observe initial connections as well as later reconnects. Adding physical-attempt outcome hooks there would avoid introducing another independent configuration callback.

Current limitations

Reconnect failures can be suppressed

PhysicalBridge.OnConnectionFailed raises the public ConnectionFailed event only while reportNextFailure is true.

After the first failure, the flag remains false until that bridge establishes a connection. Subsequent reconnect failures are therefore not reported through ConnectionFailed.

This creates the following sequence:

  1. Redis is connected successfully.
  2. The established connection closes with SocketClosed.
  3. ConnectionFailed is raised, consuming the bridge's notification.
  4. A newly rotated client certificate is selected for reconnect.
  5. The Redis server rejects that certificate.
  6. The reconnect fails, but no new ConnectionFailed event is raised because connectivity was never restored.
  7. The integration cannot report the rejected certificate or activate its known-good fallback.
  8. Subsequent reconnects continue using the rejected certificate.

A custom failure classifier cannot fix a notification that is never delivered.

Certificate selection and outcomes are separate

LocalCertificateSelectionCallback reports which certificate was selected, while ConnectionFailed and ConnectionRestored report outcomes independently.

The outcome does not identify the selected certificate. Maintaining a global FIFO queue is unsafe because interactive and subscription connections can attempt reconnection concurrently and complete out of order.

For example:

  1. Certificate A is selected for one physical connection.
  2. Certificate B is selected for another.
  3. The attempts complete in the opposite order.
  4. Certificate B's rejection can be incorrectly attributed to A.

This can produce incorrect fallback decisions, incorrect telemetry, and retained certificate references.

Proposed Tunnel hooks

Names and argument organization are illustrative:

public abstract class Tunnel
{
    public virtual ValueTask OnConnectionEstablishedAsync(
        ConnectionAttemptEventArgs args,
        CancellationToken cancellationToken) => default;

    public virtual ValueTask OnConnectionAttemptFailedAsync(
        ConnectionAttemptFailedEventArgs args,
        CancellationToken cancellationToken) => default;
}

Suggested arguments:

public class ConnectionAttemptEventArgs : EventArgs
{
    public EndPoint EndPoint { get; }

    public ConnectionType ConnectionType { get; }

    public string? PhysicalName { get; }

    public string? ClientCertificateThumbprint { get; }
}

public sealed class ConnectionAttemptFailedEventArgs
    : ConnectionAttemptEventArgs
{
    public ConnectionAttemptStage Stage { get; }

    public ConnectionFailureType FailureType { get; }

    public Exception Exception { get; }

    public bool? ServerCertificateAccepted { get; }

    public SslPolicyErrors? ServerCertificatePolicyErrors { get; }
}

Suggested stages:

public enum ConnectionAttemptStage
{
    SocketConnect,
    TlsAuthentication,
    RedisAuthentication,
    RedisHandshake,
}

An alternative is one exactly-once completion hook:

public virtual ValueTask OnConnectionAttemptCompletedAsync(
    ConnectionAttemptCompletedEventArgs args,
    CancellationToken cancellationToken) => default;

where the arguments include success/failure, stage, failure type, and exception.
Either shape works provided that every physical attempt produces exactly one terminal outcome.

ClientCertificateThumbprint can be either a sha1 or sha256 thumbprint, with the end user determining which based on length.
Alternatively, a second property ClientCertificateThumbprintHashAlgorithm can be added which states the algorithm used (using the .net HashAlgorithmName).

The stage and server-validation result help consumers exclude failures unrelated to the client certificate. Consumers may still need platform-specific classification unless StackExchange.Redis can expose a structured positive signal such as ClientCertificateRejected or a peer TLS alert.

Example usage

internal sealed class ManagedCertificateTunnel : Tunnel
{
    public override ValueTask OnConnectionEstablishedAsync(
        ConnectionAttemptEventArgs args,
        CancellationToken cancellationToken)
    {
        if (args.ClientCertificateThumbprint is not null)
        {
            // Report that the used certificate was healthy.
            // This can be used for telemetry or to mark a last-known-good certificate.
        }

        return default;
    }

    public override ValueTask OnConnectionAttemptFailedAsync(
        ConnectionAttemptFailedEventArgs args,
        CancellationToken cancellationToken)
    {
        if (args.ClientCertificateThumbprint is not null
            && IsClientCertificateRejection(args))
        {
            // Report that the used certificate was unhealthy.
            // This can be used for telemetry or to trigger fallback to a LKG certificate.
        }

        return default;
    }
}

Existing alternative

Tunnel.BeforeAuthenticateAsync makes a workaround possible today:

  1. The integration creates and authenticates its own SslStream.
  2. It disables StackExchange.Redis's normal TLS layer.
  3. It returns a custom stream that tracks the first Redis read and all pre-establishment I/O
    failures.

This provides deterministic certificate correlation, including delayed TLS 1.3 alerts, but it requires the integration to take ownership of substantial transport behavior:

  • TLS configuration and authentication;
  • stream and socket lifetime;
  • framework-specific synchronous and asynchronous stream members;
  • cloning SslClientAuthenticationOptions;
  • preserving existing custom tunnels;
  • introducing a replacement for the write-only ConfigurationOptions.CertificateValidation
    event;
  • tracking StackExchange.Redis I/O behavior across versions.

Physical-attempt hooks would allow StackExchange.Redis to continue owning TLS and transport details while providing the small amount of lifecycle information required by certificate integrations.

Logging is not a suitable alternative application-control-flow contract. It does not provide the selected certificate, structured stage information, or a stable guarantee that every physical attempt will continue to be logged.

Compatibility

The proposed methods are additive virtual members with default no-op implementations.

The existing ConnectionFailed and ConnectionRestored events can retain their current suppression and notification behavior.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions