Skip to content

3.0.x no longer sends TLS SNI when ssl_check_hostname=False, breaking SNI-routed clusters (Strimzi ingress, TLS-passthrough LBs) #3113

Description

@chrisfarnham

Summary

kafka-python 3.0.x only sends the TLS SNI extension when ssl_check_hostname is true. kafka-python ≤ 2.x always sent it. Any deployment where TLS connections are routed by SNI — Kubernetes TLS-passthrough ingresses (Strimzi type: ingress listeners, Istio Gateway TLSRoute, nginx ssl_preread, HAProxy req_ssl_sni) — becomes unreachable for clients that disable hostname verification, which is common with private/self-signed cluster CAs.

The failure is hard to diagnose from the client side: the router can't match a route and drops the connection mid-handshake, so bootstrap fails with

KafkaConnectionError: Handshake failed: [Errno 104] Connection reset by peer
kafka.errors.KafkaTimeoutError: Unable to bootstrap from [...]

with nothing suggesting SNI as the cause.

I searched existing issues for SNI, server_hostname, ssl_check_hostname, and post-3.0 handshake/connection-reset reports and found no prior report of this — apologies if I missed one. Closest prior art is #1957, which asked for clarification of ssl_check_hostname semantics back in the 2.x era; the answer there (and 2.x behavior) treated the flag as governing hostname verification only. In 3.0 it now additionally governs whether SNI is sent at all, which is a silent wire-behavior change relative to every 2.x release.

Root cause

kafka/net/transport.py (master @ 7c5003a5, present since 3.0.0):

class KafkaSSLTransport(KafkaTCPTransport):
    def __init__(self, net, sock, ssl_context, host=None, ssl_check_hostname=False):
        self._ssl_context = ssl_context
        server_hostname = host if ssl_check_hostname else None   # <-- line 376
        sock = ssl_context.wrap_socket(
            sock, server_hostname=server_hostname, do_handshake_on_connect=False)

2.x behavior for comparison (kafka/conn.py):

self._sock = self._ssl_context.wrap_socket(
    self._sock,
    server_hostname=self.host.rstrip("."),
    do_handshake_on_connect=False)

This couples two independent concerns: server_hostname controls what SNI is sent and what name is verified, but whether verification happens at all is governed by SSLContext.check_hostname — which the client already sets from ssl_check_hostname in _build_ssl_context(). Passing server_hostname=None to suppress verification also suppresses SNI.

Reproduction

Minimal setup: an SSL broker behind an nginx ssl_preread passthrough router that routes by SNI (config below), client configured with ssl_check_hostname=False.

KafkaProducer(
    bootstrap_servers=["kafka-ingress:9096"],
    security_protocol="SSL",
    ssl_cafile="cluster-ca.pem",
    ssl_certfile="client.crt",
    ssl_keyfile="client.key",
    ssl_check_hostname=False,   # 2.x: works; 3.0.x: bootstrap times out
)
# nginx.conf (stream) — stand-in for any SNI-routing ingress
stream {
  map $ssl_preread_server_name $backend {
    kafka-ingress kafka:9095;
    default       127.0.0.1:9;   # unroutable: no/unknown SNI
  }
  server { listen 9096; ssl_preread on; proxy_pass $backend; }
}

Observed (3.0.7, also reproduced on 3.0.2): with ssl_check_hostname=True the ClientHello carries SNI, nginx routes it, bootstrap succeeds. With ssl_check_hostname=False no SNI is sent, nginx can't route, and every bootstrap attempt dies mid-handshake until bootstrap_timeout_ms expires. Identical config on kafka-python 2.x works in both cases. nginx access logs confirm: sni="kafka-ingress" vs sni="". I can attach a self-contained docker-compose reproduction if useful.

Real-world instance: producers connecting to a Strimzi-managed cluster on EKS through an Istio Gateway in TLS-passthrough mode (SNI-matched TLSRoutes) crash at construction after upgrading 2.x → 3.0.x, with ssl_check_hostname=False set because the cluster uses a private CA.

Proposed fix

Always pass server_hostname; let the context decide whether to verify it (wrap_socket ignores the name for verification purposes when context.check_hostname is false):

server_hostname = host.rstrip(".") if host else None

This restores 2.x wire behavior (including the trailing-dot strip for FQDNs) and resolves the #1957-style ambiguity in the conservative direction: ssl_check_hostname goes back to meaning verification only. As a bonus it fixes a latent ValueError: check_hostname requires server_hostname for users who pass a custom ssl_context with check_hostname=True while leaving ssl_check_hostname=False in the client config.

Happy to submit a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions