You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2026/08/12 16:31:36 UTC
(pulsar) branch master updated: [feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)
This is an automated email from the ASF dual-hosted git repository.
lhotari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git
The following commit(s) were added to refs/heads/master by this push:
new fd7eebbb1036 [feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)
fd7eebbb1036 is described below
commit fd7eebbb10365910374d2374a0cff37acee1fecd
Author: Lari Hotari <lh...@users.noreply.github.com>
AuthorDate: Wed Aug 12 19:31:29 2026 +0300
[feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)
---
conf/broker.conf | 50 +-
conf/client.conf | 14 +-
conf/functions_worker.yml | 38 +-
conf/proxy.conf | 49 +-
conf/standalone.conf | 45 +-
conf/websocket.conf | 31 +-
pip/pip-478.md | 74 ++-
.../apache/pulsar/broker/ServiceConfiguration.java | 81 ++-
.../pulsar/broker/tls/DefaultBrokerTlsFactory.java | 53 +-
.../pulsar/broker/tls/TlsFactorySupport.java | 39 ++
.../configuration/PulsarConfigurationLoader.java | 54 ++
.../pulsar/jetty/tls/JettySslContextFactory.java | 82 ---
.../broker/tls/DefaultBrokerTlsPolicyTest.java | 42 ++
.../PulsarConfigurationLoaderTest.java | 64 ++
.../jetty/tls/JettySslContextFactoryTest.java | 206 ------
.../JettySslContextFactoryWithKeyStoreTest.java | 252 --------
pulsar-broker/build.gradle.kts | 3 +
.../org/apache/pulsar/broker/PulsarService.java | 127 +++-
.../pulsar/broker/namespace/NamespaceService.java | 17 +-
.../pulsar/broker/service/BrokerService.java | 192 +++++-
.../broker/service/PulsarChannelInitializer.java | 107 ++--
.../org/apache/pulsar/broker/web/WebService.java | 94 ++-
.../apache/pulsar/compaction/CompactorTool.java | 4 +-
.../pulsar/broker/admin/AdminApiTlsAuthTest.java | 11 +-
.../admin/BrokerAdminClientTlsFactoryTest.java | 87 +++
.../pulsar/broker/service/BrokerServiceTest.java | 7 +-
.../service/OneWayReplicatorDeduplicationTest.java | 6 +-
.../broker/service/ReplicatorTlsFactoryTest.java | 195 ++++++
.../tls/BrokerClientPerClusterTlsFactoryTest.java | 281 +++++++++
.../pulsar/broker/tls/CountingWebTlsFactory.java | 107 ++++
.../apache/pulsar/broker/web/WebServiceTest.java | 6 +-
.../AuthenticationTlsHostnameVerificationTest.java | 47 +-
.../pulsar/client/api/BrokerServiceLookupTest.java | 4 +-
.../pulsar/client/api/ProxyProtocolTest.java | 8 +-
.../client/api/TlsFactoryProducerConsumerTest.java | 96 +++
.../client/api/v5/V5TlsProducerConsumerTest.java | 115 ++++
.../client/impl/AdminApiKeyStoreTlsAuthTest.java | 33 +-
.../client/impl/AdminOnlyOAuth2AuthTest.java | 91 +++
.../apache/pulsar/client/impl/KeyStoreTlsTest.java | 106 ----
.../worker/WorkerServerTlsFactoryTest.java | 98 +++
.../proxy/ProxyPublishConsumeTlsTest.java | 7 +-
.../proxy/WebSocketProxyTlsFactoryTest.java | 92 +++
.../pulsar/client/admin/PulsarAdminBuilder.java | 23 +-
.../pulsar/common/policies/data/ClusterData.java | 76 +++
pulsar-client-admin/build.gradle.kts | 13 +
.../pulsar/client/admin/internal/BaseResource.java | 81 ++-
.../client/admin/internal/ComponentResource.java | 21 +-
.../admin/internal/PulsarAdminBuilderImpl.java | 12 +-
.../client/admin/internal/PulsarAdminImpl.java | 160 ++++-
.../admin/internal/http/AsyncHttpConnector.java | 265 +++++---
.../internal/http/AsyncHttpConnectorProvider.java | 76 ++-
.../admin/internal/http/TlsFactoryOwnership.java | 114 ++++
.../internal/AdminOAuth2IdpTlsBindingTest.java | 110 ++++
.../internal/AdminOAuth2IdpTlsEndToEndTest.java | 238 +++++++
.../internal/AdminTlsFactoryLifecycleTest.java | 188 ++++++
.../internal/http/AsyncHttpConnectorTest.java | 28 +
pulsar-client-api-v5/build.gradle.kts | 5 +
.../pulsar/client/api/v5/PulsarClientBuilder.java | 49 +-
.../client/api/v5/PulsarClientException.java | 33 +
.../auth/{package-info.java => AuthChallenge.java} | 20 +-
.../pulsar/client/api/v5/auth/Authentication.java | 96 ++-
.../api/v5/auth/AuthenticationCallContext.java | 69 ++
.../client/api/v5/auth/AuthenticationData.java | 99 ---
.../client/api/v5/auth/AuthenticationFactory.java | 16 +-
.../api/v5/auth/AuthenticationInitContext.java | 85 +++
.../api/v5/auth/BinaryAuthChallengeHandler.java | 49 ++
.../pulsar/client/api/v5/auth/BinaryAuthData.java | 40 ++
.../client/api/v5/auth/BinaryAuthDataProvider.java | 49 ++
.../client/api/v5/auth/ChallengeResponse.java | 39 ++
.../client/api/v5/auth/HttpAuthCallContext.java | 68 ++
.../api/v5/auth/HttpAuthChallengeHandler.java | 49 ++
.../pulsar/client/api/v5/auth/HttpAuthHeaders.java | 118 ++++
.../api/v5/auth/HttpAuthHeadersProvider.java | 42 ++
.../api/v5/auth/SinglePassAuthentication.java} | 16 +-
.../pulsar/client/api/v5/auth/package-info.java | 57 +-
.../pulsar/client/api/v5/config/TlsPolicy.java | 177 ------
.../pulsar/client/api/v5/config/package-info.java | 6 +-
.../v5/internal/ClientAuthenticationServices.java | 78 +++
.../ClientAuthenticationServicesAware.java | 47 ++
.../api/v5/internal/PulsarClientProvider.java | 2 +-
.../org/apache/pulsar/client/api/v5/Examples.java | 2 +-
.../apache/pulsar/client/api/ClientBuilder.java | 19 +-
.../api/internal/AsyncAuthenticationDriver.java | 96 +++
.../pulsar/client/api/internal/package-info.java | 13 +-
.../org/apache/pulsar/admin/cli/CmdClusters.java | 31 +-
.../apache/pulsar/client/cli/PulsarClientTool.java | 63 +-
.../apache/pulsar/admin/cli/TestCmdClusters.java | 21 +
.../pulsar/client/cli/PulsarClientToolTest.java | 162 +++++
pulsar-client-v5/build.gradle.kts | 2 +
.../client/impl/v5/AuthenticationAdapter.java | 202 ------
.../client/impl/v5/PulsarClientBuilderV5.java | 306 ++++++++-
.../client/impl/v5/PulsarClientProviderV5.java | 24 +-
.../client/impl/v5/auth/SimpleAuthCallContext.java | 57 ++
.../client/impl/v5/auth/SimpleAuthInitContext.java | 80 +++
.../impl/v5/auth/V5ToV4AuthenticationAdapter.java | 351 +++++++++++
.../pulsar/client/impl/v5/auth}/package-info.java | 14 +-
.../client/impl/v5/AuthenticationAdapterTest.java | 62 --
.../v5/CredentialOffloadThroughV5BuilderTest.java | 241 +++++++
.../client/impl/v5/PulsarClientBuilderV5Test.java | 356 ++++++++++-
.../client/impl/v5/V5AuthenticationLoaderTest.java | 171 +++++
.../v5/auth/V5ToV4AuthenticationAdapterTest.java | 251 ++++++++
pulsar-client/build.gradle.kts | 19 +
.../pulsar/client/impl/ClientBuilderImpl.java | 15 +-
.../org/apache/pulsar/client/impl/ClientCnx.java | 311 ++++++++-
.../apache/pulsar/client/impl/ConnectionPool.java | 18 +
.../org/apache/pulsar/client/impl/HttpClient.java | 280 ++++++---
.../pulsar/client/impl/HttpLookupService.java | 10 +-
.../client/impl/PulsarChannelInitializer.java | 197 +++---
.../pulsar/client/impl/PulsarClientImpl.java | 295 ++++++++-
.../client/impl/auth/AuthenticationBasic.java | 31 +-
.../client/impl/auth/AuthenticationDataTls.java | 18 +-
.../impl/auth/AuthenticationKeyStoreTls.java | 11 +
.../client/impl/auth/AuthenticationToken.java | 110 ++--
.../impl/auth/oauth2/AuthenticationOAuth2.java | 60 +-
.../pulsar/client/impl/auth/oauth2/FlowBase.java | 190 +++---
.../impl/auth/oauth2/OAuth2AuthenticationV5.java | 112 ++++
.../oauth2/StandaloneOAuth2HttpClientFactory.java | 175 ++++++
.../oauth2/protocol/DefaultMetadataResolver.java | 30 +-
.../impl/auth/oauth2/protocol/TokenClient.java | 37 +-
.../auth/v5/AsyncHttpAuthenticationProvider.java | 47 ++
.../pulsar/client/impl/auth/v5/AuthMetrics.java | 127 ++++
.../client/impl/auth/v5/BasicAuthenticationV5.java | 99 +++
.../impl/auth/v5/BinaryAuthenticationExchange.java | 201 ++++++
.../v5/DefaultClientAuthenticationServices.java | 46 ++
.../client/impl/auth/v5/FrameworkHttpClient.java | 184 ++++++
.../impl/auth/v5/FrameworkHttpClientFactory.java | 282 +++++++++
.../impl/auth/v5/HttpAuthCallContextImpl.java | 85 +++
.../impl/auth/v5/HttpAuthenticationDriver.java | 277 ++++++++
.../impl/auth/v5/HttpChallengeTransport.java | 68 ++
.../auth/v5/LegacyV4AuthenticationAdapter.java | 436 +++++++++++++
.../client/impl/auth/v5/TlsAuthentication.java | 64 ++
.../client/impl/auth/v5/TokenAuthenticationV5.java | 192 ++++++
.../pulsar/client/impl/auth/v5/V5AuthContexts.java | 211 +++++++
.../impl/auth/v5/V5AuthenticationLoader.java | 132 ++++
.../impl/auth/v5/V5BinaryAuthenticationDriver.java | 165 +++++
.../pulsar/client/impl/auth/v5}/package-info.java | 15 +-
.../client/impl/conf/ClientConfigurationData.java | 57 +-
.../client/impl/conf/ConfigurationDataUtils.java | 48 ++
.../client/impl/tls/ClientTlsFactorySupport.java | 695 +++++++++++++++++++++
.../pulsar/client/impl}/tls/package-info.java | 7 +-
.../util/PulsarHttpAsyncSslEngineFactory.java | 69 --
.../pulsar/client/impl/ClientCnxAsyncAuthTest.java | 545 ++++++++++++++++
.../impl/ClientCnxRequestTimeoutQueueTest.java | 7 +
.../ClientTlsFactoryRebuildOnFailoverTest.java | 92 +++
.../apache/pulsar/client/impl/HttpClientTest.java | 78 +++
.../impl/OAuth2IdpTlsPlaintextBrokerTest.java | 157 +++++
...AuthenticationTokenSerializationCompatTest.java | 90 +++
.../oauth2/AuthenticationFactoryOAuth2Test.java | 28 +-
.../impl/auth/oauth2/AuthenticationOAuth2Test.java | 16 +-
.../impl/auth/oauth2/OAuth2IdpTlsFoldTest.java | 86 +++
.../oauth2/OAuth2IdpTlsFrameworkClientTest.java | 214 +++++++
.../impl/auth/oauth2/OAuth2MockHttpClient.java | 49 --
.../StandaloneOAuth2HttpClientFactoryTest.java | 174 ++++++
.../impl/auth/oauth2/TlsClientAuthFlowTest.java | 8 +-
.../impl/auth/oauth2/protocol/TokenClientTest.java | 103 +--
.../impl/auth/v5/AuthInitializationRetryTest.java | 95 +++
.../client/impl/auth/v5/AuthMetricsTest.java | 149 +++++
.../auth/v5/BinaryAuthenticationExchangeTest.java | 129 ++++
.../client/impl/auth/v5/CredentialOffloadTest.java | 189 ++++++
.../auth/v5/FrameworkHttpClientFactoryTest.java | 251 ++++++++
.../impl/auth/v5/HttpAuthenticationDriverTest.java | 303 +++++++++
.../auth/v5/LegacyV4AuthenticationAdapterTest.java | 348 +++++++++++
.../client/impl/auth/v5/TlsAuthenticationTest.java | 42 ++
.../impl/conf/ConfigurationDataUtilsTest.java | 53 ++
.../impl/tls/BrokerClientPurposeFactoryTest.java | 172 +++++
.../impl/tls/ClientDefaultPolicyStoreTypeTest.java | 121 ++++
.../client/impl/tls/ClientEngineProviderTest.java | 60 ++
.../tls/ClientTlsFactoryParamsDeliveryTest.java | 316 ++++++++++
.../common/policies/data/ClusterDataImpl.java | 58 +-
.../tls/impl/FileBasedTlsFactorySettings.java | 22 +
.../common/tls/impl/TlsContextAcquisition.java | 24 +
.../org/apache/pulsar/common/tls/package-info.java | 17 +
.../common/util/FileModifiedTimeUpdaterTest.java | 24 -
.../pulsar/functions/worker/WorkerConfig.java | 57 +-
.../worker/PulsarWorkerOpenTelemetry.java | 9 +-
.../pulsar/functions/worker/WorkerUtils.java | 56 ++
.../pulsar/functions/worker/rest/WorkerServer.java | 137 ++--
pulsar-proxy/build.gradle.kts | 8 +
.../pulsar/proxy/server/AdminProxyHandler.java | 153 ++---
.../pulsar/proxy/server/DirectProxyHandler.java | 76 +--
.../apache/pulsar/proxy/server/ProxyClientCnx.java | 6 +-
.../pulsar/proxy/server/ProxyConfiguration.java | 72 ++-
.../pulsar/proxy/server/ProxyConnection.java | 39 +-
.../apache/pulsar/proxy/server/ProxyService.java | 120 +++-
.../pulsar/proxy/server/ProxyServiceStarter.java | 11 +-
.../pulsar/proxy/server/ProxyTlsFactories.java | 169 +++++
.../proxy/server/ServiceChannelInitializer.java | 104 +--
.../org/apache/pulsar/proxy/server/WebServer.java | 122 ++--
.../proxy/stats/PulsarProxyOpenTelemetry.java | 9 +-
.../AuthedAdminProxyHandlerNewTlsPathTest.java | 39 ++
.../proxy/server/AuthedAdminProxyHandlerTest.java | 13 +
.../server/ProxyBrokerClientTlsOrderingTest.java | 196 ++++++
.../server/ProxyKeyStoreTlsTransportTest.java | 6 +
.../proxy/server/ProxyKeyStoreTlsWithAuthTest.java | 5 +
.../server/ProxyKeyStoreTlsWithoutAuthTest.java | 5 +
.../pulsar/proxy/server/ProxyMutualTlsTest.java | 5 +
.../proxy/server/ProxyServiceTlsStarterTest.java | 5 +
.../org/apache/pulsar/proxy/server/ProxyTest.java | 4 +-
.../pulsar/proxy/server/ProxyTlsFactoriesTest.java | 68 ++
.../proxy/server/ProxyTlsFactoryMetricsTest.java | 143 +++++
...{ProxyTlsTest.java => ProxyTlsFactoryTest.java} | 58 +-
.../apache/pulsar/proxy/server/ProxyTlsTest.java | 5 +
.../proxy/server/ProxyWithAuthorizationTest.java | 71 ++-
.../server/ProxyWithoutServiceDiscoveryTest.java | 5 +
.../server/SslContextFallbackSynthesisTest.java | 229 +++++++
.../proxy/server/SslContextOnlyTlsFactory.java | 155 +++++
.../apache/pulsar/testclient/PerfClientUtils.java | 62 +-
.../testclient/PerformanceBaseArguments.java | 9 -
.../pulsar/testclient/PerfClientUtilsTest.java | 30 +
pulsar-websocket/build.gradle.kts | 3 +
.../apache/pulsar/websocket/WebSocketService.java | 28 +
.../pulsar/websocket/service/ProxyServer.java | 162 +++--
.../service/WebSocketProxyConfiguration.java | 48 +-
.../websocket/WebSocketProxyConfigurationTest.java | 3 +-
tests/certificate-authority/ec/ca.cert.srl | 2 +-
tests/certificate-authority/ec/jks/ca.cert.srl | 2 +-
.../ec/jks/key_store_generation.txt | 17 +-
.../ec/jks/server.keystore.jks | Bin 2004 -> 2164 bytes
.../ec/jks/server.signed.cert.pem | 24 +-
tests/certificate-authority/ec/server.cert.pem | 16 +-
tests/certificate-authority/ec/server.conf | 1 +
tests/certificate-authority/ec/server.csr.pem | 6 +-
tests/certificate-authority/generate_keystore.sh | 10 +-
.../certificate-authority/jks/broker.keystore.jks | Bin 2254 -> 2281 bytes
.../jks/broker.truststore.jks | Bin 969 -> 997 bytes
.../jks/broker.truststore.nopassword.jks | Bin 969 -> 997 bytes
.../jks/broker.truststore.pem | 113 ++--
.../integration/topologies/PulsarCluster.java | 9 +
228 files changed, 17540 insertions(+), 2964 deletions(-)
diff --git a/conf/broker.conf b/conf/broker.conf
index d1ab312300c1..8fbb45ea638e 100644
--- a/conf/broker.conf
+++ b/conf/broker.conf
@@ -857,9 +857,20 @@ proxyRoles=
# else it just accepts the originalPrincipal and authorizes it (if required).
authenticateOriginalAuthData=false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
tlsCertRefreshCheckDurationSec=300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's server-side TLS (binary listener and
+# web server; purposes BROKER/PROXY/WEB). An empty value or the literal 'default' selects the built-in
+# DefaultBrokerTlsFactory composed from these tls* settings, otherwise the named class is instantiated via
+# its public no-arg constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin
+# keys are rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
# Path for the TLS certificate file
tlsCertificateFilePath=
@@ -878,8 +889,9 @@ tlsTrustCertsFilePath=
# though the cert will not be used for client authentication.
tlsAllowInsecureConnection=false
-# Whether the hostname is validated when the broker creates a TLS connection with other brokers
-tlsHostnameVerificationEnabled=false
+# Whether the hostname is validated when the broker creates a TLS connection with other brokers.
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
# Specify the tls protocols the broker will use to negotiate during TLS handshake
# (a comma-separated list of protocol names).
@@ -909,10 +921,22 @@ tlsRequireTrustedClientCertOnConnect=false
# native engine.
tlsProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the broker's server-side
+# (listener/web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered
+# separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set, the default
+# factory builds the JDK engine with this provider as the SSLContext provider, overriding the engine
+# choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered provider,
+# failing loudly when unresolvable.
+# jsseProvider=
+
# Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
# This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has no native TLS
# engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
### --- KeyStore TLS config variables --- ###
## Note that some of the above TLS configs also apply to the KeyStore TLS configuration.
@@ -947,6 +971,14 @@ brokerClientTlsEnabledWithKeyStore=false
# The TLS Provider used by internal client to authenticate with other Pulsar brokers
brokerClientSslProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# broker's own outbound (broker-to-broker / replication) client connections, e.g. BCJSSE for FIPS (with
+# the BC-FIPS crypto provider registered separately). When set, the default factory builds the JDK engine
+# with this provider as the SSLContext provider, overriding the engine choice. Resolved via the
+# ServiceLoader mechanism with a fallback to an already-registered provider, failing loudly when
+# unresolvable.
+# brokerClientJsseProvider=
+
# TLS trusted certificate file for internal client,
# used by the internal client to authenticate with Pulsar brokers
brokerClientTrustCertsFilePath=
@@ -997,6 +1029,16 @@ brokerClientTlsCiphers=
# brokerClientTlsProtocols=TLSv1.3,TLSv1.2
brokerClientTlsProtocols=
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's own outbound (broker-to-broker) client
+# connections (purpose BROKER_CLIENT). An empty value or the literal 'default' selects the built-in
+# default factory composed from the brokerClient tls* settings, otherwise the named class is instantiated
+# via its public no-arg constructor. This is the only outbound-client TLS path; the removed PIP-337
+# brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
# You can add extra configuration options for the Pulsar Client and the Pulsar Admin Client
# by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
# and before the above brokerClient configurations named above.
diff --git a/conf/client.conf b/conf/client.conf
index 5942bdb75cb8..df44375cd2bd 100644
--- a/conf/client.conf
+++ b/conf/client.conf
@@ -46,9 +46,10 @@ authParams=
# authority.
tlsAllowInsecureConnection=false
-# Whether server hostname must match the common name of the certificate
-# the server is using.
-tlsEnableHostnameVerification=false
+# Whether the server hostname is verified against the subject alternative names (SANs) of the
+# certificate the server is using (RFC 2818/6125; CN matching is not supported).
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsEnableHostnameVerification=true
# Path for the trusted TLS certificate file.
# This cert is used to verify that any cert presented by a server
@@ -88,6 +89,13 @@ tlsKeyStorePassword=
# When TLS authentication with KeyStore is used, available options can be SunJSSE, Conscrypt and so on.
webserviceTlsProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the client's TLS SSLContext,
+# e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered separately). A distinct axis from the
+# JDK-vs-OpenSSL engine selection (sslProvider): when set, the TLS context is built on the JDK engine with
+# this provider as the SSLContext provider, overriding the engine choice. Resolved via the ServiceLoader
+# mechanism with a fallback to an already-registered provider, failing loudly when unresolvable.
+# jsseProvider=
+
#Proxy-server URL to which to connect
proxyServiceUrl=
diff --git a/conf/functions_worker.yml b/conf/functions_worker.yml
index 794cfba1d519..e6dfbc35255a 100644
--- a/conf/functions_worker.yml
+++ b/conf/functions_worker.yml
@@ -348,10 +348,28 @@ tlsKeyFilePath:
tlsTrustCertsFilePath:
# Accept untrusted TLS certificate from client
tlsAllowInsecureConnection: false
-# Whether server hostname must match the common name of the certificate
-tlsEnableHostnameVerification: false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Whether the server hostname is verified against the subject alternative names (SANs) of the
+# server certificate (RFC 2818/6125; CN matching is not supported).
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsEnableHostnameVerification: true
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
tlsCertRefreshCheckDurationSec: 300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the functions-worker web server TLS (purpose WEB).
+# When set, the new PIP-478 TLS SPI is used instead of the built-in file-based TLS loading: an empty value
+# or the literal 'default' selects the built-in default factory composed from these tls* settings, otherwise
+# the named class is instantiated via its public no-arg constructor.
+tlsFactoryClassName: ""
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig: ""
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the functions-worker's own outbound
+# (worker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the broker-client tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor.
+brokerClientTlsFactoryClassName: ""
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or comma-separated
+# key=value list)
+brokerClientTlsFactoryConfig: ""
# Whether client certificates are required for TLS. Connections are rejected if the client
# certificate isn't trusted.
tlsRequireTrustedClientCertOnConnect: false
@@ -360,7 +378,19 @@ tlsRequireTrustedClientCertOnConnect: false
## Note that some of the above TLS configs also apply to the KeyStore TLS configuration.
# Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
-tlsProvider: Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's
+# default provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt
+# ships native libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why
+# the default falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+tlsProvider:
+
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the function worker's
+# web-service TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered
+# separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set, the
+# default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider:
# Enable TLS with KeyStore type configuration in function worker.
tlsEnabledWithKeyStore: false
diff --git a/conf/proxy.conf b/conf/proxy.conf
index fdc7fce72061..fda1c872712d 100644
--- a/conf/proxy.conf
+++ b/conf/proxy.conf
@@ -119,11 +119,23 @@ numAcceptorThreads=
# native engine.
tlsProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the proxy's server-side
+# (binary front-end / web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider
+# registered separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set,
+# the default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider=
+
# Specify the TLS provider for the web service, available values can be SunJSSE, Conscrypt and etc.
# This names a JSSE (SSLContext) security provider for a Jetty-based web service, which has no native TLS
# engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
# Note that the proxy's own HTTPS listener reads tlsProvider rather than this setting.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
# Enable TLS with KeyStore type configuration in proxy.
tlsEnabledWithKeyStore=false
@@ -218,9 +230,37 @@ brokerClientTrustCertsFilePath=
# Whether TLS is enabled when communicating with Pulsar brokers
tlsEnabledWithBroker=false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
tlsCertRefreshCheckDurationSec=300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the proxy's server-side TLS (binary front-end and
+# web server; purposes PROXY/WEB). An empty value or the literal 'default' selects the built-in default
+# factory composed from these tls* settings, otherwise the named class is instantiated via its public
+# no-arg constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin keys are
+# rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the proxy's own outbound (proxy-to-broker) client
+# connections (purpose BROKER_CLIENT). An empty value or the literal 'default' selects the built-in default
+# factory composed from the brokerClient tls* settings, otherwise the named class is instantiated via its
+# public no-arg constructor. This is the only outbound-client TLS path; the removed PIP-337
+# brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# proxy's own outbound (proxy-to-broker) client connections, e.g. BCJSSE for FIPS (with the BC-FIPS
+# crypto provider registered separately). When set, the default factory builds the JDK engine with this
+# provider as the SSLContext provider, overriding the engine choice. Resolved via the ServiceLoader
+# mechanism with a fallback to an already-registered provider, failing loudly when unresolvable.
+# brokerClientJsseProvider=
+
# You can add extra configuration options for the Pulsar Client
# by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
# and before the above brokerClient configurations named above.
@@ -283,8 +323,9 @@ tlsTrustCertsFilePath=
# though the cert will not be used for client authentication.
tlsAllowInsecureConnection=false
-# Whether the hostname is validated when the proxy creates a TLS connection with brokers
-tlsHostnameVerificationEnabled=false
+# Whether the hostname is validated when the proxy creates a TLS connection with brokers.
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
# Specify the tls protocols the broker will use to negotiate during TLS handshake
# (a comma-separated list of protocol names).
diff --git a/conf/standalone.conf b/conf/standalone.conf
index eed7af99e5ad..5ffe609cf711 100644
--- a/conf/standalone.conf
+++ b/conf/standalone.conf
@@ -485,9 +485,20 @@ metadataStoreSerDesThreads=1
# Deprecated - Use webServicePortTls and brokerServicePortTls instead
tlsEnabled=false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
tlsCertRefreshCheckDurationSec=300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the embedded broker's server-side TLS (binary
+# listener and web server; purposes BROKER/PROXY/WEB). An empty value or the literal 'default' selects the
+# built-in DefaultBrokerTlsFactory composed from these tls* settings, otherwise the named class is
+# instantiated via its public no-arg constructor. This is the only server TLS path; the removed PIP-337
+# sslFactoryPlugin keys are rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
# Path for the TLS certificate file
tlsCertificateFilePath=
@@ -534,10 +545,22 @@ tlsRequireTrustedClientCertOnConnect=false
# native engine.
tlsProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the embedded broker's
+# server-side (listener/web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider
+# registered separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set,
+# the default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider=
+
# Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
# This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has no native TLS
# engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
### --- KeyStore TLS config variables --- ###
# Enable TLS with KeyStore type configuration in broker.
@@ -570,6 +593,14 @@ brokerClientTlsEnabledWithKeyStore=false
# The TLS Provider used by internal client to authenticate with other Pulsar brokers
brokerClientSslProvider=
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# embedded broker's own outbound (broker-to-broker) client connections, e.g. BCJSSE for FIPS (with the
+# BC-FIPS crypto provider registered separately). When set, the default factory builds the JDK engine
+# with this provider as the SSLContext provider, overriding the engine choice. Resolved via the
+# ServiceLoader mechanism with a fallback to an already-registered provider, failing loudly when
+# unresolvable.
+# brokerClientJsseProvider=
+
# TLS trusted certificate file for internal client,
# used by the internal client to authenticate with Pulsar brokers
brokerClientTrustCertsFilePath=
@@ -620,6 +651,16 @@ brokerClientTlsCiphers=
# brokerClientTlsProtocols=TLSv1.3,TLSv1.2
brokerClientTlsProtocols=
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the embedded broker's own outbound
+# (broker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the brokerClient tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor. This is the only outbound-client TLS path; the
+# removed PIP-337 brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
# Enable or disable system topic
systemTopicEnabled=true
diff --git a/conf/websocket.conf b/conf/websocket.conf
index 91f7f7d4c23b..f7cca9f77dca 100644
--- a/conf/websocket.conf
+++ b/conf/websocket.conf
@@ -122,7 +122,8 @@ brokerClientAuthenticationParameters=
brokerClientTrustCertsFilePath=
# Whether the hostname is validated when connecting to the broker.
-tlsHostnameVerificationEnabled=false
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
# You can add extra configuration options for the Pulsar Client
# by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
@@ -153,11 +154,35 @@ tlsTrustCertsFilePath=
# Reject the Connection if the Client Certificate is not trusted.
tlsRequireTrustedClientCertOnConnect=false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
tlsCertRefreshCheckDurationSec=300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the WebSocket proxy's web server TLS (purpose WEB).
+# When set, the new PIP-478 TLS SPI is used instead of the built-in file-based TLS loading: an empty value
+# or the literal 'default' selects the built-in default factory composed from these tls* settings, otherwise
+# the named class is instantiated via its public no-arg constructor.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the WebSocket proxy's own outbound
+# (websocket-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the broker-client tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or comma-separated
+# key=value list)
+brokerClientTlsFactoryConfig=
+
# Specify the TLS provider for the WebSocket: SunJSSE, Conscrypt and etc.
-tlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's
+# default provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt
+# ships native libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why
+# the default falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+tlsProvider=
# Enable TLS with KeyStore type configuration in WebSocket.
tlsEnabledWithKeyStore=false
diff --git a/pip/pip-478.md b/pip/pip-478.md
index 2c7a95c5e262..9419f67750f1 100644
--- a/pip/pip-478.md
+++ b/pip/pip-478.md
@@ -273,7 +273,11 @@ public interface Authentication extends AutoCloseable {
/**
* Initialization step. Called once by the framework with runtime services
* after configuration. May do I/O; the returned future completes when the
- * implementation is ready to serve credentials.
+ * implementation is ready to serve credentials. MUST NOT block the calling
+ * thread: the framework may call this from an I/O thread and never waits
+ * for the future there. Start the I/O and return promptly, off-loading
+ * blocking work to ctx.blockingExecutor(). A failed future is not cached —
+ * the next use retries.
*/
CompletableFuture<Void> initializeAsync(AuthenticationInitContext ctx);
@@ -521,7 +525,7 @@ public interface AuthenticationInitContext {
}
```
-The lifecycle is: the framework constructs a single `AuthenticationInitContext` per `PulsarClient`, calls `initializeAsync(ctx)` once when the client is built, and the implementation may retain references for the lifetime of the client. `Authentication.close()` releases any resources the implementation acquired; the framework owns and closes the shared services (HTTP clients, scheduler, blocking executor). Per-call `AuthenticationCallContext` instances are cheap to allocate.
+The lifecycle is: the framework constructs a single `AuthenticationInitContext` per `PulsarClient` and calls `initializeAsync(ctx)` once, and the implementation may retain references for the lifetime of the client. When the plugin is configured through the v5 builder it is initialized **eagerly at client build**, on the application thread, so a misconfiguration fails the build. The framework's own drivers — which also carry a v4 built-in plugin that delegates to a v5 body, where there is [...]
### `AuthenticationCallContext` / `HttpAuthCallContext`
@@ -873,6 +877,10 @@ The tier-1 case is one expression — `builder.tlsPolicy(TlsPolicy.pem(trust, ce
**Not enabling the transport is not the same as being ignored: the client composes a TLS factory whenever TLS material is configured for *any* purpose.** A client TLS factory is composed (or an adopted one initialized) when *any* of these holds — the broker transport is TLS (`useTls` / `pulsar+ssl://`), a `tlsPolicy(purpose, …)` entry exists for *any* purpose (notably `CLIENT_OAUTH2`), a `tlsFactory(…)` instance was adopted, or the configured OAuth2 plugin carries its own IdP TLS materia [...]
+**The admin client follows the same rule, for the same reason.** `PulsarAdmin` selects transport TLS by an `https://` service URL rather than a `useTls` flag, but composition is again not the same question as enablement: an admin whose OAuth2 plugin carries IdP TLS material (folded into `CLIENT_OAUTH2`, see the OAuth2 note) composes a factory even behind a plaintext `http://` admin URL, because otherwise the framework HTTP client that fetches the token has nothing to resolve `CLIENT_OAUT [...]
+
+**One factory per client, on both paths.** `initialize(...)` is called exactly once per `PulsarClient` / `PulsarAdmin`, and `close()` exactly once, however many internal transports share it — a `PulsarAdmin` in particular drives more than one HTTP connector. Custom factories may therefore hold per-instance resources (HSM sessions, rotation tasks) without defending against repeated initialization, and whoever hands a factory *in* — the broker's admin attach is the one in-tree case — trans [...]
+
The corollary for tools: TLS *modifiers* (hostname verification, allow-insecure, trust/cert paths) shape a policy and never create one, so a secure default shipped in `conf/client.conf` cannot silently make a CLI speak TLS to a plaintext broker. In the CLIs the enabler set is therefore exactly: the `pulsar+ssl://` service-URL scheme, plus (for `pulsar-client`, which reads `client.conf`) an explicit `useTls=true`, which reaches the transport through `tlsPolicy(CLIENT_DEFAULT, …)`. The `ht [...]
**`jsseProvider` — pinning the JSSE (SSLContext) provider.** `TlsPolicy.jsseProvider` names a `java.security.Provider` (matched by `Provider.getName()`) that supplies the JSSE service types the default file-based path builds — the `SSLContext` (`TLS`) itself *and* the `KeyManagerFactory` / `TrustManagerFactory` initialized from the loaded material, the pair that keeps the private-key side inside a FIPS JSSE provider. Naming a crypto-only provider here (BC-FIPS, say) still *resolves*, sin [...]
@@ -1075,7 +1083,12 @@ Modified v4 public API: `org.apache.pulsar.client.api.ClientBuilder` and `org.ap
Dependency note: the v4 `pulsar-client` module gains **three** `api` dependencies — `pulsar-client-api-v5`, `pulsar-tls-factory-api`, and `pulsar-http-client-api` — because the migrated built-in v4 plugins delegate to v5-native bodies (in-scope item #7) and surface the TLS/HTTP SPI types on their exported ABI. The shaded v4 artifacts consequently bundle the v5 auth API, the TLS-factory API, and the HTTP-client API classes — unrelocated, as `org.apache.pulsar.client.api.v5.*`, `org.apache [...]
-Removed: `org.apache.pulsar.common.util.PulsarSslFactory`, `PulsarSslConfiguration`, and `org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext` (intentional breaking change — see Backward & Forward Compatibility); the PIP-337 v4 builder methods `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` and `PulsarAdminBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` (a v4 source-compatibility break); and the `ClusterData.brokerClientSslFactory [...]
+Removed: `org.apache.pulsar.common.util.PulsarSslFactory`, `PulsarSslConfiguration`, and `org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext` (intentional breaking change — see Backward & Forward Compatibility); the PIP-337 v4 builder methods `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` and `PulsarAdminBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` (a v4 source-compatibility break); and — deprecated rather than removed — the [...]
+
+- **`ClientCnx.newConnectCommand()` → `buildConnectCommand(AuthData)`.** The `protected` hook that built the `CommandConnect` frame also *fetched* the credential (`authentication.getAuthData(...)` then `authenticate(...)`), which cannot work once the credential is a future. Credential resolution moves out to `resolveConnectAuthData()`, and the overridable hook now receives the already-resolved `AuthData`, so an override composes with both the sync and async paths. `ClientCnx` carries no [...]
+- **`PulsarClientProvider.authenticationTls(String, String)` → `authenticationTls()`.** The mTLS marker plugin no longer carries cert/key paths: material goes through `tlsPolicy(...)`, so `AuthenticationFactory.tls()` is the no-arg form and the provider seam matches it. `PulsarClientProvider` is in the `.internal.` subpackage (framework-driven, "application code neither implements nor consumes"), and both it and the v5 auth API ship first in 5.0, so nothing released depends on the two-ar [...]
+
+Also removed, but **internal-only** (no `@InterfaceStability`, no client-API importer — not a compatibility break): the `org.apache.pulsar.common.util.SecurityUtility` grab-bag (decomposed into the cohesive `PemReader` / `JcaProviders` / `JdkSslContexts` containers), the obsolete `TrustManagerProxy` / `KeyManagerProxy` delegate-swap rotation helpers, and the unused `SSLContextValidatorEngine` — see *Removal* in the Detailed Design.
### Binary protocol
@@ -1108,15 +1121,26 @@ These keys are the successors of PIP-337's `sslFactoryPlugin` / `sslFactoryPlugi
Notes on the table:
-- **Defaults.** Both provider keys are unset on every surface, and unset means today's behaviour. The one
- non-empty legacy default *routed onto the JSSE axis* is the WebSocket proxy's web-listener
- `tlsProvider=Conscrypt`, which the v4 routing below carries there whenever `jsseProvider` itself is left
- unset. (`webServiceTlsProvider` on `ServiceConfiguration` and `ProxyConfiguration` also defaults to
- `Conscrypt`. The broker reads it — `WebService` passes it to Jetty's `SslContextFactory` — but under
- PIP-337 it never reaches the built `SSLContext`, which `DefaultPulsarSslFactory` supplies with no
- provider, so the default is inert today; the proxy's web listener reads `tlsProvider` instead. Under
- this PIP the `WEB` purpose honours it: `webServiceTlsProvider` / `webServiceTlsProtocols` /
- `webServiceTlsCiphers` take precedence over the binary-listener keys and fall back to them when unset.)
+- **Defaults.** Both provider keys are unset on every surface, and unset means today's behaviour. Under this
+ PIP the `WEB` purpose honours the web-listener keys — `webServiceTlsProvider` / `webServiceTlsProtocols` /
+ `webServiceTlsCiphers` take precedence over the binary-listener keys and fall back to them when unset — and
+ a configured provider name is genuinely pinned, failing loudly when it cannot be resolved. That makes the
+ legacy `Conscrypt` defaults, which were unconditional, untenable as written — so this PIP keeps the
+ default but makes it **conditional on availability**: `webServiceTlsProvider` on `ServiceConfiguration` /
+ `ProxyConfiguration`, the functions worker's `tlsProvider` and the WebSocket proxy's web-listener
+ `tlsProvider` all resolve, when unset, to Conscrypt where Conscrypt actually loads and to the JVM default
+ everywhere else. Under PIP-337 those defaults were inert — the broker passed `webServiceTlsProvider` to
+ Jetty's `SslContextFactory`, which never reaches the built `SSLContext` that `DefaultPulsarSslFactory`
+ supplies with no provider — so no deployment has actually been running on Conscrypt, and honouring the
+ value here is what makes the setting real. An *unconditional* default would then break the web listener
+ out of the box wherever Conscrypt cannot load: `conscrypt-openjdk-uber` ships native libraries for x86_64
+ and, since 2.6.1, aarch64 — but not for every platform Pulsar runs on (s390x, for one), and it need not be
+ on the class path at all. Availability is therefore tested rather than assumed — Conscrypt's own
+ `checkAvailability()`, which is what fails where the native library is missing.
+ The conditionality applies to the **default only**: an operator who names a provider explicitly still gets
+ it pinned, and still gets the loud failure when it cannot be resolved, which is the point of pinning. It
+ applies to **server listeners only**, so it cannot interact with client-side hostname verification — a
+ server does not verify hostnames.
- **v4 auto-routing, JSSE axis only.** Existing deployments need no config change: a non-engine-literal
`sslProvider` (client, on the v4 configuration path — `ClientConfigurationData` / `loadConf` / the v4
client and admin builders; `bin/pulsar-client` reads only `jsseProvider` / `jcaProvider` from
@@ -1174,7 +1198,7 @@ New OpenTelemetry instruments:
### PIP-337 removal impact
-`PulsarSslFactory`, `PulsarSslConfiguration`, `DefaultPulsarSslFactory`, and `KeyStoreSSLContext` are removed (see *Removal* in the Detailed Design), and so are the PIP-337 `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` configuration keys themselves — **nothing PIP-337 is retained** (no `@Deprecated` fields, no config-only vestige). Be explicit about what this means operationally: deployment automation that sets the r [...]
+`PulsarSslFactory`, `PulsarSslConfiguration`, `DefaultPulsarSslFactory`, and `KeyStoreSSLContext` are removed (see *Removal* in the Detailed Design), and so are the PIP-337 `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **configuration-file keys** themselves — no PIP-337 key survives on a server config, a client config or a builder. The one deliberate exception is `ClusterData`, whose two fields are retained as `@Depr [...]
| Existing surface | Where | Disposition in 5.0 |
|---|---|---|
@@ -1185,8 +1209,8 @@ New OpenTelemetry instruments:
| `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` | **v4 public client API** | **Removed**; successor is the new `tlsFactoryClassName(String)` / `tlsFactoryConfig(String)` on the same builder (name a custom `PulsarTlsFactory` by class + JSON config). Deleting the methods is a v4 **source-compatibility break** — a deliberate 5.0 major-release break; the methods were already deprecated and non-functional, and the removal is inherently loud (a compile error), so [...]
| `PulsarAdminBuilder.sslFactoryPlugin(...)` / `sslFactoryPluginParams(...)` | v4 public admin API | Same treatment as `ClientBuilder` — **removed**, with the new `tlsFactoryClassName(String)` / `tlsFactoryConfig(String)` successors |
| `ClientConfigurationData.sslFactoryPlugin` / `sslFactoryPluginParams` | v4 client config | **Removed**; replaced by new `tlsFactoryClassName` / `tlsFactoryConfig` string fields (today the v4 config carries only a *transient* factory instance set via the v5 builder, no by-name path). A stale, non-default key in a `loadConf` map is rejected by the removed-key validation |
-| `ClusterData.brokerClientSslFactoryPlugin` / `...Params` | admin API + **serialized cluster metadata** | **Removed** (field, builder method, and `ClusterData` API accessor). Broker-client factory-class selection is **broker-level** (`ServiceConfiguration.brokerClientTlsFactoryClassName`), not per-cluster. Per-cluster TLS *material* (the existing `brokerClientTls*` fields on `ClusterData`) keeps working — each outbound replication client owns a factory built from its own configuration, [...]
-| `pulsar-admin clusters` `--tls-factory-plugin` / `--tls-factory-plugin-params` options | `CmdClusters` CLI | **Removed** — they wrote the now-removed `ClusterData` fields; per-cluster factory selection no longer exists (set the factory broker-level in broker.conf) |
+| `ClusterData.brokerClientSslFactoryPlugin` / `...Params` | admin API + **serialized cluster metadata** | **Deprecated and ignored** (with a WARN), not removed: the field stays in the metadata schema so a cluster written by 4.x still deserializes and reserializes unchanged. Its successors are the new `ClusterData.brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which select the factory for the two outbound legs whose client configuration is built from a cluster entry — [...]
+| `pulsar-admin clusters` `--tls-factory-plugin` / `--tls-factory-plugin-params` options | `CmdClusters` CLI | **Replaced** by `--tls-factory-class-name` / `--tls-factory-config`, which write the successor `ClusterData` fields. The old flags are removed, so a script passing them fails loudly with an unknown-option error rather than silently configuring nothing |
| `--ssl-factory-plugin`, `--ssl-factory-plugin-params` | `pulsar-testclient` CLI | Removed |
**Component → purpose mapping.** Every current `PulsarSslFactory` consumer and the `TlsPurpose` its TLS resolves to after migration:
@@ -1231,10 +1255,22 @@ New OpenTelemetry instruments:
- **BREAKING CHANGE — TLS hostname verification on by default + SAN required (5.0).** A v4 or v5 client — and any broker / proxy / geo-replication **outbound** connection — that connects to a server whose certificate lacks a SubjectAltName matching the target hostname now **fails the TLS handshake**, where 4.x (hostname verification off by default) silently accepted it. Deprecated CN-field matching is also removed, so a CN-only certificate no longer suffices even when its CN names the ho [...]
- The v5 client is published as a standalone artifact in 5.0.0-M1 (the existing `pulsar-client-v5` and `pulsar-client-api-v5` modules from PIP-466), **unshaded**. The v5 client will not ship a shaded distribution; applications that need shading to resolve a dependency conflict relocate it themselves in their own build, as they would any other dependency (see the plugin-packaging note above).
-- **BREAKING CHANGE — PIP-337 removed (5.0).** PIP-337 is removed from Pulsar in Pulsar 5.0 since retaining it in the code base causes an additional maintenance burden and most users don't use it. Concretely: the `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **config-file keys are removed** and a stale non-default value is **rejected at broker/proxy startup** (and at client `loadConf`) by a removed-key validation; th [...]
+- **BREAKING CHANGE — PIP-337 removed (5.0).** PIP-337 is removed from Pulsar in Pulsar 5.0 since retaining it in the code base causes an additional maintenance burden and most users don't use it. Concretely: the `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **config-file keys are removed** and a stale non-default value is **rejected at broker/proxy startup** (and at client `loadConf`) by a removed-key validation; th [...]
- **Security-relevant behavior changes (5.0), surfaced here so they aren't buried in the removal inventory:**
- **`sslProvider` JSSE-provider-name now honored via `jsseProvider`.** A v4 deployment that set `sslProvider` to a JSSE *provider name* (e.g. `Conscrypt`, `SunJSSE`) — which the old code accepted — is now routed to `TlsPolicy.jsseProvider`, so the named provider builds the `SSLContext` on the JDK engine on the binary/client path (**restoring v4 parity**, not dropping the provider); the `OPENSSL` / `OPENSSL_REFCNT` literals still select the native engine. The one residual delta is fail- [...]
- - **`ClusterData` per-cluster factory field removed — the one silently-dropped case.** The `brokerClientSslFactoryPlugin` / `...Params` fields are **removed** from the `ClusterData` metadata model; broker-client factory-class selection is now broker-level (`brokerClientTlsFactoryClassName`). Cluster metadata written by 4.x still deserializes — the removed field is **lenient-dropped** on read (Jackson ignores the unknown property), so there is no metadata-format break — which means a st [...]
+ - **`ClusterData` per-cluster factory field deprecated — the one silently-ignored case.** The `brokerClientSslFactoryPlugin` / `...Params` fields stay on the `ClusterData` metadata model but are `@Deprecated` and **ignored**, with a WARN naming the cluster so the stale value is visible in the broker log. Their successors are `brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which select the factory for the two outbound legs whose client configuration is built from a c [...]
+ - **The `Conscrypt` web-listener provider defaults become conditional.** `webServiceTlsProvider` (broker
+ and proxy), the functions worker's `tlsProvider` and the WebSocket proxy's web-listener `tlsProvider`
+ shipped a default of `Conscrypt` that was **inert** under PIP-337: it reached only Jetty's
+ `SslContextFactory.setProvider(...)`, which does not affect a factory whose `SSLContext` is supplied
+ pre-built, so no deployment has actually been running on Conscrypt. This PIP honours the value, which
+ makes the setting real — and therefore makes an unconditional default a startup failure wherever
+ Conscrypt has no native library (`conscrypt-openjdk-uber` covers x86_64 and, since 2.6.1, aarch64, but
+ not every platform — s390x, for one). The default is kept and made conditional: unset resolves to
+ Conscrypt where it is available and to the JVM default elsewhere. **Operationally this means web
+ listeners now genuinely run on Conscrypt wherever it loads, where they previously ran on the JVM
+ default**, which is the behaviour the shipped configuration always described. An operator who prefers
+ the JDK provider sets it explicitly.
- **Jetty web-listener client-cert trust is scoped like Netty's.** With optional client auth (`tlsRequireTrustedClientCertOnConnect=false`), an *untrusted* client certificate is now accepted at the web listener only when `tlsAllowInsecureConnection=true` — `wantClientAuth` no longer implies trust-all, aligning the web listener with the binary listener (see [Security Considerations](#security-considerations)).
## Upgrade
@@ -1270,7 +1306,7 @@ New OpenTelemetry instruments:
## Pulsar Geo-Replication Upgrade & Downgrade/Rollback Considerations
-Authentication is a per-client concern: each cluster authenticates the replicator client independently using whichever auth plugin is configured, and no auth-related metadata changes. TLS *is* affected, because `ClusterData` carried per-cluster `brokerClientSslFactoryPlugin`/`...Params` fields in 4.x. Those fields are **removed** from the metadata model in 5.0: broker-client factory-class selection becomes **broker-level** (`ServiceConfiguration.brokerClientTlsFactoryClassName`), not per [...]
+Authentication is a per-client concern: each cluster authenticates the replicator client independently using whichever auth plugin is configured, and no auth-related metadata changes. TLS *is* affected, because `ClusterData` carried per-cluster `brokerClientSslFactoryPlugin`/`...Params` fields in 4.x. Those fields are **deprecated and ignored** in 5.0, superseded by `ClusterData.brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which keep factory-class selection per-clus [...]
# Alternatives
@@ -1334,7 +1370,7 @@ Design questions considered during this proposal, now resolved. Each records the
# General Notes
-**Implementation approach.** The change is naturally ordered so each step keeps the codebase releasable: the new SPI types (the `pulsar-tls-factory-api` and `pulsar-http-client-api` types and the `org.apache.pulsar.client.api.v5.auth` types) land first with no consumers; then the default `FileBasedTlsFactory` / `DefaultBrokerTlsFactory` and the server-side migration (broker, proxy, websocket, functions-worker); then the client-side migration (including the new `tlsFactoryClassName` / `tl [...]
+**Implementation approach.** The change is naturally ordered so each step keeps the codebase releasable: the new SPI types (the `pulsar-tls-factory-api` and `pulsar-http-client-api` types and the `org.apache.pulsar.client.api.v5.auth` types) land first with no consumers; then the default `FileBasedTlsFactory` / `DefaultBrokerTlsFactory` and the server-side migration (broker, proxy, websocket, functions-worker); then the client-side migration (including the new `tlsFactoryClassName` / `tl [...]
**Testing strategy.** Beyond "existing v4 authentication tests pass unmodified" (the acceptance criterion for the shims), the new surface needs: rotation tests (file rotation observed by subscriptions; a failed rotation serves last-good material and recovers on the next change); Jetty `reload(...)` swap tests (new connections use the new context, established connections keep their sessions); per-adapter bridge tests (credential, challenge-response, TLS extraction); fail-fast probing test [...]
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
index 25a015577f82..b76cb9e99433 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
@@ -52,7 +52,6 @@ import org.apache.pulsar.common.policies.data.TopicType;
import org.apache.pulsar.common.protocol.Commands;
import org.apache.pulsar.common.sasl.SaslConstants;
import org.apache.pulsar.common.topics.TopicsPattern;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
import org.apache.pulsar.common.util.DirectMemoryUtils;
import org.apache.pulsar.metadata.api.MetadataStoreFactory;
import org.apache.pulsar.metadata.impl.ZKMetadataStore;
@@ -209,9 +208,13 @@ public class ServiceConfiguration implements PulsarConfiguration {
doc = "Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.\n"
+ "This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has\n"
+ "no native TLS engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid\n"
- + "provider names here."
+ + "provider names here. Leave unset (the default) to use Conscrypt when it is available on\n"
+ + "this platform, else the JVM's default provider; a configured name is pinned and startup\n"
+ + "fails if it cannot be resolved. Conscrypt ships native libraries for x86_64 and, since\n"
+ + "2.6.1, aarch64 — but not for every platform, which is why the default falls back instead of\n"
+ + "failing where it cannot load; pinning it explicitly there does fail."
)
- private String webServiceTlsProvider = "Conscrypt";
+ private String webServiceTlsProvider = "";
@FieldContext(
category = CATEGORY_TLS,
@@ -2049,7 +2052,8 @@ public class ServiceConfiguration implements PulsarConfiguration {
private boolean tlsEnabled = false;
@FieldContext(
category = CATEGORY_TLS,
- doc = "Tls cert refresh duration in seconds (set 0 to check on every new connection)"
+ doc = "Tls cert refresh duration in seconds. Set 0 to disable the background rotation "
+ + "check, so the TLS material loaded at startup is kept until restart."
)
private long tlsCertRefreshCheckDurationSec = 300;
@FieldContext(
@@ -2075,8 +2079,10 @@ public class ServiceConfiguration implements PulsarConfiguration {
@FieldContext(
category = CATEGORY_TLS,
doc = "Whether the hostname is validated when the broker creates a TLS connection with other brokers"
+ + " (e.g. geo-replication and broker-to-broker lookup). Enabled by default since Pulsar 5.0"
+ + " (PIP-478): a peer broker whose certificate does not match its hostname/SAN is rejected."
)
- private boolean tlsHostnameVerificationEnabled = false;
+ private boolean tlsHostnameVerificationEnabled = true;
@FieldContext(
category = CATEGORY_TLS,
doc = "Specify the tls protocols the broker will use to negotiate during TLS Handshake.\n\n"
@@ -2096,13 +2102,18 @@ public class ServiceConfiguration implements PulsarConfiguration {
private boolean tlsRequireTrustedClientCertOnConnect = false;
@FieldContext(
category = CATEGORY_TLS,
- doc = "SSL Factory Plugin class to provide SSLEngine and SSLContext objects. The default "
- + " class used is DefaultSslFactory.")
- private String sslFactoryPlugin = DefaultPulsarSslFactory.class.getName();
+ doc = "PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's server-side TLS "
+ + "(binary listener and web server; purposes BROKER/PROXY/WEB). An empty value or the "
+ + "literal 'default' selects the built-in DefaultBrokerTlsFactory composed from these "
+ + "tls* settings, otherwise the named class is instantiated via its public no-arg "
+ + "constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin "
+ + "keys are rejected at startup when set to a non-default value.")
+ private String tlsFactoryClassName = "";
@FieldContext(
category = CATEGORY_TLS,
- doc = "SSL Factory plugin configuration parameters.")
- private String sslFactoryPluginParams = "";
+ doc = "PIP-478 configuration parameters for tlsFactoryClassName, passed to the factory as its "
+ + "init params. Accepts a JSON object or a comma-separated key=value list.")
+ private String tlsFactoryConfig = "";
/***** --- Authentication. --- ****/
@FieldContext(
@@ -4240,18 +4251,28 @@ public class ServiceConfiguration implements PulsarConfiguration {
@FieldContext(
category = CATEGORY_KEYSTORE_TLS,
- doc = "Specify the TLS provider for the broker service: \n"
- + "When using TLS authentication with CACert, the valid value is either OPENSSL or JDK.\n"
- + "When using TLS authentication with KeyStore, available values can be SunJSSE, Conscrypt\n"
- + "and etc.\n"
+ doc = "Select the TLS engine for the broker service: either OPENSSL or JDK.\n"
+ "Leave unset (the default) to let Pulsar choose the engine: the native OpenSSL engine when a\n"
+ "netty-tcnative binary is available for the platform, otherwise the JDK engine.\n"
- + "This key is overloaded across two axes. An engine literal (JDK, OPENSSL, OPENSSL_REFCNT)\n"
- + "selects the TLS engine; any other value is read as a JSSE (SSLContext) provider name (e.g.\n"
- + "Conscrypt) and selects no native engine."
+ + "To pin a JSSE (SSLContext) provider such as BCJSSE or Conscrypt, use jsseProvider instead.\n"
+ + "For compatibility this key stays overloaded across both axes: a value that is not an engine\n"
+ + "literal (JDK, OPENSSL, OPENSSL_REFCNT) is read as a JSSE provider name and routed to\n"
+ + "jsseProvider, which takes precedence when both are set."
)
private String tlsProvider = null;
+ @FieldContext(
+ category = CATEGORY_TLS,
+ doc = "PIP-478: the name of a JSSE (SSLContext) provider — a java.security.Provider that supplies "
+ + "an SSLContext (TLS) implementation (e.g. the BouncyCastle JSSE provider BCJSSE for FIPS, "
+ + "with BCFIPS registered separately as the crypto provider it uses) — used to build the "
+ + "broker's server-side (listener/web) TLS SSLContext. A distinct axis from tlsProvider (the "
+ + "JDK-vs-OpenSSL engine switch): when set, the default factory builds the JDK engine with "
+ + "this provider as the SSLContext provider, overriding the engine choice. Resolved via the "
+ + "ServiceLoader mechanism (with a fallback to an already-registered provider), failing "
+ + "loudly when unresolvable.")
+ private String jsseProvider = null;
+
@FieldContext(
category = CATEGORY_KEYSTORE_TLS,
doc = "TLS KeyStore type configuration in broker: JKS, PKCS12"
@@ -4396,13 +4417,29 @@ public class ServiceConfiguration implements PulsarConfiguration {
private Set<String> brokerClientTlsProtocols = new TreeSet<>();
@FieldContext(
category = CATEGORY_TLS,
- doc = "SSL Factory Plugin class used by internal client to provide SSLEngine and SSLContext objects. "
- + "The default class used is DefaultSslFactory.")
- private String brokerClientSslFactoryPlugin = DefaultPulsarSslFactory.class.getName();
+ doc = "PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's own outbound "
+ + "(broker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the "
+ + "literal 'default' selects the built-in default factory composed from the brokerClient "
+ + "tls* settings, otherwise the named class is instantiated via its public no-arg "
+ + "constructor. This is the only outbound-client TLS path; the removed PIP-337 "
+ + "brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default "
+ + "value.")
+ private String brokerClientTlsFactoryClassName = "";
+ @FieldContext(
+ category = CATEGORY_TLS,
+ doc = "PIP-478 configuration parameters for brokerClientTlsFactoryClassName. Accepts a JSON "
+ + "object or a comma-separated key=value list.")
+ private String brokerClientTlsFactoryConfig = "";
@FieldContext(
category = CATEGORY_TLS,
- doc = "SSL Factory plugin configuration parameters used by internal client.")
- private String brokerClientSslFactoryPluginParams = "";
+ doc = "PIP-478: the name of a JSSE (SSLContext) provider — a java.security.Provider that supplies "
+ + "an SSLContext (TLS) implementation (e.g. the BouncyCastle JSSE provider BCJSSE for FIPS, "
+ + "with BCFIPS registered separately as the crypto provider it uses) — used to build the "
+ + "broker's own outbound (broker-to-broker / replication) client TLS SSLContext. When set, "
+ + "the default factory builds the JDK engine with this provider as the SSLContext provider, "
+ + "overriding the engine choice. Resolved via the ServiceLoader mechanism (with a fallback "
+ + "to an already-registered provider), failing loudly when unresolvable.")
+ private String brokerClientJsseProvider = null;
/* packages management service configurations (begin) */
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
index 57669098632d..2c67ffa0abfc 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
@@ -125,24 +125,28 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
* {@code tlsProtocols}, {@code tlsCiphers} when they are not. Material (PEM or keystore) and the
* insecure flag are shared with the binary listener, as they are today.
*
- * <p>Note the fallback is what makes the precedence safe to apply now: {@code webServiceTlsProvider}
- * ships a non-blank default of {@code Conscrypt}, so it always wins over {@code tlsProvider} unless an
- * operator blanks it. That matches the key's documented meaning — it names the JSSE (SSLContext)
- * provider for the Jetty web listener — and Conscrypt is shipped in the server distribution. On a
- * platform where the Conscrypt native library cannot load, the provider is not registered and pinning
- * it fails loudly at startup rather than silently ignoring the configuration.
+ * <p>A configured provider is <em>pinned</em>: if it cannot be resolved, startup fails rather than
+ * silently ignoring the configuration. Left unset, the web listener falls back to Conscrypt when it is
+ * usable on this platform — see {@link TlsFactorySupport#resolveWebJsseProvider} for why that default is
+ * conditional where 4.x's was not.
*/
@VisibleForTesting
static TlsPolicy webPolicy(ServiceConfiguration conf) {
- return serverPolicy(conf,
- firstNonBlank(conf.getWebServiceTlsProvider(), conf.getTlsProvider()),
+ String provider = firstNonBlank(conf.getWebServiceTlsProvider(), conf.getTlsProvider());
+ return serverPolicy(conf, provider,
+ // Web listeners only: unset means Conscrypt when it is usable on this platform.
+ TlsFactorySupport.resolveWebJsseProvider(null, provider),
firstNonEmpty(conf.getWebServiceTlsProtocols(), conf.getTlsProtocols()),
firstNonEmpty(conf.getWebServiceTlsCiphers(), conf.getTlsCiphers()));
}
@VisibleForTesting
static TlsPolicy serverPolicy(ServiceConfiguration conf) {
- return serverPolicy(conf, conf.getTlsProvider(), conf.getTlsProtocols(), conf.getTlsCiphers());
+ // The binary listeners keep the JVM default when unset — the Conscrypt default is a web-listener
+ // behaviour, restored from 4.x where only the web keys carried it.
+ return serverPolicy(conf, conf.getTlsProvider(),
+ TlsFactorySupport.resolveJsseProvider(null, conf.getTlsProvider()),
+ conf.getTlsProtocols(), conf.getTlsCiphers());
}
private static String firstNonBlank(String preferred, String fallback) {
@@ -153,8 +157,8 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
return preferred != null && !preferred.isEmpty() ? preferred : fallback;
}
- private static TlsPolicy serverPolicy(ServiceConfiguration conf, String provider, Set<String> protocols,
- Set<String> ciphers) {
+ private static TlsPolicy serverPolicy(ServiceConfiguration conf, String provider, String jsseProvider,
+ Set<String> protocols, Set<String> ciphers) {
// enableHostnameVerification is pinned OFF on server-role policies rather than mapped from
// tlsHostnameVerificationEnabled. That key is the broker's OUTBOUND setting ("whether the hostname
// is validated when the broker creates a TLS connection with other brokers"), and every existing
@@ -173,9 +177,10 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
.protocols(toList(protocols))
.ciphers(toList(ciphers))
// v4 parity: the provider key is overloaded. An engine literal (JDK/OPENSSL/OPENSSL_REFCNT)
- // selects the Netty engine above and yields null here; any other value is a JSSE provider
- // name (e.g. Conscrypt), which v4 used to build the SSLContext, so route it to that axis.
- .jsseProvider(TlsFactorySupport.resolveJsseProvider(null, provider));
+ // selects the Netty engine above and yields null on the JSSE axis; any other value is a JSSE
+ // provider name (e.g. Conscrypt), which v4 used to build the SSLContext. Resolved by the
+ // caller, because the web listener defaults to Conscrypt when unset and the binary ones do not.
+ .jsseProvider(jsseProvider);
if (conf.isTlsEnabledWithKeyStore()) {
builder.format(TlsPolicy.Format.KEYSTORE)
.keyStoreType(conf.getTlsKeyStoreType())
@@ -221,22 +226,14 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
}
/**
- * Map {@code tlsCertRefreshCheckDurationSec} onto the factory's poll interval, preserving the v4 meaning of
- * a non-positive value: every v4 consumer guards its refresh task with {@code > 0}, and
- * {@link FileBasedTlsFactorySettings} likewise documents {@code <= 0} as "no background poll". Pass it
- * through rather than substituting the default, so an operator who set {@code 0} still gets no poll.
- *
- * <p>Note that {@code 0} therefore disables rotation for the subscribing server purposes, exactly as it
- * already does on the PIP-337 path. The config key's "set 0 to check on every new connection" wording
- * describes only the one-shot acquisition paths, which re-stat per request; it has not applied to the
- * server listeners since they moved to a shared, periodically-refreshed context.
+ * Map {@code tlsCertRefreshCheckDurationSec} onto the factory's poll interval. The rule — including why a
+ * configured {@code 0} must stay "no background poll" rather than become the default — lives on
+ * {@link FileBasedTlsFactorySettings#refreshIntervalSecondsFromConfig(long)}, shared with the proxy,
+ * websocket proxy and functions worker so the four cannot disagree.
*/
private static int refreshIntervalSeconds(ServiceConfiguration conf) {
- long configured = conf.getTlsCertRefreshCheckDurationSec();
- if (configured <= 0) {
- return 0;
- }
- return (int) Math.min(configured, Integer.MAX_VALUE);
+ return FileBasedTlsFactorySettings.refreshIntervalSecondsFromConfig(
+ conf.getTlsCertRefreshCheckDurationSec());
}
private static List<String> toList(Set<String> values) {
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
index 3c69d79b0273..c63e78f81935 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import io.netty.handler.ssl.OpenSsl;
import io.netty.handler.ssl.SslProvider;
import io.opentelemetry.api.OpenTelemetry;
+import java.security.Provider;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Locale;
@@ -35,6 +36,7 @@ import java.util.function.Supplier;
import lombok.CustomLog;
import org.apache.commons.lang3.StringUtils;
import org.apache.pulsar.common.util.ObjectMapperFactory;
+import org.apache.pulsar.common.util.tls.JcaProviders;
import org.apache.pulsar.tls.PulsarTlsFactory;
import org.apache.pulsar.tls.TlsFactoryInitContext;
@@ -271,6 +273,43 @@ public final class TlsFactorySupport {
* @param engineProviderString the configured {@code tlsProvider}/{@code sslProvider} (may be null/blank)
* @return the resolved JSSE provider name, or {@code null} for none
*/
+ /**
+ * As {@link #resolveJsseProvider}, but for a <b>web listener</b>, whose JSSE provider defaults to
+ * Conscrypt when nothing is configured and Conscrypt is usable on this platform.
+ *
+ * <p>Conscrypt was the shipped default for the web-listener provider keys before PIP-478 and is
+ * meaningfully faster than the JDK provider, so it stays the default here. What changes is that the
+ * default is now <em>conditional</em>: under PIP-337 the key only reached Jetty's
+ * {@code SslContextFactory.setProvider(...)}, inert on a factory that overrides {@code getSslContext()},
+ * so the shipped default never selected a provider and could not fail. Now that the value is pinned into
+ * the built {@code SSLContext} it can, so it is applied only where Conscrypt actually loads.
+ * {@code conscrypt-openjdk-uber} ships native libraries for x86_64 and — since 2.6.1 — aarch64, but not
+ * for every platform Pulsar runs on (s390x, for one), and it need not be on the class path at all.
+ *
+ * <p>Hence availability rather than mere presence: {@link JcaProviders#CONSCRYPT_PROVIDER} is resolved by
+ * calling Conscrypt's own {@code checkAvailability()}, which fails where the native library is missing, so
+ * this returns {@code null} there and the JVM default applies. A provider the operator names explicitly is
+ * always honoured verbatim and still fails loudly when it cannot be resolved — only the <em>default</em>
+ * is conditional, because a default that breaks a supported platform is not a usable default.
+ *
+ * <p>This applies to server listeners only. Client-side hostname verification is unaffected: a server
+ * does not verify hostnames, so pinning Conscrypt here cannot interact with the SAN-only verification
+ * PIP-478 turns on.
+ *
+ * @param explicitJsseProvider the configured {@code jsseProvider} (may be null/blank)
+ * @param engineProviderString the configured web-listener provider key (may be null/blank)
+ * @return the resolved JSSE provider name, Conscrypt's name when nothing is configured and it is
+ * available, or {@code null} for the JVM default
+ */
+ public static String resolveWebJsseProvider(String explicitJsseProvider, String engineProviderString) {
+ String configured = resolveJsseProvider(explicitJsseProvider, engineProviderString);
+ if (configured != null) {
+ return configured;
+ }
+ Provider conscrypt = JcaProviders.CONSCRYPT_PROVIDER;
+ return conscrypt == null ? null : conscrypt.getName();
+ }
+
public static String resolveJsseProvider(String explicitJsseProvider, String engineProviderString) {
if (StringUtils.isNotBlank(explicitJsseProvider)) {
return explicitJsseProvider.trim();
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
index 16ea4b5132cd..8a2fba63bd84 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
@@ -27,6 +27,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.util.Arrays;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
@@ -43,6 +44,57 @@ import org.apache.pulsar.broker.ServiceConfiguration;
@CustomLog
public class PulsarConfigurationLoader {
+ /**
+ * PIP-337 TLS-factory configuration keys removed outright in Pulsar 5.0 (PIP-478). A stale value left in a
+ * config file (broker.conf / proxy.conf) is rejected loudly at load with an actionable migration message —
+ * preserving the fail-loud detection the removed {@code @Deprecated} fields used to provide, without
+ * retaining a functional PIP-337 field.
+ */
+ static final List<String> REMOVED_PIP337_TLS_FACTORY_KEYS = List.of(
+ "sslFactoryPlugin", "sslFactoryPluginParams",
+ "brokerClientSslFactoryPlugin", "brokerClientSslFactoryPluginParams",
+ // broker.conf / proxy.conf pass a brokerClient_<clientKey> property through to the internal client
+ // (PropertiesUtils.filterAndMapProperties strips the "brokerClient_" prefix), where a stale client
+ // sslFactoryPlugin would otherwise be silently dropped by ClientConfigurationData ignoreUnknown.
+ // Reject that prefixed form too so the removed client key cannot bypass the fail-loud detection.
+ "brokerClient_sslFactoryPlugin", "brokerClient_sslFactoryPluginParams");
+
+ /**
+ * The old PIP-337 default {@code sslFactoryPlugin} implementation FQCN. Its class is deleted in Pulsar 5.0
+ * (PIP-478), so this is a plain string literal. A {@code *Plugin} key still carrying this default value is
+ * equivalent to "unset" (no custom factory), so it is tolerated — only a non-default (custom) factory needs
+ * migration.
+ */
+ static final String DEFAULT_PIP337_SSL_FACTORY_CLASS = "org.apache.pulsar.common.util.DefaultPulsarSslFactory";
+
+ /**
+ * Reject a stale, non-default PIP-337 TLS-factory configuration key (removed in Pulsar 5.0, PIP-478) left
+ * in a config file. A key that is absent or blank is tolerated (the default); a {@code *Plugin} key still
+ * naming the old default factory FQCN ({@link #DEFAULT_PIP337_SSL_FACTORY_CLASS}) is also tolerated (it is
+ * equivalent to unset); any other non-blank value fails the load with an actionable migration message
+ * pointing to the {@code tlsFactoryClassName} successor.
+ *
+ * @param properties the loaded configuration properties
+ * @throws IllegalArgumentException if a removed PIP-337 key is present with a non-default value
+ */
+ static void rejectRemovedPip337TlsFactoryKeys(Properties properties) {
+ for (String key : REMOVED_PIP337_TLS_FACTORY_KEYS) {
+ String value = properties.getProperty(key);
+ if (StringUtils.isBlank(value)) {
+ continue;
+ }
+ // A *Plugin key still set to the old default factory FQCN means no custom factory — tolerate it as
+ // if unset. The *PluginParams keys carry no default value, so any non-blank value is rejected.
+ if (key.endsWith("Plugin") && DEFAULT_PIP337_SSL_FACTORY_CLASS.equals(value.trim())) {
+ continue;
+ }
+ throw new IllegalArgumentException("The PIP-337 '" + key + "' configuration key is removed in "
+ + "Pulsar 5.0 (PIP-478). Migrate the custom SSL factory to a PulsarTlsFactory selected by "
+ + "tlsFactoryClassName / tlsFactoryConfig (or brokerClientTlsFactoryClassName / "
+ + "brokerClientTlsFactoryConfig) and remove '" + key + "' from the configuration.");
+ }
+ }
+
/**
* Creates PulsarConfiguration and loads it with populated attribute values loaded from provided property file.
*
@@ -93,6 +145,8 @@ public class PulsarConfigurationLoader {
public static <T extends PulsarConfiguration> T create(Properties properties,
Class<? extends PulsarConfiguration> clazz) throws IOException, IllegalArgumentException {
requireNonNull(properties);
+ // PIP-478: reject a stale, removed PIP-337 sslFactoryPlugin key rather than silently ignoring it.
+ rejectRemovedPip337TlsFactoryKeys(properties);
T configuration;
try {
configuration = (T) clazz.getDeclaredConstructor().newInstance();
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
deleted file mode 100644
index 96424c7ee9bd..000000000000
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.jetty.tls;
-
-import java.util.Set;
-import javax.net.ssl.SSLContext;
-import lombok.CustomLog;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-@CustomLog
-public class JettySslContextFactory {
- static {
- // DO NOT EDIT - Load Conscrypt provider
- if (SecurityUtility.CONSCRYPT_PROVIDER != null) {
- }
- }
-
- public static SslContextFactory.Server createSslContextFactory(String sslProviderString,
- PulsarSslFactory pulsarSslFactory,
- boolean requireTrustedClientCertOnConnect,
- Set<String> ciphers, Set<String> protocols) {
- return new JettySslContextFactory.Server(sslProviderString, pulsarSslFactory,
- requireTrustedClientCertOnConnect, ciphers, protocols);
- }
-
- private static class Server extends SslContextFactory.Server {
- private final PulsarSslFactory pulsarSslFactory;
-
- public Server(String sslProviderString, PulsarSslFactory pulsarSslFactory,
- boolean requireTrustedClientCertOnConnect, Set<String> ciphers, Set<String> protocols) {
- super();
- this.pulsarSslFactory = pulsarSslFactory;
-
- if (ciphers != null && ciphers.size() > 0) {
- this.setIncludeCipherSuites(ciphers.toArray(new String[0]));
- }
-
- if (protocols != null && protocols.size() > 0) {
- this.setIncludeProtocols(protocols.toArray(new String[0]));
- }
-
- if (sslProviderString != null && !sslProviderString.equals("")) {
- setProvider(sslProviderString);
- }
-
- if (requireTrustedClientCertOnConnect) {
- this.setNeedClientAuth(true);
- this.setTrustAll(false);
- } else {
- this.setWantClientAuth(true);
- this.setTrustAll(true);
- }
-
- // https://jetty.org/docs/jetty/12.1/operations-guide/protocols/index.html#ssl-sni
- // Set to false for backwards compatibility with Jetty 9.x
- setSniRequired(false);
- }
-
- @Override
- public SSLContext getSslContext() {
- return this.pulsarSslFactory.getInternalSslContext();
- }
- }
-}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
index 4628c0d128d8..a134e4496a91 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
@@ -19,8 +19,10 @@
package org.apache.pulsar.broker.tls;
import static org.assertj.core.api.Assertions.assertThat;
+import java.security.Security;
import java.util.Set;
import org.apache.pulsar.broker.ServiceConfiguration;
+import org.apache.pulsar.common.util.tls.JcaProviders;
import org.apache.pulsar.tls.TlsPolicy;
import org.testng.annotations.Test;
@@ -108,6 +110,46 @@ public class DefaultBrokerTlsPolicyTest {
assertThat(DefaultBrokerTlsFactory.webPolicy(enabled).enableHostnameVerification()).isFalse();
}
+ @Test
+ public void theDefaultConfigurationPinsNoJsseProviderOnTheNonWebPurposes() {
+ // A configured JSSE provider is pinned and fails startup when it cannot be resolved, so the binary
+ // and broker-client purposes must not pin one out of the box — only the web listener carries a
+ // default, and only conditionally (see below).
+ ServiceConfiguration conf = conf();
+
+ assertThat(DefaultBrokerTlsFactory.serverPolicy(conf).jsseProvider()).isNull();
+ assertThat(DefaultBrokerTlsFactory.brokerClientPolicy(conf).jsseProvider()).isNull();
+ }
+
+ @Test
+ public void theWebListenerDefaultsToConscryptOnlyWhenItIsAvailable() {
+ // Conscrypt was the shipped default for webServiceTlsProvider before PIP-478 and is kept, but the
+ // default is now conditional on Conscrypt actually loading: the value is pinned into the built
+ // SSLContext rather than reaching Jetty's inert setProvider(...), so an unconditional default would
+ // fail startup wherever the native library is missing (the uber jar covers x86_64 and, since 2.6.1,
+ // aarch64 — not every platform). Both branches are asserted so this holds either way.
+ ServiceConfiguration conf = conf();
+
+ String webProvider = DefaultBrokerTlsFactory.webPolicy(conf).jsseProvider();
+ if (JcaProviders.CONSCRYPT_PROVIDER == null) {
+ assertThat(webProvider).as("no Conscrypt on this platform => the JVM default applies").isNull();
+ } else {
+ assertThat(webProvider).as("Conscrypt is available => it is the web-listener default")
+ .isEqualTo(JcaProviders.CONSCRYPT_PROVIDER.getName());
+ // Whatever the default resolves to must be resolvable, or startup would fail on it.
+ assertThat(Security.getProvider(webProvider)).as("the default must be a registered provider")
+ .isNotNull();
+ }
+ }
+
+ @Test
+ public void anExplicitWebProviderStillWinsOverTheConscryptDefault() {
+ ServiceConfiguration conf = conf();
+ conf.setWebServiceTlsProvider("SunJSSE");
+
+ assertThat(DefaultBrokerTlsFactory.webPolicy(conf).jsseProvider()).isEqualTo("SunJSSE");
+ }
+
@Test
public void anEngineLiteralInTheWebProviderSelectsNoJsseProvider() {
// The provider key is overloaded across two axes; an engine literal must not land on the JSSE one.
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
index 6bf1fc1236bf..f124c36711d7 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
@@ -25,6 +25,7 @@ import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
import com.google.common.collect.Sets;
import java.io.File;
import java.io.FileInputStream;
@@ -86,6 +87,69 @@ public class PulsarConfigurationLoaderTest {
() -> PulsarConfigurationLoader.convertFrom(new MockConfiguration(), false));
}
+ // PIP-478: a stale, removed PIP-337 sslFactoryPlugin key set to a non-default value is rejected loudly at
+ // config-file load with an actionable migration message, replacing the removed @Deprecated fail-loud field.
+ @Test
+ public void testRemovedPip337SslFactoryPluginKeysRejectedLoudly() {
+ for (String key : new String[] {"sslFactoryPlugin", "sslFactoryPluginParams",
+ "brokerClientSslFactoryPlugin", "brokerClientSslFactoryPluginParams",
+ // PIP-478 (FIX): broker.conf passes brokerClient_<clientKey> through to the internal client, so
+ // the prefixed form of the client removed keys must be rejected too (it would otherwise be
+ // silently dropped by ClientConfigurationData ignoreUnknown).
+ "brokerClient_sslFactoryPlugin", "brokerClient_sslFactoryPluginParams"}) {
+ Properties props = new Properties();
+ props.setProperty("clusterName", "test");
+ props.setProperty(key, "com.example.CustomSslFactory");
+ IllegalArgumentException ex = expectThrows(IllegalArgumentException.class,
+ () -> PulsarConfigurationLoader.create(props, ServiceConfiguration.class));
+ assertTrue(ex.getMessage().contains(key), "message should name the removed key: " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("tlsFactoryClassName"),
+ "message should point to the successor: " + ex.getMessage());
+ }
+ // A blank value (the default) is tolerated -> loads cleanly.
+ Properties blank = new Properties();
+ blank.setProperty("clusterName", "test");
+ blank.setProperty("sslFactoryPlugin", "");
+ blank.setProperty("brokerClientSslFactoryPlugin", "");
+ assertNotNull(expectNoThrow(() -> PulsarConfigurationLoader.create(blank, ServiceConfiguration.class)));
+ }
+
+ // PIP-478 (FIX): a *Plugin key still naming the OLD DEFAULT factory FQCN is equivalent to "unset" (no
+ // custom factory), so it is tolerated — only a non-default (custom) value needs migration. The *PluginParams
+ // keys carry no default value, so any non-blank value is still rejected.
+ @Test
+ public void testRemovedPip337DefaultSslFactoryFqcnTolerated() {
+ String defaultFqcn = "org.apache.pulsar.common.util.DefaultPulsarSslFactory";
+ for (String key : new String[] {"sslFactoryPlugin", "brokerClientSslFactoryPlugin",
+ "brokerClient_sslFactoryPlugin"}) {
+ Properties props = new Properties();
+ props.setProperty("clusterName", "test");
+ props.setProperty(key, defaultFqcn);
+ assertNotNull(expectNoThrow(() -> PulsarConfigurationLoader.create(props, ServiceConfiguration.class)),
+ "old default factory FQCN on '" + key + "' should be tolerated");
+ }
+ // A custom (non-default) value on the same key is still rejected.
+ Properties custom = new Properties();
+ custom.setProperty("clusterName", "test");
+ custom.setProperty("sslFactoryPlugin", "com.acme.CustomFactory");
+ expectThrows(IllegalArgumentException.class,
+ () -> PulsarConfigurationLoader.create(custom, ServiceConfiguration.class));
+ // The default FQCN is NOT a *PluginParams default -> a non-blank params value is still rejected.
+ Properties params = new Properties();
+ params.setProperty("clusterName", "test");
+ params.setProperty("sslFactoryPluginParams", defaultFqcn);
+ expectThrows(IllegalArgumentException.class,
+ () -> PulsarConfigurationLoader.create(params, ServiceConfiguration.class));
+ }
+
+ private static <T> T expectNoThrow(java.util.concurrent.Callable<T> callable) {
+ try {
+ return callable.call();
+ } catch (Exception e) {
+ throw new AssertionError("expected no exception but got: " + e, e);
+ }
+ }
+
// Deprecation warning suppressed as this test targets deprecated methods
@SuppressWarnings("deprecation")
@Test
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
deleted file mode 100644
index 0d58153b0d78..000000000000
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.jetty.tls;
-
-import com.google.common.io.Resources;
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
-import lombok.Cleanup;
-import lombok.CustomLog;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.config.RegistryBuilder;
-import org.apache.http.conn.socket.ConnectionSocketFactory;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClientBuilder;
-import org.apache.http.impl.client.HttpClients;
-import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.testng.annotations.Test;
-
-@CustomLog
-public class JettySslContextFactoryTest {
-
- @Test
- public void testJettyTlsServerTls() throws Exception {
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
- .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
- .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
- .allowInsecureConnection(false)
- .requireTrustedClientCertOnConnect(true)
- .tlsEnabledWithKeystore(false)
- .isHttps(true)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true, null, null);
-
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https",
- new SSLConnectionSocketFactory(getClientSslContext(), new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- @Test(expectedExceptions = SSLHandshakeException.class)
- public void testJettyTlsServerInvalidTlsProtocol() throws Exception {
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsProtocols(new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- })
- .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
- .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
- .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
- .allowInsecureConnection(false)
- .requireTrustedClientCertOnConnect(true)
- .tlsEnabledWithKeystore(false)
- .isHttps(true)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true, null,
- new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- });
- factory.setHostnameVerifier((s, sslSession) -> true);
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
- new String[]{"TLSv1.2"}, null, new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- @Test(expectedExceptions = SSLHandshakeException.class)
- public void testJettyTlsServerInvalidCipher() throws Exception {
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsCiphers(new HashSet<String>() {
- {
- this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
- }
- })
- .tlsProtocols(new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- })
- .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
- .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
- .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
- .allowInsecureConnection(false)
- .requireTrustedClientCertOnConnect(true)
- .isHttps(true)
- .tlsEnabledWithKeystore(false)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true,
- new HashSet<String>() {
- {
- this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
- }
- },
- new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- });
- factory.setHostnameVerifier((s, sslSession) -> true);
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
- new String[]{"TLSv1.2"}, new String[]{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
- new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- private static SSLContext getClientSslContext() throws GeneralSecurityException, IOException {
- return SecurityUtility.createSslContext(
- false,
- Resources.getResource("ssl/my-ca/ca.pem").getPath(),
- Resources.getResource("ssl/my-ca/client-ca.pem").getPath(),
- Resources.getResource("ssl/my-ca/client-key.pem").getPath(),
- null
- );
- }
-}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java
deleted file mode 100644
index b66bb8dc6bd6..000000000000
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java
+++ /dev/null
@@ -1,252 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.jetty.tls;
-
-import com.google.common.io.Resources;
-import java.io.FileInputStream;
-import java.security.KeyStore;
-import java.security.SecureRandom;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import javax.net.ssl.KeyManager;
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
-import javax.net.ssl.TrustManagerFactory;
-import lombok.Cleanup;
-import lombok.CustomLog;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.config.RegistryBuilder;
-import org.apache.http.conn.socket.ConnectionSocketFactory;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClientBuilder;
-import org.apache.http.impl.client.HttpClients;
-import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
-import org.apache.logging.log4j.Level;
-import org.apache.logging.log4j.core.config.Configurator;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.testng.annotations.Test;
-
-@CustomLog
-public class JettySslContextFactoryWithKeyStoreTest {
- static final String BROKEN_KEY_STORE_PATH =
- Resources.getResource("certificate-authority/jks/broker.keystore.jks").getPath();
- static final String BROKER_TRUST_STORE_PATH =
- Resources.getResource("certificate-authority/jks/broker.truststore.jks").getPath();
- static final String CLIENT_KEY_STORE_PATH =
- Resources.getResource("certificate-authority/jks/client.keystore.jks").getPath();
- static final String CLIENT_TRUST_STORE_PATH =
- Resources.getResource("certificate-authority/jks/client.truststore.jks").getPath();
- static final String KEY_STORE_TYPE = "JKS";
- static final String KEY_STORE_PASSWORD = "111111";
-
- @Test
- public void testJettyTlsServerTls() throws Exception {
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsKeyStoreType(KEY_STORE_TYPE)
- .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
- .tlsKeyStorePassword(KEY_STORE_PASSWORD)
- .tlsTrustStoreType(KEY_STORE_TYPE)
- .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
- .tlsTrustStorePassword(KEY_STORE_PASSWORD)
- .requireTrustedClientCertOnConnect(true)
- .tlsEnabledWithKeystore(true)
- .isHttps(true)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true, null, null);
- factory.setHostnameVerifier((s, sslSession) -> true);
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https",
- new SSLConnectionSocketFactory(getClientSslContext(), new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- @Test(expectedExceptions = SSLHandshakeException.class)
- public void testJettyTlsServerInvalidTlsProtocol() throws Exception {
- Configurator.setRootLevel(Level.INFO);
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsKeyStoreType(KEY_STORE_TYPE)
- .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
- .tlsKeyStorePassword(KEY_STORE_PASSWORD)
- .tlsTrustStoreType(KEY_STORE_TYPE)
- .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
- .tlsTrustStorePassword(KEY_STORE_PASSWORD)
- .tlsProtocols(new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- })
- .requireTrustedClientCertOnConnect(true)
- .tlsEnabledWithKeystore(true)
- .isHttps(true)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true, null,
- new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- });
- factory.setHostnameVerifier((s, sslSession) -> true);
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
- new String[]{"TLSv1.2"}, null, new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- @Test(expectedExceptions = SSLHandshakeException.class)
- public void testJettyTlsServerInvalidCipher() throws Exception {
- @Cleanup("stop")
- Server server = new Server();
- List<ServerConnector> connectors = new ArrayList<>();
- PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
- .tlsKeyStoreType(KEY_STORE_TYPE)
- .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
- .tlsKeyStorePassword(KEY_STORE_PASSWORD)
- .tlsTrustStoreType(KEY_STORE_TYPE)
- .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
- .tlsTrustStorePassword(KEY_STORE_PASSWORD)
- .tlsCiphers(new HashSet<String>() {
- {
- this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
- }
- })
- .tlsProtocols(new HashSet<String>() {
- {
- this.add("TLSv1.3");
- }
- })
- .requireTrustedClientCertOnConnect(true)
- .tlsEnabledWithKeystore(true)
- .isHttps(true)
- .build();
- PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
- sslFactory.initialize(sslConfiguration);
- sslFactory.createInternalSslContext();
- SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
- sslFactory, true,
- new HashSet<String>() {
- {
- this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
- }
- },
- new HashSet<String>() {
- {
- this.add("TLSv1.2");
- this.add("TLSv1.3");
- }
- });
- factory.setHostnameVerifier((s, sslSession) -> true);
- ServerConnector connector = new ServerConnector(server, factory);
- connector.setPort(0);
- connectors.add(connector);
- server.setConnectors(connectors.toArray(new ServerConnector[0]));
- server.start();
- // client connect
- HttpClientBuilder httpClientBuilder = HttpClients.custom();
- RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
- registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
- new String[]{"TLSv1.2"}, new String[]{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
- new NoopHostnameVerifier()));
- PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
- httpClientBuilder.setConnectionManager(cm);
- @Cleanup
- CloseableHttpClient httpClient = httpClientBuilder.build();
- HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
- httpClient.execute(httpGet);
- }
-
- private static SSLContext getClientSslContext() {
- return getSslContext(CLIENT_KEY_STORE_PATH, KEY_STORE_PASSWORD, BROKER_TRUST_STORE_PATH, KEY_STORE_PASSWORD);
- }
-
- private static SSLContext getSslContext(String keyStorePath, String keyStorePassword,
- String trustStorePath, String trustStorePassword) {
- try {
- SSLContext sslContext = SSLContext.getInstance("TLS");
- // key store
- KeyManagerFactory keyManagerFactory =
- KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
- KeyStore keyStore = KeyStore.getInstance("JKS");
- try (FileInputStream inputStream = new FileInputStream(keyStorePath)) {
- keyStore.load(inputStream, keyStorePassword.toCharArray());
- }
- keyManagerFactory.init(keyStore, keyStorePassword.toCharArray());
- KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();
- // trust store
- TrustManagerFactory trustManagerFactory =
- TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
- KeyStore trustStore = KeyStore.getInstance("JKS");
- try (FileInputStream inputStream = new FileInputStream(trustStorePath)) {
- trustStore.load(inputStream, trustStorePassword.toCharArray());
- }
- trustManagerFactory.init(trustStore);
- sslContext.init(keyManagers, trustManagerFactory.getTrustManagers(), new SecureRandom());
- return sslContext;
- } catch (Exception e) {
- log.error().exception(e).log("load ssl context error");
- return null;
- }
- }
-}
diff --git a/pulsar-broker/build.gradle.kts b/pulsar-broker/build.gradle.kts
index afdd94c92556..e4c1cfb2b04a 100644
--- a/pulsar-broker/build.gradle.kts
+++ b/pulsar-broker/build.gradle.kts
@@ -31,6 +31,9 @@ dependencies {
api(project(":pulsar-broker-common"))
api(project(":pulsar-client-original"))
implementation(project(":pulsar-client-admin-original"))
+ // PIP-478: PulsarChannelInitializer / WebService use the TLS factory SPI directly (private tlsFactory
+ // fields); it is not exposed on the broker's ABI, so `implementation`.
+ implementation(project(":pulsar-tls-factory-api"))
api(project(":pulsar-websocket"))
implementation(project(":pulsar-cli-utils"))
implementation(project(":pulsar-transaction:pulsar-transaction-common"))
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
index f80465ed3c65..526f5cce5570 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
@@ -131,6 +131,7 @@ import org.apache.pulsar.broker.stats.prometheus.PulsarPrometheusMetricsServlet;
import org.apache.pulsar.broker.storage.BookkeeperManagedLedgerStorageClass;
import org.apache.pulsar.broker.storage.ManagedLedgerStorage;
import org.apache.pulsar.broker.storage.ManagedLedgerStorageClass;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
import org.apache.pulsar.broker.transaction.buffer.TransactionBufferProvider;
import org.apache.pulsar.broker.transaction.buffer.impl.TransactionBufferClientImpl;
import org.apache.pulsar.broker.transaction.coordinator.v5.TransactionCoordinatorV5;
@@ -148,6 +149,7 @@ import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServletWithPulsarSe
import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServlets;
import org.apache.pulsar.client.admin.PulsarAdmin;
import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.admin.internal.PulsarAdminBuilderImpl;
import org.apache.pulsar.client.api.AuthenticationFactory;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.PulsarClientException;
@@ -156,6 +158,7 @@ import org.apache.pulsar.client.impl.DnsResolverGroupImpl;
import org.apache.pulsar.client.impl.PulsarClientImpl;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
import org.apache.pulsar.client.impl.conf.ConfigurationDataUtils;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
import org.apache.pulsar.client.internal.PropertiesUtils;
import org.apache.pulsar.client.util.ExecutorProvider;
import org.apache.pulsar.client.util.ScheduledExecutorProvider;
@@ -1847,8 +1850,12 @@ public class PulsarService implements AutoCloseable, ShutdownService {
public PulsarClientImpl createClientImpl(ClientConfigurationData conf,
Consumer<PulsarClientImpl.PulsarClientImplBuilder> customizer)
throws PulsarClientException {
+ ClientConfigurationData clientConf = conf != null ? conf : createClientConfigurationData();
+ // PIP-478: route the broker's own outbound clients (replication + cluster-internal lookup)
+ // onto the new TLS SPI (BROKER_CLIENT) when opted in via brokerClientTlsFactoryClassName.
+ maybeApplyBrokerClientTlsFactory(clientConf);
PulsarClientImpl.PulsarClientImplBuilder pulsarClientImplBuilder = PulsarClientImpl.builder()
- .conf(conf != null ? conf : createClientConfigurationData())
+ .conf(clientConf)
.eventLoopGroup(ioEventLoopGroup)
.timer(brokerClientSharedTimer)
.internalExecutorProvider(brokerClientSharedInternalExecutorProvider)
@@ -1862,6 +1869,110 @@ public class PulsarService implements AutoCloseable, ShutdownService {
return pulsarClientImplBuilder.build();
}
+ /**
+ * Route the broker's own outbound Pulsar client (geo-replication and cluster-internal lookup) onto the
+ * new PIP-478 TLS SPI for the {@code BROKER_CLIENT} purpose, gated on the
+ * {@code brokerClientTlsFactoryClassName} opt-in. The broker-client material — per-cluster
+ * {@code ClusterData.brokerClientTls*} first, else the broker's {@code brokerClient*}
+ * {@code ServiceConfiguration} — is already mapped onto the config's {@code tls*} fields by the caller
+ * ({@code createClientConfigurationData} / {@code BrokerService.configTlsSettings}), so this only attaches
+ * a per-client {@link org.apache.pulsar.tls.PulsarTlsFactory} composed from those fields (folding
+ * the broker-client {@code Authentication} TLS material). Leaves the config on the legacy PIP-337 path
+ * when the gate is off, or when the outbound client is not TLS, or when it is already on the new path.
+ *
+ * @param conf the outbound client configuration (mutated to carry the TLS factory when opted in)
+ */
+ private void maybeApplyBrokerClientTlsFactory(ClientConfigurationData conf) {
+ String factoryClassName = brokerClientTlsFactorySelection(conf);
+ if (!isNotBlank(factoryClassName)) {
+ return;
+ }
+ if (conf.getTlsFactory() != null || conf.getTlsPolicyMap() != null || !conf.isUseTls()) {
+ return;
+ }
+ conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, factoryClassName));
+ // PIP-478: deliver brokerClientTlsFactoryConfig to a custom factory via the init context
+ // params (parsed the same way as the server-side tlsFactoryConfig).
+ conf.setTlsFactoryParams(TlsFactorySupport.parseFactoryConfig(brokerClientTlsFactoryConfig(conf)));
+ }
+
+ /**
+ * The {@code PulsarTlsFactory} class name for an outbound broker-client connection: the value
+ * {@code BrokerService} already resolved onto this configuration from the target
+ * {@link org.apache.pulsar.common.policies.data.ClusterData} when the cluster names one, else the
+ * broker-level {@code brokerClientTlsFactoryClassName} (PIP-478).
+ *
+ * <p>Reading the config rather than the {@code ServiceConfiguration} directly is what makes per-cluster
+ * selection work: a cluster entry that names its own factory must both win over the broker-level value
+ * <em>and</em> be routed through {@link ClientTlsFactorySupport#brokerClientTlsFactory}, which wraps a
+ * custom factory so it answers the transport's {@code CLIENT_DEFAULT} request from its
+ * {@code BROKER_CLIENT} material. Resolving it by name later, outside that wrapper, makes a compliant
+ * {@code BROKER_CLIENT}-only factory return empty and the connection fail.
+ *
+ * @param conf the outbound client configuration
+ * @return the selected factory class name, or blank when neither level selects one
+ */
+ private String brokerClientTlsFactorySelection(ClientConfigurationData conf) {
+ return isNotBlank(conf.getTlsFactoryClassName())
+ ? conf.getTlsFactoryClassName()
+ : getConfiguration().getBrokerClientTlsFactoryClassName();
+ }
+
+ /**
+ * The init params for {@link #brokerClientTlsFactorySelection}. The pair resolves <em>atomically</em>:
+ * when the cluster selects the factory class, its config wins even if blank, so factory A's parameters
+ * are never handed to factory B.
+ *
+ * @param conf the outbound client configuration
+ * @return the factory configuration string for the selected factory
+ */
+ private String brokerClientTlsFactoryConfig(ClientConfigurationData conf) {
+ return isNotBlank(conf.getTlsFactoryClassName())
+ ? conf.getTlsFactoryConfig()
+ : getConfiguration().getBrokerClientTlsFactoryConfig();
+ }
+
+ /**
+ * Route the broker's own outbound admin clients (the {@code PulsarAdmin} instances built by
+ * {@link #getCreateAdminClientBuilder} and {@code BrokerService.getClusterPulsarAdmin}) onto the new
+ * PIP-478 TLS SPI, gated on the same {@code brokerClientTlsFactoryClassName} opt-in as the
+ * binary path. The admin builder rides a {@link ClientConfigurationData} internally; this
+ * reaches it through {@link PulsarAdminBuilderImpl#getConf()} and attaches a per-client
+ * {@link org.apache.pulsar.tls.PulsarTlsFactory} composed from the broker-client {@code tls*}
+ * material already mapped onto the config (folding the broker-client {@code Authentication} TLS material).
+ * Ownership transfers with the configuration: the admin client initializes the factory and closes it when
+ * the admin closes, so this method must not retain or close it. (Concretely the owner is that admin's
+ * {@code AsyncHttpConnectorProvider}, which resolves one factory for all of the admin's connectors —
+ * see {@code TlsFactoryOwnership}.) Leaves the builder on the
+ * legacy PIP-337 path when the gate is off, when the admin URL is not TLS, or when it is already on the
+ * new path.
+ *
+ * @param builder the admin builder to route (a {@link PulsarAdminBuilderImpl}; other implementations are
+ * left untouched)
+ */
+ public void applyBrokerClientTlsFactoryToAdmin(PulsarAdminBuilder builder) {
+ if (!(builder instanceof PulsarAdminBuilderImpl adminBuilder)) {
+ return;
+ }
+ ClientConfigurationData conf = adminBuilder.getConf();
+ String factoryClassName = brokerClientTlsFactorySelection(conf);
+ if (!isNotBlank(factoryClassName)) {
+ return;
+ }
+ if (conf.getTlsFactory() != null || conf.getTlsPolicyMap() != null) {
+ return;
+ }
+ // Admin traffic is HTTP; TLS is selected by an https service URL (there is no useTls flag on the
+ // admin builder path).
+ if (conf.getServiceUrl() == null || !conf.getServiceUrl().startsWith("https")) {
+ return;
+ }
+ conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, factoryClassName));
+ // PIP-478: deliver brokerClientTlsFactoryConfig to a custom factory via the init context
+ // params (parsed the same way as the server-side tlsFactoryConfig).
+ conf.setTlsFactoryParams(TlsFactorySupport.parseFactoryConfig(brokerClientTlsFactoryConfig(conf)));
+ }
+
public synchronized PulsarClient getClient() throws PulsarServerException {
if (this.client == null) {
try {
@@ -1897,10 +2008,14 @@ public class PulsarService implements AutoCloseable, ShutdownService {
if (tlsEnabled) {
conf.setTlsCiphers(this.getConfiguration().getBrokerClientTlsCiphers());
conf.setTlsProtocols(this.getConfiguration().getBrokerClientTlsProtocols());
+ // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+ // (jsseProvider) onto the internal client config so the broker's own outbound client honors them —
+ // both are documented as "used by the internal client" but were otherwise dropped here (never
+ // copied into ClientConfigurationData), silently defaulting the engine/provider.
+ conf.setSslProvider(this.getConfiguration().getBrokerClientSslProvider());
+ conf.setJsseProvider(this.getConfiguration().getBrokerClientJsseProvider());
conf.setTlsAllowInsecureConnection(this.getConfiguration().isTlsAllowInsecureConnection());
conf.setTlsHostnameVerificationEnable(this.getConfiguration().isTlsHostnameVerificationEnabled());
- conf.setSslFactoryPlugin(this.getConfiguration().getBrokerClientSslFactoryPlugin());
- conf.setSslFactoryPluginParams(this.getConfiguration().getBrokerClientSslFactoryPluginParams());
if (this.getConfiguration().isBrokerClientTlsEnabledWithKeyStore()) {
conf.setUseKeyStoreTls(true);
conf.setTlsTrustStoreType(this.getConfiguration().getBrokerClientTlsTrustStoreType());
@@ -1974,9 +2089,7 @@ public class PulsarService implements AutoCloseable, ShutdownService {
if (conf.isBrokerClientTlsEnabled()) {
builder.tlsCiphers(conf.getBrokerClientTlsCiphers())
- .tlsProtocols(conf.getBrokerClientTlsProtocols())
- .sslFactoryPlugin(conf.getBrokerClientSslFactoryPlugin())
- .sslFactoryPluginParams(conf.getBrokerClientSslFactoryPluginParams());
+ .tlsProtocols(conf.getBrokerClientTlsProtocols());
if (conf.isBrokerClientTlsEnabledWithKeyStore()) {
builder.useKeyStoreTls(true).tlsTrustStoreType(conf.getBrokerClientTlsTrustStoreType())
.tlsTrustStorePath(conf.getBrokerClientTlsTrustStore())
@@ -1996,6 +2109,8 @@ public class PulsarService implements AutoCloseable, ShutdownService {
// most of the admin request requires to make zk-call so, keep the max read-timeout based on
// zk-operation timeout
builder.readTimeout(conf.getMetadataStoreOperationTimeoutSeconds(), TimeUnit.SECONDS);
+ // PIP-478: route the broker's own admin client onto the new TLS SPI when opted in.
+ applyBrokerClientTlsFactoryToAdmin(builder);
return builder;
}
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
index bc1a0e047a50..e77f8a173b81 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
@@ -1727,6 +1727,19 @@ public class NamespaceService implements AutoCloseable {
.thenApply(GetTopicsResult::getTopics);
}
+ /**
+ * The peer-cluster lookup client used to list a peer cluster's non-persistent topics. Only the service URL
+ * comes from the cluster entry: the TLS configuration is broker-level throughout — the material from the
+ * broker's own {@code brokerClient*} settings here, and the {@code PulsarTlsFactory} from the broker-level
+ * {@code brokerClientTlsFactoryClassName} via {@link PulsarService#createClientImpl}, since nothing sets
+ * {@code tlsFactoryClassName} on this configuration. So {@code ClusterData.brokerClientTls*} does not reach
+ * this leg — as in 4.x, where it read the broker-level {@code brokerClientSslFactoryPlugin} rather than the
+ * per-cluster one. The two legs the per-cluster fields do drive (replication and the cross-cluster admin)
+ * build their configuration from {@code ClusterData} in {@code BrokerService}.
+ *
+ * @param cluster the peer cluster to reach
+ * @return the shared client for that cluster
+ */
@SuppressWarnings("deprecation")
public PulsarClientImpl getNamespaceClient(ClusterDataImpl cluster) {
PulsarClientImpl client = namespaceClients.get(cluster);
@@ -1761,9 +1774,7 @@ public class NamespaceService implements AutoCloseable {
.enableTls(true)
.tlsTrustCertsFilePath(pulsar.getConfiguration().getBrokerClientTrustCertsFilePath())
.allowTlsInsecureConnection(pulsar.getConfiguration().isTlsAllowInsecureConnection())
- .enableTlsHostnameVerification(pulsar.getConfiguration().isTlsHostnameVerificationEnabled())
- .sslFactoryPlugin(pulsar.getConfiguration().getBrokerClientSslFactoryPlugin())
- .sslFactoryPluginParams(pulsar.getConfiguration().getBrokerClientSslFactoryPluginParams());
+ .enableTlsHostnameVerification(pulsar.getConfiguration().isTlsHostnameVerificationEnabled());
} else {
clientBuilder.serviceUrl(isNotBlank(cluster.getBrokerServiceUrl())
? cluster.getBrokerServiceUrl() : cluster.getServiceUrl());
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
index 51580be4bfd2..0019cf562c3e 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
@@ -151,6 +151,7 @@ import org.apache.pulsar.broker.validator.BindAddressValidator;
import org.apache.pulsar.client.admin.PulsarAdmin;
import org.apache.pulsar.client.admin.PulsarAdminBuilder;
import org.apache.pulsar.client.admin.PulsarAdminException;
+import org.apache.pulsar.client.admin.internal.PulsarAdminBuilderImpl;
import org.apache.pulsar.client.api.ClientBuilder;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.PulsarClientException;
@@ -237,6 +238,10 @@ public class BrokerService implements Closeable {
ConcurrentHashMap.newKeySet();
private final Map<String, PulsarClient> replicationClients = new ConcurrentHashMap<>();
+ // PIP-478: clusters already warned about a stale PIP-337 factory selection, so the WARN fires once.
+ private final Set<String> stalePip337ClusterFactoryWarned = ConcurrentHashMap.newKeySet();
+ // Clusters already warned about factory config that names no factory; the WARN fires once.
+ private final Set<String> staleClusterFactoryConfigWarned = ConcurrentHashMap.newKeySet();
private final Map<String, PulsarAdmin> clusterAdmins = new ConcurrentHashMap<>();
// Multi-layer topics map:
@@ -339,6 +344,8 @@ public class BrokerService implements Closeable {
private PulsarChannelInitializer.Factory pulsarChannelInitFactory = PulsarChannelInitializer.DEFAULT_FACTORY;
private final List<Channel> listenChannels = new ArrayList<>(2);
+ // PIP-478: the channel initializers that own a PulsarTlsFactory (new-SPI TLS path); closed on shutdown.
+ private final List<PulsarChannelInitializer> pulsarChannelInitializers = new ArrayList<>(2);
private Channel listenChannel;
private Channel listenChannelTls;
@@ -655,8 +662,10 @@ public class BrokerService implements Closeable {
.listenerName(a.getListenerName()).build();
ServerBootstrap b = defaultServerBootstrap.clone();
- b.childHandler(
- pulsarChannelInitFactory.newPulsarChannelInitializer(pulsar, opts));
+ PulsarChannelInitializer channelInitializer =
+ pulsarChannelInitFactory.newPulsarChannelInitializer(pulsar, opts);
+ pulsarChannelInitializers.add(channelInitializer);
+ b.childHandler(channelInitializer);
try {
Channel ch = b.bind(addr).sync().channel();
listenChannels.add(ch);
@@ -1005,6 +1014,9 @@ public class BrokerService implements Closeable {
}
});
+ // PIP-478: dispose any PulsarTlsFactory subscriptions held by the listeners.
+ pulsarChannelInitializers.forEach(PulsarChannelInitializer::close);
+
maxTopicListInFlightLimiter.close();
if (interceptor != null) {
@@ -1632,7 +1644,6 @@ public class BrokerService implements Closeable {
return topicFuture;
}
- @SuppressWarnings("deprecation")
public PulsarClient getReplicationClient(String cluster, Optional<ClusterData> clusterDataOp) {
PulsarClient client = replicationClients.get(cluster);
if (client != null) {
@@ -1643,6 +1654,7 @@ public class BrokerService implements Closeable {
try {
ClusterData data = clusterDataOp
.orElseThrow(() -> new MetadataStoreException.NotFoundException(cluster));
+ warnOnStalePip337ClusterFactory(cluster, data);
ClientBuilder clientBuilder = PulsarClient.builder()
.enableTcpNoDelay(false)
.connectionsPerBroker(pulsar.getConfiguration().getReplicationConnectionsPerBroker())
@@ -1682,8 +1694,12 @@ public class BrokerService implements Closeable {
data.getBrokerClientKeyFilePath(),
data.getBrokerClientCertificateFilePath(),
pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
- data.getBrokerClientSslFactoryPlugin(),
- data.getBrokerClientSslFactoryPluginParams()
+ resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+ pulsar.getConfiguration().getBrokerClientTlsFactoryClassName()),
+ resolveBrokerClientTlsFactoryConfig(cluster,
+ data.getBrokerClientTlsFactoryClassName(),
+ data.getBrokerClientTlsFactoryConfig(),
+ pulsar.getConfiguration().getBrokerClientTlsFactoryConfig())
);
} else if (pulsar.getConfiguration().isBrokerClientTlsEnabled()) {
configTlsSettings(clientBuilder, serviceUrlTls,
@@ -1699,8 +1715,12 @@ public class BrokerService implements Closeable {
pulsar.getConfiguration().getBrokerClientKeyFilePath(),
pulsar.getConfiguration().getBrokerClientCertificateFilePath(),
pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
- pulsar.getConfiguration().getBrokerClientSslFactoryPlugin(),
- pulsar.getConfiguration().getBrokerClientSslFactoryPluginParams()
+ resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+ pulsar.getConfiguration().getBrokerClientTlsFactoryClassName()),
+ resolveBrokerClientTlsFactoryConfig(cluster,
+ data.getBrokerClientTlsFactoryClassName(),
+ data.getBrokerClientTlsFactoryConfig(),
+ pulsar.getConfiguration().getBrokerClientTlsFactoryConfig())
);
} else {
clientBuilder.serviceUrl(
@@ -1726,6 +1746,99 @@ public class BrokerService implements Closeable {
});
}
+ /**
+ * Resolve the {@code PulsarTlsFactory} class name for outbound connections to a remote cluster: the
+ * cluster's own {@code brokerClientTlsFactoryClassName} when set, else the broker-level one (PIP-478).
+ *
+ * <p>Unlike the TLS <em>material</em> in a {@link ClusterData} entry — which is taken wholesale, so a
+ * cluster that enables broker-client TLS supplies all of its own certificates — the factory falls back.
+ * The factory selects the <em>mechanism</em> that loads material rather than the material itself, and a
+ * deployment that configured a custom broker-client factory (an HSM-backed one, say) must not silently
+ * revert to the default file-based factory just because a cluster entry did not repeat the setting.
+ * That is the same reasoning that makes {@code brokerClientSslProvider} / {@code brokerClientJsseProvider}
+ * broker-level in these helpers: a silent downgrade of the TLS mechanism is a security regression.
+ *
+ * @param clusterValue the cluster's value, possibly blank
+ * @param brokerValue the broker-level value
+ * @return the cluster value when non-blank, else the broker-level value
+ */
+ /**
+ * Log the stale PIP-337 per-cluster factory selection, once per cluster (PIP-478). This is the single
+ * removed PIP-337 key whose stale value cannot fail loud — rejecting it on a metadata read would make the
+ * cluster unloadable — so a WARN naming the cluster is the whole remediation signal, and both
+ * {@code ClusterData}'s deprecation javadoc and pip-478.md promise it.
+ *
+ * @param cluster the cluster name, for the operator to act on
+ * @param data the cluster metadata being consumed
+ */
+ @SuppressWarnings("deprecation")
+ private void warnOnStalePip337ClusterFactory(String cluster, ClusterData data) {
+ if (StringUtils.isBlank(data.getBrokerClientSslFactoryPlugin())
+ && StringUtils.isBlank(data.getBrokerClientSslFactoryPluginParams())) {
+ return;
+ }
+ if (!stalePip337ClusterFactoryWarned.add(cluster)) {
+ return;
+ }
+ log.warn()
+ .attr("cluster", cluster)
+ .attr("brokerClientSslFactoryPlugin", data.getBrokerClientSslFactoryPlugin())
+ .attr("brokerClientSslFactoryPluginParams", data.getBrokerClientSslFactoryPluginParams())
+ .log("Ignoring the PIP-337 per-cluster SSL factory settings: it was removed in Pulsar 5.0 "
+ + "(PIP-478). Set ClusterData.brokerClientTlsFactoryClassName (per cluster) or the "
+ + "broker-level brokerClientTlsFactoryClassName instead");
+ }
+
+ /**
+ * Resolve the {@code PulsarTlsFactory} class name for outbound connections to a remote cluster: the
+ * cluster's own {@code brokerClientTlsFactoryClassName} when set, else the broker-level one (PIP-478).
+ *
+ * <p>Unlike the TLS <em>material</em> in a {@link ClusterData} entry — taken wholesale, so a cluster that
+ * enables broker-client TLS supplies all of its own certificates — the factory falls back. The factory
+ * selects the <em>mechanism</em> that loads material rather than the material itself, and a deployment
+ * that configured a custom broker-client factory (an HSM-backed one, say) must not silently revert to the
+ * default file-based factory because a cluster entry did not repeat the setting. That is the same
+ * reasoning that keeps {@code brokerClientSslProvider} / {@code brokerClientJsseProvider} broker-level in
+ * these helpers: a silent downgrade of the TLS mechanism is a security regression.
+ *
+ * @param clusterValue the cluster's value, possibly blank
+ * @param brokerValue the broker-level value
+ * @return the cluster value when non-blank, else the broker-level value
+ */
+ private static String resolveBrokerClientTlsFactory(String clusterValue, String brokerValue) {
+ return StringUtils.isNotBlank(clusterValue) ? clusterValue : brokerValue;
+ }
+
+ /**
+ * The factory configuration that belongs with {@link #resolveBrokerClientTlsFactory}'s class name. The
+ * pair resolves <em>atomically</em>: when the cluster names the factory, the cluster's config wins even
+ * when blank. Resolving the two independently would hand factory A's init parameters to factory B — a
+ * cluster that overrides only the class name would otherwise inherit the broker-level config.
+ *
+ * @param clusterClassName the cluster's factory class name, possibly blank
+ * @param clusterConfig the cluster's factory configuration, possibly blank
+ * @param brokerConfig the broker-level factory configuration
+ * @return the configuration belonging to the selected factory
+ */
+ private String resolveBrokerClientTlsFactoryConfig(String cluster, String clusterClassName,
+ String clusterConfig, String brokerConfig) {
+ if (StringUtils.isNotBlank(clusterClassName)) {
+ return clusterConfig;
+ }
+ if (StringUtils.isNotBlank(clusterConfig) && staleClusterFactoryConfigWarned.add(cluster)) {
+ // The cluster configured init params but named no factory, so the broker-level factory applies and
+ // these params are not its. Dropping them silently is the same failure shape as a stale PIP-337
+ // value, so say so rather than let the operator believe they took effect.
+ log.warn()
+ .attr("cluster", cluster)
+ .log("Ignoring ClusterData.brokerClientTlsFactoryConfig: the cluster sets no "
+ + "brokerClientTlsFactoryClassName, so the broker-level factory and its own "
+ + "configuration apply. Set the class name on the cluster to use this "
+ + "configuration");
+ }
+ return brokerConfig;
+ }
+
private void configTlsSettings(ClientBuilder clientBuilder, String serviceUrl,
boolean brokerClientTlsEnabledWithKeyStore, boolean isTlsAllowInsecureConnection,
String brokerClientTlsTrustStoreType, String brokerClientTlsTrustStore,
@@ -1733,16 +1846,28 @@ public class BrokerService implements Closeable {
String brokerClientTlsKeyStore, String brokerClientTlsKeyStorePassword,
String brokerClientTrustCertsFilePath,
String brokerClientKeyFilePath, String brokerClientCertificateFilePath,
- boolean isTlsHostnameVerificationEnabled, String brokerClientSslFactoryPlugin,
- String brokerClientSslFactoryPluginParams) {
+ boolean isTlsHostnameVerificationEnabled,
+ String tlsFactoryClassName, String tlsFactoryConfig) {
+ // PIP-478: the PIP-337 sslFactoryPlugin config is removed. A stale broker-level key with a
+ // non-default value is rejected at config-file load by PulsarConfigurationLoader; the
+ // per-cluster ClusterData field is retained but ignored, and warnOnStalePip337ClusterFactory
+ // logs it (the one place a stale value cannot fail loud). Its successor is
+ // ClusterData.brokerClientTlsFactoryClassName, resolved by
+ // resolveBrokerClientTlsFactory below.
clientBuilder
.serviceUrl(serviceUrl)
.allowTlsInsecureConnection(isTlsAllowInsecureConnection)
- .enableTlsHostnameVerification(isTlsHostnameVerificationEnabled);
- if (StringUtils.isNotBlank(brokerClientSslFactoryPlugin)) {
- clientBuilder.sslFactoryPlugin(brokerClientSslFactoryPlugin)
- .sslFactoryPluginParams(brokerClientSslFactoryPluginParams);
- }
+ .enableTlsHostnameVerification(isTlsHostnameVerificationEnabled)
+ .tlsFactoryClassName(tlsFactoryClassName)
+ .tlsFactoryConfig(tlsFactoryConfig)
+ // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+ // (jsseProvider) so geo-replication clients honor them, mirroring
+ // PulsarService.createClientConfigurationData. Without this the replication client silently
+ // builds TLS with the default JDK provider (e.g. a FIPS/OpenSSL provider downgrade).
+ .sslProvider(pulsar.getConfiguration().getBrokerClientSslProvider());
+ // jsseProvider has no ClientBuilder setter; set it on the underlying config like sslProvider above.
+ ((ClientBuilderImpl) clientBuilder).getClientConfigurationData()
+ .setJsseProvider(pulsar.getConfiguration().getBrokerClientJsseProvider());
if (brokerClientTlsEnabledWithKeyStore) {
clientBuilder.useKeyStoreTls(true)
.tlsTrustStoreType(brokerClientTlsTrustStoreType)
@@ -1765,8 +1890,13 @@ public class BrokerService implements Closeable {
String brokerClientTlsKeyStore, String brokerClientTlsKeyStorePassword,
String brokerClientTrustCertsFilePath,
String brokerClientKeyFilePath, String brokerClientCertificateFilePath,
- boolean isTlsHostnameVerificationEnabled, String brokerClientSslFactoryPlugin,
- String brokerClientSslFactoryPluginParams) {
+ boolean isTlsHostnameVerificationEnabled,
+ String tlsFactoryClassName, String tlsFactoryConfig) {
+ // PIP-478: the PIP-337 sslFactoryPlugin config is removed (see configTlsSettings). The cluster's
+ // brokerClientTlsFactoryClassName covers this "purpose" too: a ClusterData entry selects one factory
+ // for both outbound legs it drives — the binary replication client and this admin (HTTPS) client.
+ adminBuilder.tlsFactoryClassName(tlsFactoryClassName)
+ .tlsFactoryConfig(tlsFactoryConfig);
if (brokerClientTlsEnabledWithKeyStore) {
adminBuilder.useKeyStoreTls(true)
.tlsTrustStoreType(brokerClientTlsTrustStoreType)
@@ -1782,8 +1912,14 @@ public class BrokerService implements Closeable {
}
adminBuilder.allowTlsInsecureConnection(isTlsAllowInsecureConnection)
.enableTlsHostnameVerification(isTlsHostnameVerificationEnabled)
- .sslFactoryPlugin(brokerClientSslFactoryPlugin)
- .sslFactoryPluginParams(brokerClientSslFactoryPluginParams);
+ // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+ // (jsseProvider) so cross-cluster admin clients honor them, mirroring
+ // PulsarService.createClientConfigurationData. Without this the cluster admin silently builds
+ // TLS with the default JDK provider (e.g. a FIPS/OpenSSL provider downgrade).
+ .sslProvider(pulsar.getConfiguration().getBrokerClientSslProvider());
+ // jsseProvider has no PulsarAdminBuilder setter; set it on the underlying config like sslProvider.
+ ((PulsarAdminBuilderImpl) adminBuilder).getConf()
+ .setJsseProvider(pulsar.getConfiguration().getBrokerClientJsseProvider());
}
public PulsarAdmin getClusterPulsarAdmin(String cluster, Optional<ClusterData> clusterDataOp) {
@@ -1795,6 +1931,7 @@ public class BrokerService implements Closeable {
try {
ClusterData data = clusterDataOp
.orElseThrow(() -> new MetadataStoreException.NotFoundException(cluster));
+ warnOnStalePip337ClusterFactory(cluster, data);
PulsarAdminBuilder builder = PulsarAdmin.builder();
ServiceConfiguration conf = pulsar.getConfig();
@@ -1831,8 +1968,12 @@ public class BrokerService implements Closeable {
data.getBrokerClientKeyFilePath(),
data.getBrokerClientCertificateFilePath(),
pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
- data.getBrokerClientSslFactoryPlugin(),
- data.getBrokerClientSslFactoryPluginParams()
+ resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+ conf.getBrokerClientTlsFactoryClassName()),
+ resolveBrokerClientTlsFactoryConfig(cluster,
+ data.getBrokerClientTlsFactoryClassName(),
+ data.getBrokerClientTlsFactoryConfig(),
+ conf.getBrokerClientTlsFactoryConfig())
);
} else if (conf.isBrokerClientTlsEnabled()) {
configAdminTlsSettings(builder,
@@ -1848,8 +1989,12 @@ public class BrokerService implements Closeable {
conf.getBrokerClientKeyFilePath(),
conf.getBrokerClientCertificateFilePath(),
pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
- conf.getBrokerClientSslFactoryPlugin(),
- conf.getBrokerClientSslFactoryPluginParams()
+ resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+ conf.getBrokerClientTlsFactoryClassName()),
+ resolveBrokerClientTlsFactoryConfig(cluster,
+ data.getBrokerClientTlsFactoryClassName(),
+ data.getBrokerClientTlsFactoryConfig(),
+ conf.getBrokerClientTlsFactoryConfig())
);
}
@@ -1857,6 +2002,9 @@ public class BrokerService implements Closeable {
// zk-operation timeout
builder.readTimeout(conf.getMetadataStoreOperationTimeoutSeconds(), TimeUnit.SECONDS);
+ // PIP-478: route this per-cluster admin client onto the new TLS SPI when opted in.
+ pulsar.applyBrokerClientTlsFactoryToAdmin(builder);
+
PulsarAdmin adminClient = builder.build();
log.info().attr("adminApiUrl", adminApiUrl).log("Created client admin instance");
return adminClient;
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
index 579e253f8807..7c4de4013c53 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
@@ -23,19 +23,24 @@ import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.flush.FlushConsolidationHandler;
-import io.netty.handler.ssl.SslHandler;
-import java.util.concurrent.TimeUnit;
+import io.netty.handler.ssl.SslContext;
import lombok.Builder;
import lombok.CustomLog;
import lombok.Data;
import org.apache.pulsar.broker.PulsarService;
import org.apache.pulsar.broker.ServiceConfiguration;
+import org.apache.pulsar.broker.tls.DefaultBrokerTlsFactory;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
import org.apache.pulsar.common.protocol.ByteBufPair;
import org.apache.pulsar.common.protocol.FrameDecoderUtil;
import org.apache.pulsar.common.protocol.OptionalProxyProtocolDecoder;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
+import org.apache.pulsar.common.tls.impl.TlsSynthesisSpec;
import org.apache.pulsar.common.util.netty.PulsarFlowControlHandler;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
@CustomLog
public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel> {
@@ -46,7 +51,10 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
private final String listenerName;
private final boolean enableTls;
private final ServiceConfiguration brokerConf;
- private PulsarSslFactory sslFactory;
+ // PIP-478 TLS SPI factory (the only server TLS path since PIP-337 removal).
+ private PulsarTlsFactory tlsFactory;
+ private TlsHandle<SslContext> tlsSubscription;
+ private volatile SslContext tlsServerContext;
/**
* @param pulsar
@@ -61,21 +69,53 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
this.enableTls = opts.isEnableTLS();
ServiceConfiguration serviceConfig = pulsar.getConfiguration();
if (this.enableTls) {
- PulsarSslConfiguration pulsarSslConfig = buildSslConfiguration(serviceConfig);
- this.sslFactory = (PulsarSslFactory) Class.forName(serviceConfig.getSslFactoryPlugin())
- .getConstructor().newInstance();
- this.sslFactory.initialize(pulsarSslConfig);
- this.sslFactory.createInternalSslContext();
- if (serviceConfig.getTlsCertRefreshCheckDurationSec() > 0) {
- this.pulsar.getExecutor().scheduleWithFixedDelay(this::refreshSslContext,
- serviceConfig.getTlsCertRefreshCheckDurationSec(),
- serviceConfig.getTlsCertRefreshCheckDurationSec(),
- TimeUnit.SECONDS);
- }
+ initializeTlsFactory(serviceConfig);
}
this.brokerConf = pulsar.getConfiguration();
}
+ // PIP-478: build the PulsarTlsFactory, initialize it, and subscribe to the BROKER purpose. A volatile
+ // Netty SslContext holds the latest instance; rotation is delivered by the subscription (no refresh
+ // task). The subscription's first delivery happens-before its future completes, so tlsServerContext is
+ // set by the time this returns.
+ private void initializeTlsFactory(ServiceConfiguration serviceConfig) throws Exception {
+ this.tlsFactory = TlsFactorySupport.createFactory(serviceConfig.getTlsFactoryClassName(),
+ DefaultBrokerTlsFactory.class,
+ () -> DefaultBrokerTlsFactory.fromServiceConfiguration(serviceConfig));
+ try {
+ TlsFactoryInitContext initContext = TlsFactorySupport.initContext(
+ TlsFactorySupport.parseFactoryConfig(serviceConfig.getTlsFactoryConfig()),
+ pulsar.getExecutor(), pulsar.getExecutor(), pulsar.getOpenTelemetry().getOpenTelemetry());
+ TlsFactorySupport.initializeBlocking(this.tlsFactory, initContext);
+ this.tlsSubscription = TlsContextAcquisition.acquireNettyContext(this.tlsFactory, TlsPurpose.BROKER,
+ TlsSynthesisSpec.server(serviceConfig.isTlsRequireTrustedClientCertOnConnect()),
+ ctx -> this.tlsServerContext = ctx)
+ .get()
+ .orElseThrow(() -> new IllegalStateException(
+ "TLS factory supplied no Netty SslContext for purpose " + TlsPurpose.BROKER));
+ } catch (Exception e) {
+ // Ctor-throw cleanup: the factory was created (and possibly initialized, its rotation
+ // scheduler running) but the subscription failed; close() will not be called on a half-constructed
+ // initializer, so release it here. close() is null-safe and idempotent.
+ close();
+ throw e;
+ }
+ }
+
+ /** Dispose the PIP-478 TLS factory and its subscription, if any. Safe to call more than once. */
+ public void close() {
+ TlsHandle<SslContext> subscription = this.tlsSubscription;
+ if (subscription != null) {
+ this.tlsSubscription = null;
+ subscription.dispose();
+ }
+ PulsarTlsFactory factory = this.tlsFactory;
+ if (factory != null) {
+ this.tlsFactory = null;
+ factory.close();
+ }
+ }
+
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// disable auto read explicitly so that requests aren't served until auto read is enabled
@@ -85,7 +125,11 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
ch.config().setWriteBufferLowWaterMark(pulsar.getConfig().getPulsarChannelWriteBufferLowWaterMark());
ch.pipeline().addLast("consolidation", new FlushConsolidationHandler(1024, true));
if (this.enableTls) {
- ch.pipeline().addLast(TLS_HANDLER, new SslHandler(this.sslFactory.createServerSslEngine(ch.alloc())));
+ // PIP-478: build the handler from the current (possibly rotated) factory-owned SslContext, pinning
+ // it across newHandler so a concurrent rotation cannot free the native OpenSSL context mid-build
+ // (use-after-free guard).
+ ch.pipeline().addLast(TLS_HANDLER, TlsContextAcquisition.withPinnedContext(
+ () -> this.tlsServerContext, ctx -> ctx.newHandler(ch.alloc())));
}
ch.pipeline().addLast("ByteBufPairEncoder", ByteBufPair.getEncoder(this.enableTls));
@@ -133,33 +177,4 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
*/
private String listenerName;
}
-
- protected PulsarSslConfiguration buildSslConfiguration(ServiceConfiguration serviceConfig) {
- return PulsarSslConfiguration.builder()
- .tlsKeyStoreType(serviceConfig.getTlsKeyStoreType())
- .tlsKeyStorePath(serviceConfig.getTlsKeyStore())
- .tlsKeyStorePassword(serviceConfig.getTlsKeyStorePassword())
- .tlsTrustStoreType(serviceConfig.getTlsTrustStoreType())
- .tlsTrustStorePath(serviceConfig.getTlsTrustStore())
- .tlsTrustStorePassword(serviceConfig.getTlsTrustStorePassword())
- .tlsCiphers(serviceConfig.getTlsCiphers())
- .tlsProtocols(serviceConfig.getTlsProtocols())
- .tlsTrustCertsFilePath(serviceConfig.getTlsTrustCertsFilePath())
- .tlsCertificateFilePath(serviceConfig.getTlsCertificateFilePath())
- .tlsKeyFilePath(serviceConfig.getTlsKeyFilePath())
- .allowInsecureConnection(serviceConfig.isTlsAllowInsecureConnection())
- .requireTrustedClientCertOnConnect(serviceConfig.isTlsRequireTrustedClientCertOnConnect())
- .tlsEnabledWithKeystore(serviceConfig.isTlsEnabledWithKeyStore())
- .tlsCustomParams(serviceConfig.getSslFactoryPluginParams())
- .serverMode(true)
- .build();
- }
-
- protected void refreshSslContext() {
- try {
- this.sslFactory.update();
- } catch (Exception e) {
- log.error().exception(e).log("Failed to refresh SSL context");
- }
- }
}
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
index 7f576e7d19b1..908943b89f75 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
@@ -37,8 +37,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
import lombok.CustomLog;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
@@ -47,12 +45,15 @@ import org.apache.pulsar.broker.PulsarService;
import org.apache.pulsar.broker.ServiceConfiguration;
import org.apache.pulsar.broker.intercept.BrokerInterceptor;
import org.apache.pulsar.broker.intercept.BrokerInterceptors;
+import org.apache.pulsar.broker.tls.DefaultBrokerTlsFactory;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
import org.apache.pulsar.broker.validator.BindAddressValidator;
import org.apache.pulsar.common.configuration.BindAddress;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
import org.apache.pulsar.jetty.metrics.JettyStatisticsCollector;
-import org.apache.pulsar.jetty.tls.JettySslContextFactory;
+import org.apache.pulsar.jetty.tls.JettyTlsFactory;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsPurpose;
import org.eclipse.jetty.ee10.servlet.FilterHolder;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.ee10.servlet.ServletContextRequest;
@@ -109,8 +110,9 @@ public class WebService implements AutoCloseable {
private final ServerConnector httpsConnector;
private final FilterInitializer filterInitializer;
private JettyStatisticsCollector jettyStatisticsCollector;
- private PulsarSslFactory sslFactory;
- private ScheduledFuture<?> sslContextRefreshTask;
+ // PIP-478 TLS SPI factory (the only server TLS path since PIP-337 removal).
+ private PulsarTlsFactory tlsFactory;
+ private JettyTlsFactory.ReloadableServerTls reloadableServerTls;
@Getter
private static final DynamicSkipUnknownPropertyHandler sharedUnknownPropertyHandler =
@@ -163,21 +165,7 @@ public class WebService implements AutoCloseable {
SslContextFactory.Server sslCtxFactory = null;
if (tlsRequired) {
try {
- PulsarSslConfiguration sslConfiguration = buildSslConfiguration(config);
- this.sslFactory = (PulsarSslFactory) Class.forName(config.getSslFactoryPlugin())
- .getConstructor().newInstance();
- this.sslFactory.initialize(sslConfiguration);
- this.sslFactory.createInternalSslContext();
- if (config.getTlsCertRefreshCheckDurationSec() > 0) {
- this.sslContextRefreshTask = this.pulsar.getExecutor()
- .scheduleWithFixedDelay(this::refreshSslContext,
- config.getTlsCertRefreshCheckDurationSec(),
- config.getTlsCertRefreshCheckDurationSec(),
- TimeUnit.SECONDS);
- }
- sslCtxFactory = JettySslContextFactory.createSslContextFactory(config.getWebServiceTlsProvider(),
- this.sslFactory, config.isTlsRequireTrustedClientCertOnConnect(),
- config.getTlsCiphers(), config.getTlsProtocols());
+ sslCtxFactory = createTlsFactoryWebServer(config);
} catch (Exception e) {
throw new PulsarServerException(e);
}
@@ -569,8 +557,14 @@ public class WebService implements AutoCloseable {
jettyStatisticsCollector = null;
}
webServiceExecutor.join();
- if (this.sslContextRefreshTask != null) {
- this.sslContextRefreshTask.cancel(true);
+ // PIP-478: dispose the TLS factory subscription and close the factory, if the new path was used.
+ if (this.reloadableServerTls != null) {
+ this.reloadableServerTls.subscription().dispose();
+ this.reloadableServerTls = null;
+ }
+ if (this.tlsFactory != null) {
+ this.tlsFactory.close();
+ this.tlsFactory = null;
}
webExecutorThreadPoolStats.close();
this.executorStats.close();
@@ -593,33 +587,35 @@ public class WebService implements AutoCloseable {
}
}
- protected PulsarSslConfiguration buildSslConfiguration(ServiceConfiguration serviceConfig) {
- return PulsarSslConfiguration.builder()
- .tlsKeyStoreType(serviceConfig.getTlsKeyStoreType())
- .tlsKeyStorePath(serviceConfig.getTlsKeyStore())
- .tlsKeyStorePassword(serviceConfig.getTlsKeyStorePassword())
- .tlsTrustStoreType(serviceConfig.getTlsTrustStoreType())
- .tlsTrustStorePath(serviceConfig.getTlsTrustStore())
- .tlsTrustStorePassword(serviceConfig.getTlsTrustStorePassword())
- .tlsCiphers(serviceConfig.getTlsCiphers())
- .tlsProtocols(serviceConfig.getTlsProtocols())
- .tlsTrustCertsFilePath(serviceConfig.getTlsTrustCertsFilePath())
- .tlsCertificateFilePath(serviceConfig.getTlsCertificateFilePath())
- .tlsKeyFilePath(serviceConfig.getTlsKeyFilePath())
- .allowInsecureConnection(serviceConfig.isTlsAllowInsecureConnection())
- .requireTrustedClientCertOnConnect(serviceConfig.isTlsRequireTrustedClientCertOnConnect())
- .tlsEnabledWithKeystore(serviceConfig.isTlsEnabledWithKeyStore())
- .tlsCustomParams(serviceConfig.getSslFactoryPluginParams())
- .serverMode(true)
- .isHttps(true)
- .build();
- }
-
- protected void refreshSslContext() {
+ // PIP-478: build the PulsarTlsFactory and drive a vanilla Jetty SslContextFactory.Server through the
+ // SSLContext subscription (setSslContext pre-start, reload() on rotation). The web server has no cert
+ // refresh task — the factory delivers rotations to the reloading server factory.
+ private SslContextFactory.Server createTlsFactoryWebServer(ServiceConfiguration config) throws Exception {
+ this.tlsFactory = TlsFactorySupport.createFactory(config.getTlsFactoryClassName(),
+ DefaultBrokerTlsFactory.class, () -> DefaultBrokerTlsFactory.fromServiceConfiguration(config));
+ // Once the factory is created it owns live resources (cert watchers, HSM sessions, reload work).
+ // A failure in any subsequent step rethrows out of the constructor without returning a WebService,
+ // so doClose() is never reachable — dispose/close the partial state here to avoid leaking it.
try {
- this.sslFactory.update();
+ TlsFactoryInitContext initContext = TlsFactorySupport.initContext(
+ TlsFactorySupport.parseFactoryConfig(config.getTlsFactoryConfig()),
+ pulsar.getExecutor(), pulsar.getExecutor(), pulsar.getOpenTelemetry().getOpenTelemetry());
+ TlsFactorySupport.initializeBlocking(this.tlsFactory, initContext);
+ this.reloadableServerTls = JettyTlsFactory.createReloadingServerFactory(this.tlsFactory, TlsPurpose.WEB,
+ pulsar.getExecutor(),
+ config.getWebServiceTlsProvider(), config.isTlsRequireTrustedClientCertOnConnect(),
+ config.isTlsAllowInsecureConnection(), config.getTlsCiphers(), config.getTlsProtocols());
+ return this.reloadableServerTls.sslContextFactory();
} catch (Exception e) {
- log.error().exception(e).log("Failed to refresh SSL context");
+ if (this.reloadableServerTls != null) {
+ this.reloadableServerTls.subscription().dispose();
+ this.reloadableServerTls = null;
+ }
+ if (this.tlsFactory != null) {
+ this.tlsFactory.close();
+ this.tlsFactory = null;
+ }
+ throw e;
}
}
}
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
index 5413b10858fa..93a0b38682aa 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
@@ -95,9 +95,7 @@ public class CompactorTool {
if (internalListener.getBrokerServiceUrlTls() != null && brokerConfig.isBrokerClientTlsEnabled()) {
clientBuilder.serviceUrl(internalListener.getBrokerServiceUrlTls().toString())
.allowTlsInsecureConnection(brokerConfig.isTlsAllowInsecureConnection())
- .enableTlsHostnameVerification(brokerConfig.isTlsHostnameVerificationEnabled())
- .sslFactoryPlugin(brokerConfig.getBrokerClientSslFactoryPlugin())
- .sslFactoryPluginParams(brokerConfig.getBrokerClientSslFactoryPluginParams());
+ .enableTlsHostnameVerification(brokerConfig.isTlsHostnameVerificationEnabled());
if (brokerConfig.isBrokerClientTlsEnabledWithKeyStore()) {
clientBuilder.useKeyStoreTls(true)
.tlsKeyStoreType(brokerConfig.getBrokerClientTlsKeyStoreType())
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
index 173e0557fb42..51e5a2ddfd3f 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
@@ -52,7 +52,8 @@ import org.apache.pulsar.common.policies.data.ClusterData;
import org.apache.pulsar.common.policies.data.ResourceGroup;
import org.apache.pulsar.common.policies.data.TenantInfoImpl;
import org.apache.pulsar.common.tls.NoopHostnameVerifier;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.jackson.JacksonFeature;
@@ -112,12 +113,12 @@ public class AdminApiTlsAuthTest extends MockedPulsarServiceBaseTest {
ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(httpConfig)
.register(JacksonConfigurator.class).register(JacksonFeature.class);
- X509Certificate trustCertificates[] = SecurityUtility.loadCertificatesFromPemFile(
+ X509Certificate trustCertificates[] = PemReader.loadCertificatesFromPemFile(
CA_CERT_FILE_PATH);
- SSLContext sslCtx = SecurityUtility.createSslContext(
+ SSLContext sslCtx = JdkSslContexts.createSslContext(
false, trustCertificates,
- SecurityUtility.loadCertificatesFromPemFile(getTlsFileForClient(user + ".cert")),
- SecurityUtility.loadPrivateKeyFromPemFile(getTlsFileForClient(user + ".key-pk8")));
+ PemReader.loadCertificatesFromPemFile(getTlsFileForClient(user + ".cert")),
+ PemReader.loadPrivateKeyFromPemFile(getTlsFileForClient(user + ".key-pk8")));
clientBuilder.sslContext(sslCtx).hostnameVerifier(NoopHostnameVerifier.INSTANCE);
Client client = clientBuilder.build();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java
new file mode 100644
index 000000000000..74746f77713e
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.broker.admin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.Optional;
+import java.util.Set;
+import lombok.CustomLog;
+import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
+import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 4b: the broker's own outbound admin client (the {@code AsyncHttpConnector} behind
+ * {@link org.apache.pulsar.broker.PulsarService#getAdminClient()}) on the new TLS SPI, opted in via
+ * {@code brokerClientTlsFactoryClassName=default}. A targeted variant of {@link BrokerAdminClientTlsAuthTest};
+ * that test exercises the legacy PIP-337 default (gate off) unmodified.
+ */
+@CustomLog
+@Test(groups = "broker-admin")
+public class BrokerAdminClientTlsFactoryTest extends MockedPulsarServiceBaseTest {
+
+ @BeforeMethod
+ @Override
+ public void setup() throws Exception {
+ conf.setWebServicePortTls(Optional.of(0));
+ conf.setBrokerServicePortTls(Optional.of(0));
+ conf.setTlsCertificateFilePath(BROKER_CERT_FILE_PATH);
+ conf.setTlsKeyFilePath(BROKER_KEY_FILE_PATH);
+ conf.setTlsTrustCertsFilePath(CA_CERT_FILE_PATH);
+ conf.setAuthenticationEnabled(true);
+ // The broker's own admin client authenticates with the broker cert (CN=broker-localhost-SAN).
+ conf.setSuperUserRoles(Set.of("broker-localhost-SAN"));
+ conf.setAuthenticationProviders(
+ Set.of("org.apache.pulsar.broker.authentication.AuthenticationProviderTls"));
+ conf.setAuthorizationEnabled(true);
+ conf.setBrokerClientTlsEnabled(true);
+ conf.setTlsAllowInsecureConnection(true);
+ // Opt in: route the broker's own outbound clients — including the admin client — onto
+ // the PIP-478 TLS SPI. The base MockedPulsarServiceBaseTest wires the broker-client AuthenticationTls
+ // material (broker cert/key + CA trust) automatically for the web-TLS + AuthenticationProviderTls case.
+ conf.setBrokerClientTlsFactoryClassName("default");
+ super.internalSetup();
+ }
+
+ @AfterMethod(alwaysRun = true)
+ @Override
+ public void cleanup() throws Exception {
+ super.internalCleanup();
+ }
+
+ @Test
+ public void brokerAdminClientRidesNewTlsPathAndWorksOverTls() throws Exception {
+ // The broker's own admin client targets its https web service; the gate routes its AsyncHttpConnector
+ // onto the new SPI.
+ PulsarAdminImpl admin = (PulsarAdminImpl) pulsar.getAdminClient();
+ assertThat(admin.getServiceUrl()).startsWith("https://");
+ assertThat(admin.getAsyncHttpConnector().getTlsFactory())
+ .as("the broker admin client should ride the PIP-478 TLS SPI when "
+ + "brokerClientTlsFactoryClassName is set")
+ .isNotNull();
+ // Create then read a cluster over TLS: a full write+read admin round-trip driven by the new-SPI
+ // handshake (a TLS failure on either call would throw, so completion proves the factory is wired).
+ admin.clusters().createCluster(configClusterName,
+ ClusterData.builder().serviceUrl(brokerUrl.toString()).build());
+ assertThat(admin.clusters().getClusters()).contains(configClusterName);
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
index 0d21ae73617a..91787645f1fa 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
@@ -21,6 +21,7 @@ package org.apache.pulsar.broker.service;
import static org.apache.pulsar.broker.loadbalance.extensions.channel.ServiceUnitStateTableViewImpl.TOPIC;
import static org.apache.pulsar.common.naming.SystemTopicNames.TRANSACTION_COORDINATOR_ASSIGN;
import static org.apache.pulsar.common.naming.SystemTopicNames.TRANSACTION_COORDINATOR_LOG;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doNothing;
@@ -760,7 +761,11 @@ public class BrokerServiceTest extends BrokerTestBase {
fail("should fail");
} catch (Exception e) {
- assertTrue(e.getMessage().contains("unable to find valid certification path to requested target"));
+ // The PKIX failure is reported by the JDK trust manager the TLS engine delegates to, so it always
+ // appears in the cause chain. Which engine surfaces it, and therefore what the top-level message
+ // says, depends on the configured TLS provider: the JDK engine says "General SSLEngine problem"
+ // and the OpenSSL engine says "General OpenSslEngine problem". Assert on the chain, not the tip.
+ assertThat(e).hasStackTraceContaining("unable to find valid certification path to requested target");
} finally {
pulsarClient.close();
}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
index 20b9f0e8ded5..854bedb9392b 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
@@ -321,12 +321,10 @@ public class OneWayReplicatorDeduplicationTest extends OneWayReplicatorTestBase
(conf, eventLoopGroup) -> new ClientCnx(InstrumentProvider.NOOP, conf, eventLoopGroup) {
@Override
- protected ByteBuf newConnectCommand() throws Exception {
+ protected ByteBuf buildConnectCommand(AuthData authData) throws Exception {
if (supportsReplDedupByLidAndEid) {
- return super.newConnectCommand();
+ return super.buildConnectCommand(authData);
}
- authenticationDataProvider = authentication.getAuthData(remoteHostName);
- AuthData authData = authenticationDataProvider.authenticate(AuthData.INIT_AUTH_DATA);
BaseCommand cmd = Commands.newConnectWithoutSerialize(authentication.getAuthMethodName(), authData,
this.protocolVersion, clientVersion, proxyToTargetBrokerAddress,
null, null, null, null, null);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java
new file mode 100644
index 000000000000..dc8bf787c188
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.broker.service;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import java.util.List;
+import java.util.Optional;
+import lombok.Cleanup;
+import lombok.CustomLog;
+import org.apache.pulsar.broker.BrokerTestUtil;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.common.naming.TopicName;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 4a: the broker's own outbound clients (geo-replication, cluster-internal lookup) on the new
+ * TLS SPI ({@code BROKER_CLIENT}). A targeted variant of {@link ReplicatorTlsTest} that opts in via
+ * {@code brokerClientTlsFactoryClassName=default}; {@link ReplicatorTlsTest} itself exercises the legacy
+ * PIP-337 default (gate off) unmodified.
+ */
+@Test(groups = "broker-replication")
+@CustomLog
+public class ReplicatorTlsFactoryTest extends ReplicatorTestBase {
+
+ @Override
+ @BeforeClass(timeOut = 300000)
+ public void setup() throws Exception {
+ // The per-cluster replication clients already ride TLS from their ClusterData brokerClientTls* material
+ // (configured by ReplicatorTestBase); opting in via brokerClientTlsFactoryClassName routes them onto the
+ // PIP-478 TLS SPI. (Deliberately not setting the ServiceConfiguration brokerClientTlsEnabled flag: it is
+ // unrelated to this gate — the replication path keys off ClusterData — and toggling it perturbs the
+ // remote-admin harness independently of PIP-478.)
+ config1.setBrokerClientTlsFactoryClassName("default");
+ config2.setBrokerClientTlsFactoryClassName("default");
+ config3.setBrokerClientTlsFactoryClassName("default");
+ super.setup();
+ }
+
+ @Override
+ @AfterClass(alwaysRun = true, timeOut = 300000)
+ public void cleanup() throws Exception {
+ super.cleanup();
+ }
+
+ @Test
+ public void testReplicationClientOnNewTlsPath() throws Exception {
+ log.info("--- Starting ReplicatorTlsFactoryTest::testReplicationClientOnNewTlsPath ---");
+ for (BrokerService ns : List.of(ns1, ns2, ns3)) {
+ ns.getReplicationClient(cluster1, Optional.of(admin1.clusters().getCluster(cluster1)));
+ ns.getReplicationClient(cluster2, Optional.of(admin1.clusters().getCluster(cluster2)));
+ ns.getReplicationClient(cluster3, Optional.of(admin1.clusters().getCluster(cluster3)));
+
+ ns.getReplicationClients().forEach((cluster, client) -> {
+ ClientConfigurationData conf = ((PulsarClientImpl) client).getConfiguration();
+ assertTrue(conf.isUseTls());
+ // The PIP-478 TLS factory was adopted for the broker's own outbound client.
+ assertNotNull(conf.getTlsFactory(), "broker-client should ride the PIP-478 TLS SPI when "
+ + "brokerClientTlsFactoryClassName is set");
+ // The broker-client material is still mapped onto the config fields (composed into the factory).
+ assertEquals(conf.getTlsTrustCertsFilePath(), caCertFilePath);
+ assertEquals(conf.getTlsKeyFilePath(), clientKeyFilePath);
+ assertEquals(conf.getTlsCertificateFilePath(), clientCertFilePath);
+ });
+ }
+ }
+
+ @Test
+ public void perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnset() throws Exception {
+ // PIP-478: a ClusterData entry may name its own PulsarTlsFactory, so a deployment can drive
+ // connections to one remote cluster through a different factory than the rest. Prove the cluster's
+ // value alone is enough — with the broker-level key blank, which is how it ships.
+ // The blank side of each case is what makes it discriminating: if precedence were inverted, the
+ // resolved name would come back empty rather than "default".
+ String brokerLevel = config1.getBrokerClientTlsFactoryClassName();
+ try {
+ config1.setBrokerClientTlsFactoryClassName("");
+ ClusterData clusterSelects = admin1.clusters().getCluster(cluster2).clone()
+ .brokerClientTlsFactoryClassName("default")
+ .build();
+ PulsarClient overrides = ns1.getReplicationClient(
+ BrokerTestUtil.newUniqueName(cluster2 + "-per-cluster"), Optional.of(clusterSelects));
+ ClientConfigurationData overrideConf = ((PulsarClientImpl) overrides).getConfiguration();
+ assertEquals(overrideConf.getTlsFactoryClassName(), "default",
+ "the cluster's brokerClientTlsFactoryClassName must select the factory on its own");
+ // The name reaching the config is not enough: PulsarService.maybeApplyBrokerClientTlsFactory has
+ // to act on it, or the selection is silently dropped and a custom BROKER_CLIENT-only factory is
+ // later resolved unwrapped — which answers CLIENT_DEFAULT with empty and fails the connection.
+ assertNotNull(overrideConf.getTlsFactory(),
+ "the per-cluster selection must actually build (and purpose-wrap) the factory");
+
+ config1.setBrokerClientTlsFactoryClassName("default");
+ ClusterData clusterSilent = admin1.clusters().getCluster(cluster2).clone()
+ .brokerClientTlsFactoryClassName("")
+ .build();
+ PulsarClient inherits = ns1.getReplicationClient(
+ BrokerTestUtil.newUniqueName(cluster2 + "-inherits"), Optional.of(clusterSilent));
+ assertEquals(((PulsarClientImpl) inherits).getConfiguration().getTlsFactoryClassName(), "default",
+ "a cluster that names no factory must inherit the broker-level one rather than silently "
+ + "reverting to the default factory");
+
+ // With both sides set, the cluster wins. Asserted on tlsFactoryConfig because it is an opaque
+ // string: two distinct values can be compared without either having to resolve to a real class.
+ String brokerConfig = config1.getBrokerClientTlsFactoryConfig();
+ try {
+ config1.setBrokerClientTlsFactoryConfig("scope=broker");
+ ClusterData bothSet = admin1.clusters().getCluster(cluster2).clone()
+ .brokerClientTlsFactoryClassName("default")
+ .brokerClientTlsFactoryConfig("scope=cluster")
+ .build();
+ PulsarClient wins = ns1.getReplicationClient(
+ BrokerTestUtil.newUniqueName(cluster2 + "-both-set"), Optional.of(bothSet));
+ assertEquals(((PulsarClientImpl) wins).getConfiguration().getTlsFactoryConfig(), "scope=cluster",
+ "the cluster's setting must take precedence over the broker-level one");
+ } finally {
+ config1.setBrokerClientTlsFactoryConfig(brokerConfig);
+ }
+ } finally {
+ config1.setBrokerClientTlsFactoryClassName(brokerLevel);
+ }
+ }
+
+ @Test
+ public void perClusterTlsFactoryAlsoDrivesTheCrossClusterAdminClient() throws Exception {
+ // A cluster entry drives two outbound legs. getClusterPulsarAdmin() is the second one, and it went
+ // through a different apply method that read the broker-level key only, so the cluster's selection
+ // never reached the admin client.
+ String brokerLevel = config1.getBrokerClientTlsFactoryClassName();
+ try {
+ config1.setBrokerClientTlsFactoryClassName("");
+ ClusterData clusterSelects = admin1.clusters().getCluster(cluster2).clone()
+ .brokerClientTlsFactoryClassName("default")
+ .build();
+ PulsarAdmin clusterAdmin = ns1.getClusterPulsarAdmin(
+ BrokerTestUtil.newUniqueName(cluster2 + "-admin"), Optional.of(clusterSelects));
+ ClientConfigurationData conf = ((PulsarAdminImpl) clusterAdmin).getClientConfigData();
+ assertEquals(conf.getTlsFactoryClassName(), "default",
+ "the cluster's factory selection must reach the admin client configuration");
+ assertNotNull(conf.getTlsFactory(),
+ "the per-cluster selection must build the admin client's factory too");
+ } finally {
+ config1.setBrokerClientTlsFactoryClassName(brokerLevel);
+ }
+ }
+
+ @Test
+ public void testReplicatesAcrossClustersOnNewTlsPath() throws Exception {
+ log.info("--- Starting ReplicatorTlsFactoryTest::testReplicatesAcrossClustersOnNewTlsPath ---");
+ // A real broker-to-broker TLS handshake on the new path: replication rides the BROKER_CLIENT
+ // connection, so producing on cluster1 and consuming the replicated messages on cluster2 / cluster3
+ // exercises the new-SPI TLS handshake end to end.
+ final TopicName dest = TopicName.get(
+ BrokerTestUtil.newUniqueName("persistent://pulsar/ns/tlsFactoryReplTopic"));
+
+ @Cleanup
+ MessageProducer producer1 = new MessageProducer(url1, dest);
+ @Cleanup
+ MessageConsumer consumer1 = new MessageConsumer(url1, dest);
+ @Cleanup
+ MessageConsumer consumer2 = new MessageConsumer(url2, dest);
+ @Cleanup
+ MessageConsumer consumer3 = new MessageConsumer(url3, dest);
+
+ // Local first (confirms the topic + starts the replicators to r2/r3 over the BROKER_CLIENT connection).
+ producer1.produce(2);
+ consumer1.receive(2);
+ // Replicated: broker-to-broker delivery over the new-SPI TLS connection.
+ consumer2.receive(2, 30);
+ consumer3.receive(2, 30);
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java
new file mode 100644
index 000000000000..0abd2280c3e6
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java
@@ -0,0 +1,281 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.broker.tls;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslContextBuilder;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
+import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: per-cluster broker-client TLS factory selection, exercised through the broker rather than through
+ * {@code ClientTlsFactorySupport} in isolation. Two things only a broker-level test can show:
+ *
+ * <ul>
+ * <li><b>{@code wrapBrokerClientPurpose} actually runs.</b> A custom broker-client factory resolves under
+ * {@code BROKER_CLIENT} by the PIP's stable-API contract, but the client transport asks for
+ * {@code CLIENT_DEFAULT}. Without the wrapper a compliant custom factory returns {@code empty()} and the
+ * connection fails. The recording factories below serve <em>only</em> {@code BROKER_CLIENT}, so they
+ * would fail in exactly that way — the previous tests all used {@code "default"}, which never reaches
+ * the wrapper.</li>
+ * <li><b>The class name / config pair resolves atomically.</b> A cluster that names a factory uses its own
+ * config even when blank, so factory A's parameters can never be handed to factory B; the broker-level
+ * config applies only when the cluster names no factory. This is where inverted logic would hide.</li>
+ * </ul>
+ *
+ * <p>The resolved factory is asked for {@code CLIENT_DEFAULT} directly — the same request
+ * {@code PulsarChannelInitializer} makes when a connection is established — so no peer cluster has to exist.
+ */
+@Test(groups = "broker")
+public class BrokerClientPerClusterTlsFactoryTest extends MockedPulsarServiceBaseTest {
+
+ private static final String BROKER_LEVEL_CONFIG = "owner=broker-level";
+ private static final String CLUSTER_LEVEL_CONFIG = "owner=cluster-level";
+
+ @BeforeMethod
+ @Override
+ protected void setup() throws Exception {
+ BrokerLevelTlsFactory.reset();
+ ClusterLevelTlsFactory.reset();
+ conf.setBrokerClientTlsEnabled(true);
+ conf.setBrokerClientTlsFactoryClassName(BrokerLevelTlsFactory.class.getName());
+ conf.setBrokerClientTlsFactoryConfig(BROKER_LEVEL_CONFIG);
+ internalSetup();
+ }
+
+ @AfterMethod(alwaysRun = true)
+ @Override
+ protected void cleanup() throws Exception {
+ internalCleanup();
+ BrokerLevelTlsFactory.reset();
+ ClusterLevelTlsFactory.reset();
+ }
+
+ @Test
+ public void theTransportsClientDefaultRequestReachesTheFactoryAsBrokerClient() throws Exception {
+ PulsarTlsFactory factory = replicationClientTlsFactory(clusterNaming(null, null));
+
+ // Exactly what PulsarChannelInitializer asks for on the client transport.
+ Optional<TlsHandle<SslContext>> handle =
+ factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS);
+
+ // Both this request and the client build's fail-fast probe go through the wrapper, so assert the
+ // invariant (every request arrives translated) rather than a request count.
+ assertThat(BrokerLevelTlsFactory.REQUESTED_PURPOSES)
+ .as("the wrapper must translate the transport's CLIENT_DEFAULT to BROKER_CLIENT before "
+ + "delegating; a compliant BROKER_CLIENT-only custom factory sees only BROKER_CLIENT")
+ .isNotEmpty()
+ .containsOnly(TlsPurpose.BROKER_CLIENT);
+ assertThat(handle).as("so a BROKER_CLIENT-only factory can serve the transport's request").isPresent();
+ }
+
+ @Test
+ public void aClusterNamingItsOwnFactoryWinsOverTheBrokerLevelOne() throws Exception {
+ PulsarTlsFactory factory = replicationClientTlsFactory(
+ clusterNaming(ClusterLevelTlsFactory.class.getName(), CLUSTER_LEVEL_CONFIG));
+
+ assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+ .isPresent();
+ assertThat(ClusterLevelTlsFactory.instantiated)
+ .as("the cluster's own factory is the one instantiated").isTrue();
+ assertThat(BrokerLevelTlsFactory.instantiated)
+ .as("the broker-level factory must not be used by a cluster that names its own").isFalse();
+ assertThat(ClusterLevelTlsFactory.initParams)
+ .as("and it receives its own cluster-level config")
+ .containsExactly(Map.entry("owner", "cluster-level"));
+ }
+
+ @Test
+ public void aClusterNamingAFactoryWithBlankConfigDoesNotInheritTheBrokerLevelConfig() throws Exception {
+ PulsarTlsFactory factory = replicationClientTlsFactory(
+ clusterNaming(ClusterLevelTlsFactory.class.getName(), ""));
+
+ assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+ .isPresent();
+ assertThat(ClusterLevelTlsFactory.initParams)
+ .as("the config follows the class name: naming a factory with a blank config means blank, never "
+ + "the broker-level config — otherwise factory A's parameters reach factory B")
+ .isEmpty();
+ }
+
+ @Test
+ public void aClusterNamingNoFactoryInheritsBothBrokerLevelValues() throws Exception {
+ PulsarTlsFactory factory = replicationClientTlsFactory(clusterNaming(null, null));
+
+ assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+ .isPresent();
+ assertThat(BrokerLevelTlsFactory.instantiated)
+ .as("the broker-level factory applies when the cluster names none").isTrue();
+ assertThat(ClusterLevelTlsFactory.instantiated).isFalse();
+ assertThat(BrokerLevelTlsFactory.initParams)
+ .as("and the broker-level config applies with it")
+ .containsExactly(Map.entry("owner", "broker-level"));
+ }
+
+ private ClusterData clusterNaming(String factoryClassName, String factoryConfig) {
+ return ClusterData.builder()
+ .serviceUrl("http://peer-cluster:8080")
+ .brokerServiceUrl("pulsar://peer-cluster:6650")
+ .serviceUrlTls("https://peer-cluster:8443")
+ .brokerServiceUrlTls("pulsar+ssl://peer-cluster:6651")
+ .brokerClientTlsEnabled(true)
+ .brokerClientTlsFactoryClassName(factoryClassName)
+ .brokerClientTlsFactoryConfig(factoryConfig)
+ .build();
+ }
+
+ /** The TLS factory the replication (binary) leg to a peer cluster ends up using. */
+ private PulsarTlsFactory replicationClientTlsFactory(ClusterData clusterData) {
+ PulsarClientImpl client = (PulsarClientImpl) pulsar.getBrokerService()
+ .getReplicationClient("peer-cluster", Optional.of(clusterData));
+ PulsarTlsFactory factory = client.getConfiguration().getTlsFactory();
+ assertThat(factory).as("an opted-in broker-client leg resolves a TLS factory").isNotNull();
+ return factory;
+ }
+
+ /**
+ * A custom broker-client factory that records the purposes it is asked for and the init params it was
+ * given. It serves <em>only</em> {@link TlsPurpose#BROKER_CLIENT} — the PIP-478 contract for a custom
+ * broker-client factory — so a missing purpose translation shows up as an empty result rather than as a
+ * silently passing test.
+ */
+ abstract static class RecordingTlsFactory implements PulsarTlsFactory {
+
+ abstract void recordPurpose(TlsPurpose purpose);
+
+ abstract void recordParams(Map<String, String> params);
+
+ @Override
+ public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+ recordParams(context.params());
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+ Class<T> instanceClass) {
+ recordPurpose(purpose);
+ if (!TlsPurpose.BROKER_CLIENT.equals(purpose) || instanceClass != SslContext.class) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ try {
+ SslContext context = SslContextBuilder.forClient().build();
+ TlsHandle<T> handle = new TlsHandle<>() {
+ @Override
+ @SuppressWarnings("unchecked")
+ public T get() {
+ return (T) context;
+ }
+
+ @Override
+ public void dispose() {
+ }
+ };
+ return CompletableFuture.completedFuture(Optional.of(handle));
+ } catch (Exception e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+ Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+ return createInstance(purpose, instanceClass).thenApply(opt -> {
+ opt.ifPresent(handle -> onLoadOrReload.accept(handle.get()));
+ return opt;
+ });
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ /** Selected by {@code ServiceConfiguration.brokerClientTlsFactoryClassName}. */
+ public static class BrokerLevelTlsFactory extends RecordingTlsFactory {
+
+ static final List<TlsPurpose> REQUESTED_PURPOSES = new CopyOnWriteArrayList<>();
+ static volatile Map<String, String> initParams = Map.of();
+ static volatile boolean instantiated;
+
+ static void reset() {
+ REQUESTED_PURPOSES.clear();
+ initParams = Map.of();
+ instantiated = false;
+ }
+
+ public BrokerLevelTlsFactory() {
+ instantiated = true;
+ }
+
+ @Override
+ void recordPurpose(TlsPurpose purpose) {
+ REQUESTED_PURPOSES.add(purpose);
+ }
+
+ @Override
+ void recordParams(Map<String, String> params) {
+ initParams = Map.copyOf(params);
+ }
+ }
+
+ /** Selected by {@code ClusterData.brokerClientTlsFactoryClassName}, so "which class won" is observable. */
+ public static class ClusterLevelTlsFactory extends RecordingTlsFactory {
+
+ static final List<TlsPurpose> REQUESTED_PURPOSES = new CopyOnWriteArrayList<>();
+ static volatile Map<String, String> initParams = Map.of();
+ static volatile boolean instantiated;
+
+ static void reset() {
+ REQUESTED_PURPOSES.clear();
+ initParams = Map.of();
+ instantiated = false;
+ }
+
+ public ClusterLevelTlsFactory() {
+ instantiated = true;
+ }
+
+ @Override
+ void recordPurpose(TlsPurpose purpose) {
+ REQUESTED_PURPOSES.add(purpose);
+ }
+
+ @Override
+ void recordParams(Map<String, String> params) {
+ initParams = Map.copyOf(params);
+ }
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java
new file mode 100644
index 000000000000..cc3aba86c24a
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.broker.tls;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.pulsar.common.tls.impl.FileBasedTlsFactory;
+import org.apache.pulsar.common.tls.impl.FileBasedTlsFactorySettings;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsEndpoint;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+
+/**
+ * A PIP-478 test factory selected via {@code tlsFactoryClassName} that counts every {@code createInstance}
+ * call so a test can prove the new TLS SPI was actually exercised for the WEB (HTTPS) purpose — the Jetty web
+ * path requests the JDK {@code javax.net.ssl.SSLContext} and {@code SSLParameters}, so unlike
+ * {@code SslContextOnlyTlsFactory} (which counts only refused Netty {@code SslContext} requests) this counts
+ * the JDK-context path. Material loading is delegated to a real {@link FileBasedTlsFactory} built from the
+ * {@code trust}/{@code cert}/{@code key}/{@code insecure} factory-config params, so the served context is a
+ * genuine, usable WEB context.
+ *
+ * <p>Reflectively instantiable via the public no-arg constructor.
+ */
+public class CountingWebTlsFactory implements PulsarTlsFactory {
+
+ private static final AtomicInteger CREATE_INSTANCE_CALLS = new AtomicInteger();
+
+ private volatile FileBasedTlsFactory delegate;
+
+ public CountingWebTlsFactory() {
+ }
+
+ /** Total number of {@code createInstance} calls served (across all instances). */
+ public static int createInstanceCount() {
+ return CREATE_INSTANCE_CALLS.get();
+ }
+
+ public static void reset() {
+ CREATE_INSTANCE_CALLS.set(0);
+ }
+
+ @Override
+ public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+ Map<String, String> params = context.params();
+ TlsPolicy policy = TlsPolicy.builder()
+ .format(TlsPolicy.Format.PEM)
+ .trustCertsFilePath(StringUtils.trimToNull(params.get("trust")))
+ .certificateFilePath(StringUtils.trimToNull(params.get("cert")))
+ .keyFilePath(StringUtils.trimToNull(params.get("key")))
+ .allowInsecureConnection(Boolean.parseBoolean(params.getOrDefault("insecure", "false")))
+ .enableHostnameVerification(false)
+ .build();
+ this.delegate = new FileBasedTlsFactory(Map.of(TlsPurpose.WEB, policy),
+ FileBasedTlsFactorySettings.builder().build());
+ return delegate.initialize(context);
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose, Class<T> instanceClass) {
+ CREATE_INSTANCE_CALLS.incrementAndGet();
+ return delegate.createInstance(purpose, instanceClass);
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(
+ TlsPurpose purpose, TlsEndpoint endpoint, Class<T> instanceClass) {
+ CREATE_INSTANCE_CALLS.incrementAndGet();
+ return delegate.createInstance(purpose, endpoint, instanceClass);
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(
+ TlsPurpose purpose, Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+ CREATE_INSTANCE_CALLS.incrementAndGet();
+ return delegate.createInstance(purpose, instanceClass, onLoadOrReload);
+ }
+
+ @Override
+ public void close() {
+ if (delegate != null) {
+ delegate.close();
+ }
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
index adf9132f9a94..1aa82ab5464a 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
@@ -73,7 +73,7 @@ import org.apache.pulsar.common.policies.data.ClusterData;
import org.apache.pulsar.common.policies.data.ClusterDataImpl;
import org.apache.pulsar.common.policies.data.TenantInfo;
import org.apache.pulsar.common.util.ObjectMapperFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.PemReader;
import org.apache.pulsar.utils.ResourceUtils;
import org.asynchttpclient.AsyncHttpClient;
import org.asynchttpclient.BoundRequestBuilder;
@@ -471,8 +471,8 @@ public class WebServiceTest {
if (useTls) {
KeyManager[] keyManagers = null;
if (useAuth) {
- Certificate[] tlsCert = SecurityUtility.loadCertificatesFromPemFile(CLIENT_CERT_FILE_PATH);
- PrivateKey tlsKey = SecurityUtility.loadPrivateKeyFromPemFile(CLIENT_KEY_FILE_PATH);
+ Certificate[] tlsCert = PemReader.loadCertificatesFromPemFile(CLIENT_CERT_FILE_PATH);
+ PrivateKey tlsKey = PemReader.loadPrivateKeyFromPemFile(CLIENT_KEY_FILE_PATH);
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
ks.load(null, null);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
index c91c2fec9763..82db1c79c17e 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
@@ -18,7 +18,6 @@
*/
package org.apache.pulsar.client.api;
-import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
@@ -29,8 +28,6 @@ import lombok.CustomLog;
import org.apache.pulsar.broker.authentication.AuthenticationProviderBasic;
import org.apache.pulsar.broker.authentication.AuthenticationProviderTls;
import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.common.tls.PublicSuffixMatcher;
-import org.apache.pulsar.common.tls.TlsHostnameVerifier;
import org.assertj.core.util.Sets;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
@@ -113,9 +110,7 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
@AfterMethod(alwaysRun = true)
@Override
protected void cleanup() throws Exception {
- if (!methodName.equals("testDefaultHostVerifier")) {
- super.internalCleanup();
- }
+ super.internalCleanup();
}
@DataProvider(name = "hostnameVerification")
@@ -128,8 +123,10 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
*
* <pre>
* 1. Client tries to connect to broker with hostname="localhost"
- * 2. Broker sends x509 certificates with CN = "pulsar"
- * 3. Client verifies the host-name and closes the connection and fails consumer creation
+ * 2. Broker sends an x509 certificate whose SubjectAltName does not match "localhost"
+ * (in fact it has no SAN at all, only CN=broker.pulsar.apache.org)
+ * 3. Client performs SAN-based (RFC 2818) host-name verification, closes the connection
+ * and fails consumer creation
* </pre>
*
* @throws Exception
@@ -142,7 +139,13 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
this.hostnameVerificationEnabled = hostnameVerificationEnabled;
clientTrustCertFilePath = TLS_MIM_TRUST_CERT_FILE_PATH;
- // setup broker cert which has CN = "pulsar" different than broker's hostname="localhost"
+ // setup broker cert which has no SAN matching the broker's hostname="localhost"
+ // (CN=broker.pulsar.apache.org, no subjectAltName): CN-based matching is no longer supported (PIP-478).
+ // The broker itself uses this deliberately-mismatched cert, so its own internal client (e.g. the
+ // system-topic reader for topic policies) cannot verify "localhost" against it; disable hostname
+ // verification for the broker's outbound client here. This test exercises the end-user client's
+ // hostname verification only (toggled via enableTlsHostnameVerification below).
+ conf.setTlsHostnameVerificationEnabled(false);
conf.setBrokerServicePortTls(Optional.of(0));
conf.setWebServicePortTls(Optional.of(0));
conf.setAuthenticationProviders(Sets.newTreeSet(AuthenticationProviderTls.class.getName()));
@@ -175,8 +178,8 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
*
* <pre>
* 1. Client tries to connect to broker with hostname="localhost"
- * 2. Broker sends x509 certificates with CN = "localhost"
- * 3. Client verifies the host-name and continues
+ * 2. Broker sends an x509 certificate whose SubjectAltName includes DNS:localhost
+ * 3. Client performs SAN-based (RFC 2818) host-name verification and continues
* </pre>
*
* @throws Exception
@@ -185,7 +188,7 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
public void testTlsSyncProducerAndConsumerCorrectBrokerHost() throws Exception {
log.info().attr("starting", methodName).log("-- Starting test");
cleanup();
- // setup broker cert which has CN = "localhost"
+ // setup broker cert whose subjectAltName includes DNS:localhost
conf.setBrokerServicePortTls(Optional.of(0));
conf.setWebServicePortTls(Optional.of(0));
conf.setAuthenticationProviders(Sets.newTreeSet(AuthenticationProviderTls.class.getName()));
@@ -221,24 +224,4 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
log.info().attr("exiting", methodName).log("-- Exiting test");
}
- /**
- * This test verifies {@link TlsHostnameVerifier} behavior and gives fair idea about host matching result.
- *
- * @throws Exception
- */
- @Test
- public void testDefaultHostVerifier() throws Exception {
- log.info().attr("starting", methodName).log("-- Starting test");
- Method matchIdentityStrict = TlsHostnameVerifier.class.getDeclaredMethod("matchIdentityStrict",
- String.class, String.class, PublicSuffixMatcher.class);
- matchIdentityStrict.setAccessible(true);
- Assert.assertTrue((boolean) matchIdentityStrict.invoke(null, "pulsar", "pulsar", null));
- Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar.com", "pulsar", null));
- Assert.assertTrue((boolean) matchIdentityStrict.invoke(null, "pulsar-broker1.com", "pulsar*.com", null));
- // unmatched remainder: "1-broker." should not contain "."
- Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar-broker1.com", "pulsar*com", null));
- Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar.com", "*", null));
- log.info().attr("exiting", methodName).log("-- Exiting test");
- }
-
}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
index 997691921edd..6a07d33e6001 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
@@ -95,7 +95,7 @@ import org.apache.pulsar.common.policies.data.ClusterData;
import org.apache.pulsar.common.policies.data.PoliciesUtil;
import org.apache.pulsar.common.policies.data.TenantInfoImpl;
import org.apache.pulsar.common.util.ObjectMapperFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
import org.apache.pulsar.policies.data.loadbalancer.LocalBrokerData;
import org.apache.pulsar.policies.data.loadbalancer.NamespaceBundleStats;
import org.apache.zookeeper.KeeperException;
@@ -620,7 +620,7 @@ public class BrokerServiceLookupTest extends ProducerConsumerBase implements ITe
final String lookupResourceUrl = "/lookup/v2/topic/persistent/my-property/my-ns/my-topic1";
// set client cert_key file
- SSLContext sslCtx = SecurityUtility.createSslContext(false, CA_CERT_FILE_PATH,
+ SSLContext sslCtx = JdkSslContexts.createSslContext(false, CA_CERT_FILE_PATH,
getTlsFileForClient("admin.cert"), getTlsFileForClient("admin.key-pk8"), "");
HttpsURLConnection.setDefaultSSLSocketFactory(sslCtx.getSocketFactory());
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
index edb58abfdf22..ec90d3827821 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
@@ -44,9 +44,13 @@ public class ProxyProtocolTest extends TlsProducerConsumerBase {
@Test
public void testSniProxyProtocol() throws Exception {
- // Client should try to connect to proxy and pass broker-url as SNI header
+ // Client should try to connect to proxy and pass broker-url as SNI header.
+ // The broker service URL host must be present in the broker certificate's SAN so that
+ // (now default-on) hostname verification passes: broker.cert.pem carries
+ // DNS:unresolvable-broker-address. The host is never resolved because SNI routing sends
+ // the connection to the proxy; it only needs to match the presented certificate.
String proxyUrl = pulsar.getBrokerServiceUrlTls();
- String brokerServiceUrl = "pulsar+ssl://unresolvable-address:6651";
+ String brokerServiceUrl = "pulsar+ssl://unresolvable-broker-address:6651";
String topicName = "persistent://my-property/my-ns/my-topic1";
ClientBuilder clientBuilder = PulsarClient.builder().serviceUrl(brokerServiceUrl)
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java
new file mode 100644
index 000000000000..7a59002ff874
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.concurrent.TimeUnit;
+import lombok.Cleanup;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 2b: verifies the broker serves TLS end-to-end over the new {@code PulsarTlsFactory} path
+ * (selected by {@code tlsFactoryClassName=default}, which wires the built-in {@code DefaultBrokerTlsFactory})
+ * for both the binary listener (purpose BROKER) and the web server (purpose WEB), including mTLS client-auth
+ * in REQUIRE mode. The legacy PIP-337 path is exercised by {@link TlsProducerConsumerTest}.
+ */
+@Test(groups = "broker-api")
+public class TlsFactoryProducerConsumerTest extends TlsProducerConsumerBase {
+
+ @Override
+ protected void internalSetUpForBroker() {
+ super.internalSetUpForBroker();
+ // Select the new PIP-478 TLS SPI (built-in DefaultBrokerTlsFactory) for BROKER/PROXY/WEB purposes.
+ conf.setTlsFactoryClassName("default");
+ }
+
+ @Test(timeOut = 60000)
+ public void testTlsFactoryProduceConsumeOverBinaryAndHttps() throws Exception {
+ // Admin over HTTPS exercises the web server on the new WEB-purpose path.
+ internalSetUpForNamespace();
+
+ // Producer/consumer over binary TLS with mTLS client-auth exercises the BROKER-purpose path.
+ internalSetUpForClient(true, pulsar.getBrokerServiceUrlTls());
+ final String topic = "persistent://my-property/my-ns/tls-factory-topic";
+
+ @Cleanup
+ Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+ .subscriptionName("tls-factory-sub").subscribe();
+
+ @Cleanup
+ Producer<byte[]> producer = pulsarClient.newProducer().topic(topic).create();
+
+ final int numMessages = 10;
+ for (int i = 0; i < numMessages; i++) {
+ producer.send(("msg-" + i).getBytes());
+ }
+
+ for (int i = 0; i < numMessages; i++) {
+ Message<byte[]> msg = consumer.receive(5, TimeUnit.SECONDS);
+ assertThat(msg).isNotNull();
+ assertThat(new String(msg.getData())).isEqualTo("msg-" + i);
+ consumer.acknowledge(msg);
+ }
+ }
+
+ @Test(timeOut = 60000)
+ public void testTlsFactoryBinaryProtocolRequiresClientCert() throws Exception {
+ internalSetUpForNamespace();
+ final String topic = "persistent://my-property/my-ns/tls-factory-noauth-topic";
+
+ // Without client certs, the REQUIRE-mode broker must fail the TLS handshake.
+ internalSetUpForClient(false, pulsar.getBrokerServiceUrlTls());
+ assertThat(catchSubscribeFailure(topic)).isTrue();
+
+ // With client certs, the connection succeeds.
+ internalSetUpForClient(true, pulsar.getBrokerServiceUrlTls());
+ @Cleanup
+ Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+ .subscriptionName("tls-factory-sub").subscribe();
+ assertThat(consumer.isConnected()).isTrue();
+ }
+
+ private boolean catchSubscribeFailure(String topic) {
+ try (Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+ .subscriptionName("tls-factory-sub").subscriptionType(SubscriptionType.Exclusive).subscribe()) {
+ return false;
+ } catch (Exception ex) {
+ return true;
+ }
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java
new file mode 100644
index 000000000000..9842282a24ba
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.time.Duration;
+import lombok.Cleanup;
+import org.apache.pulsar.client.api.TlsProducerConsumerBase;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationFactory;
+import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.testng.annotations.Test;
+
+/**
+ * End-to-end mTLS produce/consume over the v5 client's new PIP-478 TLS path (stage 3b): a v5
+ * {@link PulsarClient} configured with {@code tlsPolicy(...)} against a TLS broker that requires a trusted
+ * client certificate. The binary transport builds its per-connection {@code SslContext} through the
+ * client-side {@code FileBasedTlsFactory} — the only client TLS path since the PIP-337 removal (stage 4c).
+ */
+@Test(groups = "broker-api")
+public class V5TlsProducerConsumerTest extends TlsProducerConsumerBase {
+
+ private static final String TOPIC = "persistent://my-property/my-ns/v5-tls-topic";
+
+ /** mTLS material supplied directly through {@link PulsarClientBuilder#tlsPolicy(TlsPolicy)}. */
+ @Test
+ public void testV5MtlsViaTlsPolicy() throws Exception {
+ internalSetUpForNamespace();
+
+ TlsPolicy policy = TlsPolicy.builder()
+ .format(TlsPolicy.Format.PEM)
+ .trustCertsFilePath(CA_CERT_FILE_PATH)
+ .certificateFilePath(getTlsFileForClient("admin.cert"))
+ .keyFilePath(getTlsFileForClient("admin.key-pk8"))
+ // The shared test broker cert is not guaranteed to carry a SAN for the advertised host, so
+ // (as the v4 TLS suites do) hostname verification is left off; the new path bakes trust and
+ // the client certificate into the factory-built context.
+ .enableHostnameVerification(false)
+ .build();
+
+ @Cleanup
+ PulsarClient client = PulsarClient.builder()
+ .serviceUrl(pulsar.getBrokerServiceUrlTls())
+ .tlsPolicy(policy)
+ .build();
+
+ assertProduceConsume(client, TOPIC + "-policy", "v5-mtls-via-policy");
+ }
+
+ /**
+ * mTLS material (trust + client certificate/key) supplied through {@code tlsPolicy(...)} and the auth
+ * method advertised through the built-in no-arg {@link AuthenticationFactory#tls()} marker plugin,
+ * which carries no material itself — the broker authenticates from the certificate presented during the
+ * TLS handshake, sourced from the client TLS factory.
+ */
+ @Test
+ public void testV5MtlsViaTlsPolicyWithTlsAuthMarker() throws Exception {
+ internalSetUpForNamespace();
+
+ TlsPolicy policy = TlsPolicy.builder()
+ .format(TlsPolicy.Format.PEM)
+ .trustCertsFilePath(CA_CERT_FILE_PATH)
+ .certificateFilePath(getTlsFileForClient("admin.cert"))
+ .keyFilePath(getTlsFileForClient("admin.key-pk8"))
+ // The shared test broker cert is not guaranteed to carry a SAN for the advertised host, so
+ // (as the v4 TLS suites do) hostname verification is left off.
+ .enableHostnameVerification(false)
+ .build();
+
+ @Cleanup
+ PulsarClient client = PulsarClient.builder()
+ .serviceUrl(pulsar.getBrokerServiceUrlTls())
+ .tlsPolicy(policy)
+ .authentication(AuthenticationFactory.tls())
+ .build();
+
+ assertProduceConsume(client, TOPIC + "-marker", "v5-mtls-via-marker");
+ }
+
+ private static void assertProduceConsume(PulsarClient client, String topic, String payload)
+ throws Exception {
+ @Cleanup
+ Producer<String> producer = client.newProducer(Schema.string())
+ .topic(topic)
+ .create();
+
+ @Cleanup
+ QueueConsumer<String> consumer = client.newQueueConsumer(Schema.string())
+ .topic(topic)
+ .subscriptionName("v5-tls-sub")
+ .subscribe();
+
+ producer.newMessage().value(payload).send();
+
+ Message<String> received = consumer.receive(Duration.ofSeconds(10));
+ assertThat(received).isNotNull();
+ assertThat(received.value()).isEqualTo(payload);
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
index 546e2751ab61..e4c170a627d6 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
@@ -27,6 +27,10 @@ import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.GenericType;
import jakarta.ws.rs.core.MediaType;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.KeyStore;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -35,7 +39,9 @@ import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import javax.crypto.SecretKey;
+import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
import lombok.Cleanup;
import lombok.CustomLog;
import org.apache.pulsar.broker.authentication.AuthenticationProviderTls;
@@ -50,7 +56,6 @@ import org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls;
import org.apache.pulsar.client.impl.auth.AuthenticationToken;
import org.apache.pulsar.common.policies.data.ClusterData;
import org.apache.pulsar.common.policies.data.TenantInfoImpl;
-import org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.jackson.JacksonFeature;
@@ -139,7 +144,7 @@ public class AdminApiKeyStoreTlsAuthTest extends ProducerConsumerBase {
ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(httpConfig)
.register(JacksonConfigurator.class).register(JacksonFeature.class);
- SSLContext sslCtx = KeyStoreSSLContext.createClientSslContext(
+ SSLContext sslCtx = createJdkClientSslContext(
KEYSTORE_TYPE,
PROXY_KEYSTORE_FILE_PATH,
PROXY_KEYSTORE_PW,
@@ -153,6 +158,30 @@ public class AdminApiKeyStoreTlsAuthTest extends ProducerConsumerBase {
return client.target(brokerUrlTls.toString());
}
+ // PIP-478 stage 4c: builds the Jersey test client's mutual-TLS SSLContext straight from the JDK KeyStore /
+ // SSLContext APIs (replacing the removed KeyStoreSSLContext.createClientSslContext helper).
+ private static SSLContext createJdkClientSslContext(String keyStoreType, String keyStorePath,
+ String keyStorePassword, String trustStoreType, String trustStorePath, String trustStorePassword)
+ throws Exception {
+ KeyStore keyStore = KeyStore.getInstance(keyStoreType);
+ try (InputStream in = Files.newInputStream(Paths.get(keyStorePath))) {
+ keyStore.load(in, keyStorePassword.toCharArray());
+ }
+ KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ kmf.init(keyStore, keyStorePassword.toCharArray());
+
+ KeyStore trustStore = KeyStore.getInstance(trustStoreType);
+ try (InputStream in = Files.newInputStream(Paths.get(trustStorePath))) {
+ trustStore.load(in, trustStorePassword.toCharArray());
+ }
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
+ return sslContext;
+ }
+
PulsarAdmin buildAdminClient() throws Exception {
Map<String, String> authParams = new HashMap<>();
authParams.put(AuthenticationKeyStoreTls.KEYSTORE_PATH, CLIENT_KEYSTORE_FILE_PATH);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java
new file mode 100644
index 000000000000..20954c02cd5a
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.Set;
+import lombok.Cleanup;
+import org.apache.pulsar.broker.auth.MockOIDCIdentityProvider;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.api.Authentication;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationFactoryOAuth2;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: admin-only OAuth2. A {@link PulsarAdmin} built without a {@link PulsarClient} must
+ * acquire its OAuth2 token through the framework HTTP client bound by {@code PulsarAdminImpl} (mirroring
+ * {@code PulsarClientImpl}), not the removed private OAuth2 {@code AsyncHttpClient}. Building the admin runs
+ * {@code auth.start()}, which resolves the IdP metadata, and the first {@code getAuthData()} fetches the
+ * token — both over the framework client. If the binding were missing, {@code getHttpClient()} would throw
+ * "OAuth2 requires the authentication to be initialized by a PulsarClient/PulsarAdmin".
+ */
+@Test(groups = "broker-impl")
+public class AdminOnlyOAuth2AuthTest {
+
+ private static final String CREDENTIALS_FILE =
+ "./src/test/resources/authentication/token/credentials_file.json";
+ private static final String AUDIENCE = "my-pulsar-cluster";
+
+ private MockOIDCIdentityProvider server;
+
+ @BeforeMethod(alwaysRun = true)
+ public void setUp() {
+ server = new MockOIDCIdentityProvider("super-secret-client-secret", AUDIENCE, 30000);
+ }
+
+ @AfterMethod(alwaysRun = true)
+ public void tearDown() {
+ if (server != null) {
+ server.stop();
+ server = null;
+ }
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ public void adminOnlyOAuth2AcquiresTokenViaFrameworkClient() throws Exception {
+ Path credentials = Paths.get(CREDENTIALS_FILE).toAbsolutePath();
+ Authentication auth = AuthenticationFactoryOAuth2.clientCredentials(
+ new URL(server.getIssuer()), credentials.toUri().toURL(), AUDIENCE);
+
+ // Building the admin runs auth.start() (IdP metadata resolution); with no PulsarClient in play, that
+ // must go over the framework HTTP client bound by PulsarAdminImpl. A missing binding would throw
+ // here. The dummy service URL is never contacted during token acquisition.
+ @Cleanup
+ PulsarAdmin admin = PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:65535")
+ .authentication(auth)
+ .build();
+
+ // The first getAuthData() fetches the token over the same framework client; a Bearer token surfaces
+ // through the auth data's HTTP headers.
+ AuthenticationDataProvider data = auth.getAuthData();
+ assertThat(data.hasDataForHttp()).isTrue();
+ Set<Map.Entry<String, String>> headers = data.getHttpHeaders();
+ assertThat(headers).anyMatch(h -> "Authorization".equals(h.getKey())
+ && h.getValue() != null && h.getValue().startsWith("Bearer "));
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java
deleted file mode 100644
index 6edcf0d60cdc..000000000000
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.client.impl;
-
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_KEYSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_KEYSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_TRUSTSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_TRUSTSTORE_NO_PASSWORD_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_KEYSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_KEYSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_NO_PASSWORD_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.KEYSTORE_TYPE;
-import java.util.Collections;
-import org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext;
-import org.apache.pulsar.common.util.keystoretls.SSLContextValidatorEngine;
-import org.testng.annotations.Test;
-
-@Test(groups = "broker-impl")
-public class KeyStoreTlsTest {
-
- @Test(timeOut = 300000)
- public void testValidate() throws Exception {
- KeyStoreSSLContext serverSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.SERVER,
- null,
- KEYSTORE_TYPE,
- BROKER_KEYSTORE_FILE_PATH,
- BROKER_KEYSTORE_PW,
- false,
- KEYSTORE_TYPE,
- CLIENT_TRUSTSTORE_FILE_PATH,
- CLIENT_TRUSTSTORE_PW,
- true,
- null,
- null);
- serverSSLContext.createSSLContext();
-
- KeyStoreSSLContext clientSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.CLIENT,
- null,
- KEYSTORE_TYPE,
- CLIENT_KEYSTORE_FILE_PATH,
- CLIENT_KEYSTORE_PW,
- false,
- KEYSTORE_TYPE,
- BROKER_TRUSTSTORE_FILE_PATH,
- BROKER_KEYSTORE_PW,
- false,
- null,
- // set client's protocol to TLSv1.2 since SSLContextValidatorEngine.validate doesn't handle TLSv1.3
- Collections.singleton("TLSv1.2"));
- clientSSLContext.createSSLContext();
-
- SSLContextValidatorEngine.validate(clientSSLContext::createSSLEngine, serverSSLContext::createSSLEngine);
- }
-
- @Test(timeOut = 300000)
- public void testValidateKeyStoreNoPwd() throws Exception {
- KeyStoreSSLContext serverSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.SERVER,
- null,
- KEYSTORE_TYPE,
- BROKER_KEYSTORE_FILE_PATH,
- BROKER_KEYSTORE_PW,
- false,
- KEYSTORE_TYPE,
- CLIENT_TRUSTSTORE_NO_PASSWORD_FILE_PATH,
- null,
- true,
- null,
- null);
- serverSSLContext.createSSLContext();
-
- KeyStoreSSLContext clientSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.CLIENT,
- null,
- KEYSTORE_TYPE,
- CLIENT_KEYSTORE_FILE_PATH,
- CLIENT_KEYSTORE_PW,
- false,
- KEYSTORE_TYPE,
- BROKER_TRUSTSTORE_NO_PASSWORD_FILE_PATH,
- null,
- false,
- null,
- // set client's protocol to TLSv1.2 since SSLContextValidatorEngine.validate doesn't handle TLSv1.3
- Collections.singleton("TLSv1.2"));
- clientSSLContext.createSSLContext();
-
- SSLContextValidatorEngine.validate(clientSSLContext::createSSLEngine, serverSSLContext::createSSLEngine);
- }
-}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java
new file mode 100644
index 000000000000..5efc193d8f54
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.functions.worker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import org.apache.pulsar.broker.authentication.AuthenticationService;
+import org.apache.pulsar.broker.tls.CountingWebTlsFactory;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.functions.worker.rest.WorkerServer;
+import org.apache.pulsar.utils.ResourceUtils;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 (T5): the functions-worker {@link WorkerServer} serves its WEB (HTTPS) listener through a custom
+ * {@link org.apache.pulsar.tls.PulsarTlsFactory} selected via {@code tlsFactoryClassName}. A counting
+ * factory proves the new SPI path is exercised (its {@code createInstance} is invoked while building the WEB
+ * SslContextFactory) and a successful HTTPS request against the unauthenticated {@code /version} endpoint
+ * proves the synthesized web TLS listener actually works. The {@link WorkerService} is mocked, so no broker /
+ * bookkeeper is started — the worker's Jetty web server alone.
+ */
+public class WorkerServerTlsFactoryTest {
+
+ private static final String CA = ResourceUtils.getAbsolutePath("certificate-authority/certs/ca.cert.pem");
+ private static final String CERT =
+ ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.cert.pem");
+ private static final String KEY =
+ ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.key-pk8.pem");
+
+ @Test
+ public void webListenerServedThroughCustomTlsFactory() throws Exception {
+ int before = CountingWebTlsFactory.createInstanceCount();
+
+ WorkerConfig workerConfig = new WorkerConfig();
+ workerConfig.setWorkerHostname("localhost");
+ workerConfig.setWorkerPort(null);
+ workerConfig.setWorkerPortTls(0);
+ workerConfig.setTlsEnabled(true);
+ workerConfig.setTlsCertificateFilePath(CERT);
+ workerConfig.setTlsKeyFilePath(KEY);
+ workerConfig.setTlsTrustCertsFilePath(CA);
+ workerConfig.setTlsFactoryClassName(CountingWebTlsFactory.class.getName());
+ workerConfig.setTlsFactoryConfig("trust=" + CA + ",cert=" + CERT + ",key=" + KEY);
+
+ WorkerService workerService = mock(WorkerService.class);
+ when(workerService.getWorkerConfig()).thenReturn(workerConfig);
+ AuthenticationService authenticationService = mock(AuthenticationService.class);
+
+ WorkerServer workerServer = new WorkerServer(workerService, authenticationService);
+ workerServer.start();
+ try {
+ int port = workerServer.getListenPortHTTPS().orElseThrow();
+ ContentResponse response = httpsGet("https://localhost:" + port + "/version");
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getContentAsString()).contains("version");
+ assertThat(CountingWebTlsFactory.createInstanceCount())
+ .as("the WEB purpose was served through the custom tlsFactoryClassName")
+ .isGreaterThan(before);
+ } finally {
+ workerServer.stop();
+ }
+ }
+
+ private static ContentResponse httpsGet(String url) throws Exception {
+ SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+ sslContextFactory.setSslContext(JdkSslContexts.createSslContext(
+ false, PemReader.loadCertificatesFromPemFile(CA), null));
+ HttpClient httpClient = new HttpClient();
+ httpClient.setSslContextFactory(sslContextFactory);
+ httpClient.start();
+ try {
+ return httpClient.newRequest(url).send();
+ } finally {
+ httpClient.stop();
+ }
+ }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
index e487735d5bab..cc4a83596363 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
@@ -31,7 +31,8 @@ import lombok.Cleanup;
import lombok.CustomLog;
import org.apache.pulsar.client.api.TlsProducerConsumerBase;
import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
import org.apache.pulsar.metadata.impl.ZKMetadataStore;
import org.apache.pulsar.websocket.WebSocketService;
import org.apache.pulsar.websocket.service.ProxyServer;
@@ -106,8 +107,8 @@ public class ProxyPublishConsumeTlsTest extends TlsProducerConsumerBase {
URI produceUri = URI.create(producerUri);
SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
- sslContextFactory.setSslContext(SecurityUtility
- .createSslContext(false, SecurityUtility.loadCertificatesFromPemFile(CA_CERT_FILE_PATH), null));
+ sslContextFactory.setSslContext(JdkSslContexts
+ .createSslContext(false, PemReader.loadCertificatesFromPemFile(CA_CERT_FILE_PATH), null));
@Cleanup("stop")
HttpClient httpClient = new HttpClient();
httpClient.setSslContextFactory(sslContextFactory);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java
new file mode 100644
index 000000000000..12869cc9f711
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.websocket.proxy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.Optional;
+import org.apache.pulsar.broker.tls.CountingWebTlsFactory;
+import org.apache.pulsar.common.configuration.VipStatus;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.utils.ResourceUtils;
+import org.apache.pulsar.websocket.service.ProxyServer;
+import org.apache.pulsar.websocket.service.WebSocketProxyConfiguration;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 (T5): the standalone WebSocket proxy serves its WEB (HTTPS) listener through a custom
+ * {@link org.apache.pulsar.tls.PulsarTlsFactory} selected via {@code tlsFactoryClassName}. A counting
+ * factory proves the new SPI path is exercised (its {@code createInstance} is invoked while building the WEB
+ * SslContextFactory) and a successful HTTPS request against the {@code /status.html} endpoint proves the
+ * synthesized web TLS listener actually works. No broker is started — the proxy's Jetty web server alone.
+ */
+@Test(groups = "websocket")
+public class WebSocketProxyTlsFactoryTest {
+
+ private static final String CA = ResourceUtils.getAbsolutePath("certificate-authority/certs/ca.cert.pem");
+ private static final String CERT =
+ ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.cert.pem");
+ private static final String KEY =
+ ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.key-pk8.pem");
+
+ @Test
+ public void webListenerServedThroughCustomTlsFactory() throws Exception {
+ int before = CountingWebTlsFactory.createInstanceCount();
+
+ WebSocketProxyConfiguration config = new WebSocketProxyConfiguration();
+ config.setClusterName("test");
+ config.setWebServicePort(Optional.empty());
+ config.setWebServicePortTls(Optional.of(0));
+ config.setTlsFactoryClassName(CountingWebTlsFactory.class.getName());
+ config.setTlsFactoryConfig("trust=" + CA + ",cert=" + CERT + ",key=" + KEY);
+ config.setStatusFilePath(CA);
+
+ ProxyServer proxyServer = new ProxyServer(config);
+ proxyServer.addRestResource("/", VipStatus.ATTRIBUTE_STATUS_FILE_PATH, CA, VipStatus.class);
+ proxyServer.start();
+ try {
+ int port = proxyServer.getListenPortHTTPS().orElseThrow();
+ ContentResponse response = httpsGet("https://localhost:" + port + "/status.html");
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getContentAsString()).isEqualTo("OK");
+ assertThat(CountingWebTlsFactory.createInstanceCount())
+ .as("the WEB purpose was served through the custom tlsFactoryClassName")
+ .isGreaterThan(before);
+ } finally {
+ proxyServer.stop();
+ }
+ }
+
+ private static ContentResponse httpsGet(String url) throws Exception {
+ SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+ sslContextFactory.setSslContext(JdkSslContexts.createSslContext(
+ false, PemReader.loadCertificatesFromPemFile(CA), null));
+ HttpClient httpClient = new HttpClient();
+ httpClient.setSslContextFactory(sslContextFactory);
+ httpClient.start();
+ try {
+ return httpClient.newRequest(url).send();
+ } finally {
+ httpClient.stop();
+ }
+ }
+}
diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
index 8332952bef72..a9fb95a12111 100644
--- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
+++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
@@ -293,18 +293,25 @@ public interface PulsarAdminBuilder {
PulsarAdminBuilder tlsProtocols(Set<String> tlsProtocols);
/**
- * SSL Factory Plugin used to generate the SSL Context and SSLEngine.
- * @param sslFactoryPlugin Name of the SSL Factory Class to be used.
- * @return PulsarAdminBuilder
+ * Set the class name of a custom {@code PulsarTlsFactory} (PIP-478) used to build the admin client's TLS
+ * engines. An empty value or the literal {@code "default"} selects the built-in file-based factory
+ * composed from the {@code tls*} settings; any other value is instantiated reflectively via its public
+ * no-arg constructor. This is the by-name successor of the removed PIP-337 {@code sslFactoryPlugin}.
+ *
+ * @param tlsFactoryClassName the {@code PulsarTlsFactory} class name (blank/{@code default} for built-in)
+ * @return the admin builder instance
*/
- PulsarAdminBuilder sslFactoryPlugin(String sslFactoryPlugin);
+ PulsarAdminBuilder tlsFactoryClassName(String tlsFactoryClassName);
/**
- * Parameters used by the SSL Factory Plugin class.
- * @param sslFactoryPluginParams String parameters to be used by the SSL Factory Class.
- * @return
+ * Set the configuration parameters passed to a custom {@link #tlsFactoryClassName(String)} as its init
+ * params (PIP-478). Accepts a JSON object or a comma-separated {@code key=value} list; ignored by the
+ * built-in file-based factory.
+ *
+ * @param tlsFactoryConfig the factory configuration (JSON object or {@code key=value} list)
+ * @return the admin builder instance
*/
- PulsarAdminBuilder sslFactoryPluginParams(String sslFactoryPluginParams);
+ PulsarAdminBuilder tlsFactoryConfig(String tlsFactoryConfig);
/**
* This sets the connection time out for the pulsar admin client.
diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
index 6aeed746db42..65571526f385 100644
--- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
+++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
@@ -65,8 +65,48 @@ public interface ClusterData {
String getBrokerClientTlsKeyStore();
+ /**
+ * The {@code PulsarTlsFactory} class name used for outbound connections to this cluster (PIP-478), or
+ * blank to inherit the broker-level {@code brokerClientTlsFactoryClassName}. It applies to the two
+ * outbound legs whose client configuration is <em>built from this cluster entry</em>: the
+ * binary-protocol replication client and the cross-cluster admin (HTTPS) client.
+ *
+ * <p>It does not reach the peer-cluster lookup client ({@code NamespaceService.getNamespaceClient}),
+ * which takes only the service URL from the cluster entry and all of its TLS configuration — material
+ * and factory alike — from the broker-level {@code brokerClient*} settings. That leg was already
+ * broker-level in 4.x (it read the broker-level {@code brokerClientSslFactoryPlugin}, never the
+ * per-cluster one), so this is unchanged behaviour rather than a PIP-478 narrowing.
+ *
+ * @return the per-cluster TLS factory class name, or blank/null to inherit the broker-level setting
+ */
+ String getBrokerClientTlsFactoryClassName();
+
+ /**
+ * The configuration parameters passed to {@link #getBrokerClientTlsFactoryClassName()} as its init
+ * params (a JSON object or a {@code key=value} list).
+ *
+ * <p>This value follows the class name rather than inheriting on its own: when this cluster names a
+ * factory, this configuration is used even when blank, so factory A's parameters can never be handed to
+ * factory B. Only when the class name is blank — so the broker-level factory applies — does the
+ * broker-level {@code brokerClientTlsFactoryConfig} apply too.
+ *
+ * @return the per-cluster TLS factory configuration; used verbatim when this cluster names a factory
+ */
+ String getBrokerClientTlsFactoryConfig();
+
+ /**
+ * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained in the cluster
+ * metadata schema for wire/metadata compatibility, but a configured value is ignored (with a WARN).
+ * Use {@link #getBrokerClientTlsFactoryClassName()} instead.
+ */
+ @Deprecated
String getBrokerClientSslFactoryPlugin();
+ /**
+ * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained for metadata
+ * compatibility but ignored. Use {@link #getBrokerClientTlsFactoryConfig()} instead.
+ */
+ @Deprecated
String getBrokerClientSslFactoryPluginParams();
String getListenerName();
@@ -116,8 +156,44 @@ public interface ClusterData {
Builder listenerName(String listenerName);
+ /**
+ * Select the {@code PulsarTlsFactory} used for outbound connections to this cluster (PIP-478) —
+ * the binary-protocol replication client and the cross-cluster admin (HTTPS) client, the two legs
+ * whose configuration is built from this cluster entry (see
+ * {@link ClusterData#getBrokerClientTlsFactoryClassName()} for the leg it does not reach). Leave
+ * blank to inherit the broker-level {@code brokerClientTlsFactoryClassName}.
+ *
+ * @param tlsFactoryClassName the factory class name, or blank to inherit the broker-level setting
+ * @return this builder
+ */
+ Builder brokerClientTlsFactoryClassName(String tlsFactoryClassName);
+
+ /**
+ * Configuration passed to {@link #brokerClientTlsFactoryClassName(String)} as its init params (a
+ * JSON object or a {@code key=value} list).
+ *
+ * <p>It follows the class name rather than inheriting on its own: setting a class name here means
+ * this configuration is used even when blank, so one factory's parameters are never handed to
+ * another. It is ignored — with a warning — when no class name is set on this cluster.
+ *
+ * @param tlsFactoryConfig the factory configuration for this cluster's factory
+ * @return this builder
+ */
+ Builder brokerClientTlsFactoryConfig(String tlsFactoryConfig);
+
+ /**
+ * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Setting it writes a
+ * metadata field retained only for compatibility; the value is ignored (with a WARN). Use
+ * {@link #brokerClientTlsFactoryClassName(String)} instead.
+ */
+ @Deprecated
Builder brokerClientSslFactoryPlugin(String sslFactoryPlugin);
+ /**
+ * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained for
+ * metadata compatibility but ignored. Use {@link #brokerClientTlsFactoryConfig(String)} instead.
+ */
+ @Deprecated
Builder brokerClientSslFactoryPluginParams(String sslFactoryPluginParams);
ClusterData build();
diff --git a/pulsar-client-admin/build.gradle.kts b/pulsar-client-admin/build.gradle.kts
index b82bdf95708e..7a08a5226d4e 100644
--- a/pulsar-client-admin/build.gradle.kts
+++ b/pulsar-client-admin/build.gradle.kts
@@ -19,6 +19,9 @@
plugins {
id("pulsar.public-java-library-conventions")
+ // PIP-478: AdminOAuth2IdpTlsEndToEndTest stands up a WireMock HTTPS IdP with the shared CA test certs
+ // (tests/certificate-authority), proving the admin's folded CLIENT_OAUTH2 trust reaches the IdP.
+ id("pulsar.test-certs-conventions")
}
dependencies {
@@ -26,6 +29,11 @@ dependencies {
api(project(":pulsar-client-admin-api"))
api(project(":pulsar-client-original"))
api(project(":pulsar-common"))
+ // PIP-478: AsyncHttpConnector (internal) uses the TLS factory SPI directly (PulsarTlsFactory / TlsHandle /
+ // TlsPurpose) but never surfaces it on this module's exported ABI, so it is `implementation`. The HTTP SPI
+ // is not named directly here (only pulsar-client's FrameworkHttpClientFactory is referenced), so no direct
+ // http-client-api dependency is needed.
+ implementation(project(":pulsar-tls-factory-api"))
implementation(project(":pulsar-package-management:pulsar-package-core"))
api(libs.jersey.client)
implementation(libs.jersey.media.json.jackson)
@@ -42,6 +50,11 @@ dependencies {
api(libs.asynchttpclient)
implementation(libs.commons.lang3)
implementation(libs.completable.futures)
+ // PIP-478 stage 4b: the admin AsyncHttpConnector rides the PIP-478 TLS SPI on the new path, whose init
+ // context carries an OpenTelemetry root (compile-only; the real root is supplied at runtime by the owning
+ // component, matching pulsar-common / pulsar-tls-factory-api).
+ compileOnly(libs.opentelemetry.api)
testImplementation(libs.wiremock)
+ testImplementation(libs.opentelemetry.api)
}
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
index 5cfb785c5854..feee6417c21b 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
@@ -29,10 +29,15 @@ import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.GenericType;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.time.Duration;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -51,6 +56,8 @@ import org.apache.pulsar.client.admin.PulsarAdminException.ServerSideErrorExcept
import org.apache.pulsar.client.api.Authentication;
import org.apache.pulsar.client.api.AuthenticationDataProvider;
import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.impl.auth.v5.AsyncHttpAuthenticationProvider;
+import org.apache.pulsar.client.impl.auth.v5.HttpAuthenticationDriver;
import org.apache.pulsar.common.policies.data.ErrorData;
import org.apache.pulsar.common.util.ObjectMapperFactory;
@@ -79,34 +86,20 @@ public abstract class BaseResource {
// do the authentication stage, and once authentication completed return a Builder
public CompletableFuture<Builder> requestAsync(final WebTarget target) {
CompletableFuture<Builder> builderFuture = new CompletableFuture<>();
- CompletableFuture<Map<String, String>> authFuture = new CompletableFuture<>();
try {
- AuthenticationDataProvider authData = auth.getAuthData(target.getUri().getHost());
-
- if (authData.hasDataForHttp()) {
- auth.authenticationStage(target.getUri().toString(), authData, null, authFuture);
- } else {
- authFuture.complete(null);
- }
-
- // auth complete, return a new Builder
- authFuture.whenComplete((respHeaders, ex) -> {
+ computeAuthHeaders(target.getUri()).whenComplete((authHeaders, ex) -> {
if (ex != null) {
log.warn().attr("uri", target.getUri())
.exceptionMessage(ex)
.log("Failed to perform http request at auth stage");
- builderFuture.completeExceptionally(new PulsarClientException(ex));
+ builderFuture.completeExceptionally(new PulsarClientException(unwrapCompletion(ex)));
return;
}
try {
Builder builder = target.request(MediaType.APPLICATION_JSON);
- if (authData.hasDataForHttp()) {
- Set<Entry<String, String>> headers =
- auth.newRequestHeader(target.getUri().toString(), authData, respHeaders);
- if (headers != null) {
- headers.forEach(entry -> builder.header(entry.getKey(), entry.getValue()));
- }
+ if (authHeaders != null) {
+ authHeaders.forEach((name, value) -> builder.header(name, value));
}
builderFuture.complete(builder);
} catch (Throwable t) {
@@ -120,6 +113,58 @@ public abstract class BaseResource {
return builderFuture;
}
+ /**
+ * Compute the authentication headers to attach to the outgoing admin request, or a future of
+ * {@code null} when the plugin contributes none (PIP-478).
+ *
+ * <p>When the plugin exposes the v5-native SASL-over-HTTP capability (via the
+ * {@link AsyncHttpAuthenticationProvider} bridge), the framework {@link HttpAuthenticationDriver} runs
+ * the bounded {@code 401}→resubmit→{@code 200} exchange over the plugin's own HTTP client — a bodiless
+ * {@code GET} to the original URI each round, exactly what the v4 {@code authenticationStage(...)} does
+ * today — and yields the validated role-token headers. Otherwise the deprecated v4
+ * {@code authenticationStage(...)} / {@code newRequestHeader(...)} hooks run verbatim, preserving
+ * behaviour for third-party plugins and single-pass built-ins.
+ */
+ protected CompletableFuture<Map<String, String>> computeAuthHeaders(URI uri) {
+ try {
+ if (auth instanceof AsyncHttpAuthenticationProvider provider) {
+ Optional<HttpAuthenticationDriver> driver = provider.httpAuthenticationDriver()
+ .filter(HttpAuthenticationDriver::supportsHttpChallenge);
+ if (driver.isPresent()) {
+ Duration budget = Duration.ofMillis(requestTimeoutMs > 0 ? requestTimeoutMs : 60_000L);
+ // Null transport => the driver uses the plugin's own default transport (its JAX-RS client).
+ return driver.get().authenticateAsync(uri, null, budget)
+ .thenApply(headers -> (headers == null || headers.isEmpty()) ? null : headers.asMap());
+ }
+ }
+ AuthenticationDataProvider authData = auth.getAuthData(uri.getHost());
+ if (!authData.hasDataForHttp()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ CompletableFuture<Map<String, String>> stage = new CompletableFuture<>();
+ auth.authenticationStage(uri.toString(), authData, null, stage);
+ return stage.thenApply(respHeaders -> {
+ try {
+ Set<Entry<String, String>> headers = auth.newRequestHeader(uri.toString(), authData, respHeaders);
+ if (headers == null) {
+ return null;
+ }
+ Map<String, String> map = new LinkedHashMap<>();
+ headers.forEach(entry -> map.put(entry.getKey(), entry.getValue()));
+ return map;
+ } catch (Exception e) {
+ throw new CompletionException(e);
+ }
+ });
+ } catch (Throwable t) {
+ return CompletableFuture.failedFuture(t);
+ }
+ }
+
+ private static Throwable unwrapCompletion(Throwable ex) {
+ return (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex;
+ }
+
public <T> CompletableFuture<Void> asyncPutRequest(final WebTarget target, Entity<T> entity) {
final CompletableFuture<Void> future = new CompletableFuture<>();
try {
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
index ae60c30b25cd..f1be4471fc71 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
@@ -24,11 +24,8 @@ import jakarta.ws.rs.client.WebTarget;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
-import java.util.concurrent.CompletableFuture;
import org.apache.pulsar.client.admin.PulsarAdminException;
import org.apache.pulsar.client.api.Authentication;
-import org.apache.pulsar.client.api.AuthenticationDataProvider;
-import org.apache.pulsar.common.sasl.SaslConstants;
import org.apache.pulsar.common.util.ObjectMapperFactory;
import org.asynchttpclient.RequestBuilder;
@@ -57,17 +54,13 @@ public class ComponentResource extends BaseResource {
}
private Set<Entry<String, String>> getAuthHeaders(WebTarget target) throws Exception {
- AuthenticationDataProvider authData = auth.getAuthData(target.getUri().getHost());
- String targetUrl = target.getUri().toString();
- if (auth.getAuthMethodName().equalsIgnoreCase(SaslConstants.AUTH_METHOD_NAME)) {
- CompletableFuture<Map<String, String>> authFuture = new CompletableFuture<>();
- auth.authenticationStage(targetUrl, authData, null, authFuture);
- return auth.newRequestHeader(targetUrl, authData, authFuture.get());
- } else if (authData.hasDataForHttp()) {
- return auth.newRequestHeader(targetUrl, authData, null);
- } else {
- return null;
- }
+ // The blocking get() runs on the caller's admin/application thread (addAuthHeaders is invoked
+ // synchronously from the raw-AsyncHttpClient admin operations), never the Netty event loop, and the
+ // SASL warmup completes on the plugin's own JAX-RS client pool — so this cannot deadlock. This
+ // mirrors the blocking authFuture.get() it replaces, now routed through the shared HTTP auth driver
+ // (PIP-478) when the plugin exposes the v5 SASL-over-HTTP capability.
+ Map<String, String> headers = computeAuthHeaders(target.getUri()).get();
+ return (headers == null || headers.isEmpty()) ? null : headers.entrySet();
}
protected ObjectWriter objectWriter() {
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
index 46ebc1ec04e9..eb2e0669ec36 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
@@ -73,6 +73,8 @@ public class PulsarAdminBuilderImpl implements PulsarAdminBuilder {
@Override
public PulsarAdminBuilder loadConf(Map<String, Object> config) {
+ // PIP-478: reject a stale, removed PIP-337 sslFactoryPlugin key with an actionable message.
+ ConfigurationDataUtils.rejectRemovedPip337TlsFactoryKeys(config);
conf = ConfigurationDataUtils.loadData(config, conf, ClientConfigurationData.class);
setAuthenticationFromPropsIfAvailable(conf);
if (config.containsKey("acceptGzipCompression")) {
@@ -224,16 +226,14 @@ public class PulsarAdminBuilderImpl implements PulsarAdminBuilder {
}
@Override
- public PulsarAdminBuilder sslFactoryPlugin(String sslFactoryPlugin) {
- if (StringUtils.isNotBlank(sslFactoryPlugin)) {
- conf.setSslFactoryPlugin(sslFactoryPlugin);
- }
+ public PulsarAdminBuilder tlsFactoryClassName(String tlsFactoryClassName) {
+ conf.setTlsFactoryClassName(StringUtils.isBlank(tlsFactoryClassName) ? "" : tlsFactoryClassName);
return this;
}
@Override
- public PulsarAdminBuilder sslFactoryPluginParams(String sslFactoryPluginParams) {
- conf.setSslFactoryPluginParams(sslFactoryPluginParams);
+ public PulsarAdminBuilder tlsFactoryConfig(String tlsFactoryConfig) {
+ conf.setTlsFactoryConfig(StringUtils.isBlank(tlsFactoryConfig) ? "" : tlsFactoryConfig);
return this;
}
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
index f1f1cdbe35fe..79ad64e59fde 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
@@ -20,11 +20,14 @@ package org.apache.pulsar.client.admin.internal;
import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.annotations.VisibleForTesting;
+import io.opentelemetry.api.OpenTelemetry;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.WebTarget;
import java.io.IOException;
import java.net.URL;
+import java.time.Clock;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.CustomLog;
@@ -60,10 +63,18 @@ import org.apache.pulsar.client.admin.internal.http.AsyncHttpConnectorProvider;
import org.apache.pulsar.client.api.Authentication;
import org.apache.pulsar.client.api.AuthenticationFactory;
import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServicesAware;
import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
import org.apache.pulsar.client.impl.auth.AuthenticationDisabled;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.client.impl.auth.v5.DefaultClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.FrameworkHttpClientFactory;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
import org.apache.pulsar.common.net.ServiceURI;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.jackson.JacksonFeature;
@@ -108,6 +119,10 @@ public class PulsarAdminImpl implements PulsarAdmin {
private final ScalableTopics scalableTopics;
protected final WebTarget root;
protected final Authentication auth;
+ // PIP-478: the framework HTTP client factory bound into a services-aware auth plugin
+ // (e.g. OAuth2) so an admin-only client acquires tokens over the framework client; null when the auth
+ // plugin does not implement ClientAuthenticationServicesAware. Closed with this admin.
+ private FrameworkHttpClientFactory authHttpClientFactory;
@Getter
private AsyncHttpConnectorProvider asyncConnectorProvider;
@@ -128,15 +143,22 @@ public class PulsarAdminImpl implements PulsarAdmin {
.attr("authMethodName", auth.getAuthMethodName())
.log("created");
- this.auth.start();
-
if (clientConfigData != null && StringUtils.isBlank(clientConfigData.getServiceUrl())) {
clientConfigData.setServiceUrl(serviceUrl);
}
+ // Built before the authentication is bound and started, because both may already need the TLS
+ // factory it owns: an OAuth2 plugin's start() fetches IdP metadata over HTTPS through the framework
+ // HTTP client, which resolves CLIENT_OAUTH2 against this provider's factory. Resolving the service
+ // URL first is part of that — the provider reads it to decide whether a factory is needed at all.
asyncConnectorProvider = new AsyncHttpConnectorProvider(clientConfigData,
clientConfigData.getAutoCertRefreshSeconds(), acceptGzipCompression);
+ boolean constructed = false;
+ try {
+ bindAuthenticationServices(clientConfigData);
+ this.auth.start();
+
ClientConfig httpConfig = new ClientConfig();
httpConfig.property(ClientProperties.FOLLOW_REDIRECTS, true);
httpConfig.property(ClientProperties.ASYNC_THREADPOOL_SIZE, 8);
@@ -197,6 +219,17 @@ public class PulsarAdminImpl implements PulsarAdmin {
if (originalCtxLoader != null) {
Thread.currentThread().setContextClassLoader(originalCtxLoader);
}
+ constructed = true;
+ } finally {
+ if (!constructed) {
+ // close() is unreachable when the constructor throws, and the provider owns live resources
+ // from the moment its factory is resolved — the TLS factory itself plus a non-daemon
+ // "pulsar-admin-tls-factory" rotation thread. Without this, every failed build() (a bad
+ // trustCertsFilePath being the common case) leaks one of each. The connectors release only
+ // their own borrowed handles, which is exactly why this has to happen here.
+ asyncConnectorProvider.close();
+ }
+ }
}
/**
@@ -442,6 +475,121 @@ public class PulsarAdminImpl implements PulsarAdmin {
/**
* Close the Pulsar admin client to release all the resources.
*/
+ /**
+ * PIP-478: bind the framework HTTP client factory into a services-aware auth plugin (e.g.
+ * OAuth2) before {@code auth.start()}, which eagerly acquires a token via the flow's HTTP client. A
+ * {@code PulsarAdmin} used without a {@code PulsarClient} previously rode the now-removed private OAuth2
+ * {@code AsyncHttpClient}; this mirrors {@code PulsarClientImpl.bindAuthenticationServices} so admin-only
+ * OAuth2 acquires tokens over the framework client. The admin transport is HTTP-only, so the shared event
+ * loop / timer / DNS resolver are not threaded here (AsyncHttpClient provisions its own, as the removed
+ * private client did); the TLS factory supplier reads the admin's attached factory (broker admin path)
+ * when present.
+ */
+ /**
+ * Ensure the admin's TLS factory can serve {@link TlsPurpose#CLIENT_OAUTH2} when the configured OAuth2
+ * plugin carries its own IdP TLS material (PIP-478, issue #24944).
+ *
+ * <p>This mirrors what {@code PulsarClientImpl} does: the IdP policy is folded into the client's own
+ * factory rather than the plugin being left to self-provision a standalone one. Folding is the better of
+ * the two remedies because the standalone factory is built from a fresh
+ * {@code ClientConfigurationData} — losing the admin's SOCKS5 proxy scope and address, so IdP traffic
+ * stopped being proxied — and because it duplicates a {@code FileBasedTlsFactory} and its refresh
+ * scheduler per admin.
+ *
+ * <p>Called before the framework HTTP client factory is bound, so that factory has something to resolve
+ * {@code CLIENT_OAUTH2} against. A policy the caller supplied explicitly wins: the fold is
+ * {@code putIfAbsent}.
+ *
+ * @param conf the admin client configuration, mutated to carry the IdP policy
+ */
+ private void foldOAuth2IdpPolicy(ClientConfigurationData conf) {
+ if (!(auth instanceof AuthenticationOAuth2 oauth2)) {
+ return;
+ }
+ oauth2.idpTlsPolicy().ifPresent(policy -> {
+ Map<TlsPurpose, TlsPolicy> policies = conf.getTlsPolicyMap();
+ if (policies == null) {
+ policies = new LinkedHashMap<>();
+ conf.setTlsPolicyMap(policies);
+ }
+ policies.putIfAbsent(TlsPurpose.CLIENT_OAUTH2, policy);
+ });
+ }
+
+ /**
+ * The TLS factory the authentication plugin's framework HTTP client resolves purposes against: the one
+ * adopted onto the configuration when present, else the one this admin's {@link AsyncHttpConnector}
+ * composed. Returning {@code null} leaves the framework client on platform-default trust, which is correct
+ * only when nothing configured a trust domain for it.
+ *
+ * @param conf this admin's client configuration
+ * @return the TLS factory, or {@code null} when none was composed
+ */
+ private PulsarTlsFactory authTlsFactory(ClientConfigurationData conf) {
+ PulsarTlsFactory adopted = conf.getTlsFactory();
+ if (adopted != null) {
+ return adopted;
+ }
+ // From the provider rather than a connector: this is called during auth start() as well as per
+ // request, and at start() time no connector exists yet. The provider resolves lazily and memoizes,
+ // so asking it here composes the one factory this admin uses rather than an extra one.
+ return asyncConnectorProvider.tlsFactory();
+ }
+
+ /**
+ * Whether {@link #bindAuthenticationServices} actually bound the framework HTTP client factory into the
+ * authentication plugin.
+ *
+ * <p>Exists so a test can observe the decision at its real call site rather than by calling the
+ * predicate directly: review demonstrated that deleting the guard's invocation here left every test
+ * asserting {@code leaveOAuth2Standalone(...)} green, because none of them ran this method
+ * (VisibleForTesting).
+ *
+ * @return whether the framework HTTP client factory was bound
+ */
+ boolean boundAuthHttpClientFactoryForTest() {
+ return authHttpClientFactory != null;
+ }
+
+ /**
+ * The bound framework HTTP client factory, so a test can issue the IdP request through exactly the client
+ * the authentication plugin uses (VisibleForTesting).
+ *
+ * @return the framework HTTP client factory, or {@code null} when none was bound
+ */
+ FrameworkHttpClientFactory authHttpClientFactoryForTest() {
+ return authHttpClientFactory;
+ }
+
+ private void bindAuthenticationServices(ClientConfigurationData conf) {
+ if (conf == null || !(auth instanceof ClientAuthenticationServicesAware aware)) {
+ return;
+ }
+ foldOAuth2IdpPolicy(conf);
+ // Resolve the shared factory here, not on first use. First use is the OAuth2 plugin's start(), which
+ // runs inside the flow's own lock and blocks on material loading; resolving under that lock invites
+ // the loading path to deadlock against it. Doing it here — after the fold, so CLIENT_OAUTH2 is in the
+ // policy map, and before anything plugin-owned is locked — also surfaces a bad TLS configuration at
+ // PulsarAdmin build time rather than at the first authenticated request. No-op when nothing needs it.
+ asyncConnectorProvider.tlsFactory();
+ String clientInstanceId = "pulsar-admin-" + Integer.toHexString(System.identityHashCode(this));
+ // The TLS factory the framework HTTP client resolves its purposes against — notably the CLIENT_OAUTH2
+ // policy folded in just above. An adopted factory (the broker's admin-client attach) is already on the
+ // configuration; otherwise the one the connector composes is the admin's single factory, and reading it
+ // from there rather than composing a second one is what keeps the fold meaningful. The supplier is
+ // resolved lazily, at the first authenticated request, because the connector is created later in this
+ // constructor.
+ this.authHttpClientFactory = new FrameworkHttpClientFactory(
+ () -> null, () -> null, () -> null, () -> authTlsFactory(conf), conf, clientInstanceId);
+ OpenTelemetry openTelemetry = conf.getOpenTelemetry() != null ? conf.getOpenTelemetry()
+ : OpenTelemetry.noop();
+ // The admin OAuth2 flow only uses the HTTP client factory; the scheduler / blocking executor of the
+ // (binary-protocol) auth services are unused on the HTTP-only admin path.
+ ClientAuthenticationServices services = new DefaultClientAuthenticationServices(
+ authHttpClientFactory, null, null, Clock.systemDefaultZone(), openTelemetry, clientInstanceId);
+ aware.bindClientAuthenticationServices(services);
+ }
+
@Override
public void close() {
try {
@@ -452,6 +600,14 @@ public class PulsarAdminImpl implements PulsarAdmin {
client.close();
asyncHttpConnector.close();
+ if (authHttpClientFactory != null) {
+ authHttpClientFactory.close();
+ }
+ // PIP-478: last, because everything above holds subscriptions on the factory this closes — the
+ // connectors' CLIENT_DEFAULT subscription and the auth HTTP clients' CLIENT_OAUTH2 one. Closing the
+ // factory first would tear it down while those subscriptions are still live, which a custom factory
+ // is entitled to treat as an error.
+ asyncConnectorProvider.close();
}
@VisibleForTesting
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
index 8f30fad66a3b..eb7ecce939fa 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
@@ -29,10 +29,12 @@ import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.TEMPORA
import static org.asynchttpclient.util.MiscUtils.isNonEmpty;
import com.google.common.annotations.VisibleForTesting;
import com.spotify.futures.ConcurrencyReducer;
+import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.EventLoopGroup;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.ssl.SslContext;
import io.netty.resolver.NameResolver;
import io.netty.util.concurrent.DefaultThreadFactory;
import jakarta.ws.rs.ProcessingException;
@@ -54,9 +56,9 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
+import javax.net.ssl.SSLEngine;
import lombok.CustomLog;
import lombok.Data;
import lombok.Getter;
@@ -66,18 +68,20 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.pulsar.PulsarVersion;
import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
-import org.apache.pulsar.client.api.PulsarClientException;
import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
import org.apache.pulsar.client.impl.PulsarServiceNameResolver;
import org.apache.pulsar.client.impl.ServiceNameResolver;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
import org.apache.pulsar.client.util.ExecutorProvider;
-import org.apache.pulsar.client.util.PulsarHttpAsyncSslEngineFactory;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
+import org.apache.pulsar.common.tls.impl.TlsSynthesisSpec;
import org.apache.pulsar.common.util.FutureUtil;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
import org.apache.pulsar.common.util.netty.DnsResolverUtil;
import org.apache.pulsar.common.util.netty.EventLoopUtil;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
import org.asynchttpclient.AsyncCompletionHandlerBase;
import org.asynchttpclient.AsyncHandler;
import org.asynchttpclient.AsyncHttpClient;
@@ -115,33 +119,60 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
private final ServiceNameResolver serviceNameResolver;
private final ScheduledExecutorService delayer = Executors.newScheduledThreadPool(1,
new DefaultThreadFactory("delayer"));
- private ScheduledExecutorService sslRefresher;
private final boolean acceptGzipCompression;
@Getter
private final NameResolver<InetAddress> nameResolver;
private final EventLoopGroup eventLoopGroup;
private final boolean createdEventLoopGroup;
private final Map<String, ConcurrencyReducer<Response>> concurrencyReducers = new ConcurrentHashMap<>();
- private PulsarSslFactory sslFactory;
+ // PIP-478 (new TLS path): the PulsarTlsFactory this connector reads, carried with the responsibility for
+ // closing it — owned when this connector resolved it, borrowed when AsyncHttpConnectorProvider supplied
+ // the one it shares across a PulsarAdmin's connectors. The subscription to the CLIENT_DEFAULT SslContext
+ // (whose callback refreshes the volatile below on rotation) is this connector's own either way.
+ private TlsFactoryOwnership tlsFactoryOwnership = TlsFactoryOwnership.none();
+ private TlsHandle<SslContext> tlsFactorySubscription;
+ private volatile SslContext tlsFactorySslContext;
+ private final TlsFactoryOwnership suppliedTlsFactory;
@Getter
@Setter
private boolean followRedirects = true;
public AsyncHttpConnector(Client client, ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
boolean acceptGzipCompression) {
+ this(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression, TlsFactoryOwnership.none());
+ }
+
+ AsyncHttpConnector(Client client, ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
+ boolean acceptGzipCompression, TlsFactoryOwnership suppliedTlsFactory) {
this((int) client.getConfiguration().getProperty(ClientProperties.CONNECT_TIMEOUT),
(int) client.getConfiguration().getProperty(ClientProperties.READ_TIMEOUT),
PulsarAdminImpl.DEFAULT_REQUEST_TIMEOUT_SECONDS * 1000,
autoCertRefreshTimeSeconds,
- conf, acceptGzipCompression, null);
+ conf, acceptGzipCompression, null, suppliedTlsFactory);
}
- @SneakyThrows
public AsyncHttpConnector(int connectTimeoutMs, int readTimeoutMs,
int requestTimeoutMs,
int autoCertRefreshTimeSeconds, ClientConfigurationData conf,
boolean acceptGzipCompression,
PulsarClientSharedResourcesImpl sharedResources) {
+ this(connectTimeoutMs, readTimeoutMs, requestTimeoutMs, autoCertRefreshTimeSeconds, conf,
+ acceptGzipCompression, sharedResources, TlsFactoryOwnership.none());
+ }
+
+ /**
+ * @param suppliedTlsFactory a factory somebody else owns, to borrow instead of resolving one — normally
+ * {@link AsyncHttpConnectorProvider}'s per-admin factory. Pass
+ * {@link TlsFactoryOwnership#none()} to resolve, and own, one from {@code conf}
+ */
+ @SneakyThrows
+ AsyncHttpConnector(int connectTimeoutMs, int readTimeoutMs,
+ int requestTimeoutMs,
+ int autoCertRefreshTimeSeconds, ClientConfigurationData conf,
+ boolean acceptGzipCompression,
+ PulsarClientSharedResourcesImpl sharedResources,
+ TlsFactoryOwnership suppliedTlsFactory) {
+ this.suppliedTlsFactory = suppliedTlsFactory;
Validate.notEmpty(conf.getServiceUrl(), "Service URL is not provided");
serviceNameResolver = new PulsarServiceNameResolver();
String serviceUrl = conf.getServiceUrl();
@@ -152,14 +183,39 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
this.nameResolver = sharedResourceHolder.getNameResolver();
this.eventLoopGroup = sharedResourceHolder.getEventLoopGroup();
this.createdEventLoopGroup = sharedResourceHolder.isCreateEventLoop();
- AsyncHttpClientConfig asyncHttpClientConfig =
- createAsyncHttpClientConfig(conf, connectTimeoutMs, readTimeoutMs, requestTimeoutMs,
- autoCertRefreshTimeSeconds, sharedResources);
- httpClient = createAsyncHttpClient(asyncHttpClientConfig);
+ boolean initialized = false;
+ try {
+ AsyncHttpClientConfig asyncHttpClientConfig =
+ createAsyncHttpClientConfig(conf, connectTimeoutMs, readTimeoutMs, requestTimeoutMs,
+ autoCertRefreshTimeSeconds, sharedResources);
+ httpClient = createAsyncHttpClient(asyncHttpClientConfig);
+ initialized = true;
+ } finally {
+ if (!initialized) {
+ // Ctor-throw cleanup: createAsyncHttpClientConfig may already have allocated the TLS
+ // factory and its NON-daemon rotation executor (and, if we created it, the event loop group);
+ // release them here since close() will not run on a half-constructed connector — otherwise the
+ // non-daemon executor keeps the JVM alive.
+ releasePartialResources();
+ }
+ }
this.requestTimeout = requestTimeoutMs > 0 ? Duration.ofMillis(requestTimeoutMs) : null;
this.maxRetries = httpClient.getConfig().getMaxRequestRetry();
}
+ /** Release resources that may have been allocated before a constructor failure. */
+ private void releasePartialResources() {
+ if (tlsFactorySubscription != null) {
+ tlsFactorySubscription.dispose();
+ }
+ // Same ownership rule as close(): a borrowed factory belongs to the provider, which closes it even
+ // when this connector's construction failed.
+ tlsFactoryOwnership.close();
+ if (createdEventLoopGroup && eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
+ eventLoopGroup.shutdownGracefully();
+ }
+ }
+
private SharedResourceHolder buildResourcesIfConfigured(
PulsarClientSharedResourcesImpl sharedResources) {
EventLoopGroup eventLoopGroup = null;
@@ -194,7 +250,12 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
configureAsyncHttpClientConfig(conf, connectTimeoutMs,
readTimeoutMs, requestTimeoutMs, confBuilder, sharedResources);
if (conf.getServiceUrl().startsWith("https://")) {
- configureAsyncHttpClientSslEngineFactory(conf, autoCertRefreshTimeSeconds, confBuilder);
+ configureAsyncHttpClientWithTlsFactory(confBuilder, resolveNewTlsFactory(conf), conf);
+ } else if (needsTlsFactory(conf)) {
+ // Only the factory is resolved here: no SslEngineFactory is wired onto this connector, so the
+ // admin transport itself stays plaintext (the same separation as
+ // PulsarClientImpl.needsClientTlsFactory).
+ resolveNewTlsFactory(conf);
}
AsyncHttpClientConfig asyncHttpClientConfig = confBuilder.build();
return asyncHttpClientConfig;
@@ -243,8 +304,10 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
&& super.keepAlive(remoteAddress, ahcRequest, request, response);
}
});
- confBuilder.setSslEngineFactory(
- new PulsarHttpAsyncSslEngineFactory(sslFactory, null, conf.isTlsHostnameVerificationEnable()));
+ // This flag only governs AsyncHttpClient's default engine factory; the https path installs its own
+ // SslEngineFactory with hostname verification baked into the factory-built Netty context (see
+ // configureAsyncHttpClientWithTlsFactory).
+ confBuilder.setDisableHttpsEndpointIdentificationAlgorithm(!conf.isTlsHostnameVerificationEnable());
configureSocks5ProxyIfNeeded(confBuilder, conf);
}
@@ -252,30 +315,114 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
return new DefaultAsyncHttpClient(asyncHttpClientConfig);
}
+ /**
+ * Resolve the new-SPI TLS factory for an https admin URL (the only path since the PIP-337 removal):
+ * a {@link PulsarTlsFactory} adopted through the {@code tlsFactory} seam (the broker's admin-client
+ * attach — see {@code PulsarService.getCreateAdminClientBuilder}), or the built-in file-based factory
+ * composed from the {@code tls*} fields. A connector that resolves the factory itself owns it (and its
+ * rotation executor) and releases both in {@link #close()}; one that borrows the provider's shared factory
+ * does not. {@link TlsFactoryOwnership} carries which of the two applies.
+ */
+ /**
+ * Whether this admin configuration needs a {@link PulsarTlsFactory} at all: the admin transport is HTTPS,
+ * or the configuration describes a trust domain other than the transport — the {@code CLIENT_OAUTH2}
+ * policy {@code PulsarAdminImpl} folds in from an OAuth2 plugin's IdP material — or a factory was adopted
+ * onto the configuration and still has to be initialized and owned. Without the factory in that
+ * second case the admin's framework HTTP client has nothing to resolve {@code CLIENT_OAUTH2} against and
+ * falls back to platform-default trust, silently ignoring the plugin's {@code trustCertsFilePath}.
+ *
+ * <p>Shared with {@link AsyncHttpConnectorProvider}, which resolves the one factory per admin, so the
+ * decision to allocate and the decision to use cannot drift apart.
+ *
+ * @param conf the admin client configuration
+ * @return whether a TLS factory must be resolved
+ */
+ static boolean needsTlsFactory(ClientConfigurationData conf) {
+ return conf.getServiceUrl() != null && conf.getServiceUrl().startsWith("https://")
+ || conf.getTlsPolicyMap() != null && !conf.getTlsPolicyMap().isEmpty()
+ // An adopted factory counts too: resolution is what initializes it and puts it under the
+ // provider's ownership, so skipping it here would serve the framework HTTP client an
+ // uninitialized factory that nothing ever closes. In-tree the broker's admin attach only
+ // adopts on an https URL, so this arm is reachable only from out-of-tree code — but the
+ // rule pip-478.md states covers adoption, and this is what makes the code match it.
+ || conf.getTlsFactory() != null;
+ }
+
@SneakyThrows
- private void configureAsyncHttpClientSslEngineFactory(ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
- DefaultAsyncHttpClientConfig.Builder confBuilder)
- throws GeneralSecurityException, IOException {
- // Set client key and certificate if available
- sslRefresher = Executors.newScheduledThreadPool(1,
- new DefaultThreadFactory("pulsar-admin-ssl-refresher"));
- PulsarSslConfiguration sslConfiguration = buildSslConfiguration(conf, serviceNameResolver
- .resolveHostUri().getHost());
- this.sslFactory = (PulsarSslFactory) Class.forName(conf.getSslFactoryPlugin())
- .getConstructor().newInstance();
- this.sslFactory.initialize(sslConfiguration);
- this.sslFactory.createInternalSslContext();
- if (conf.getAutoCertRefreshSeconds() > 0) {
- this.sslRefresher.scheduleWithFixedDelay(this::refreshSslContext, conf.getAutoCertRefreshSeconds(),
- conf.getAutoCertRefreshSeconds(), TimeUnit.SECONDS);
+ private PulsarTlsFactory resolveNewTlsFactory(ClientConfigurationData conf) {
+ if (suppliedTlsFactory.isPresent()) {
+ // A sibling connector on the same PulsarAdmin already resolved it. Resolving again would construct
+ // and initialize a second instance of a by-name custom factory — the SPI says initialize is called
+ // once — and, for a factory adopted from the broker's admin attach, would close the same instance
+ // twice. This connector still takes its own subscription below; only the factory is shared.
+ this.tlsFactoryOwnership = suppliedTlsFactory;
+ return suppliedTlsFactory.factory();
}
- String hostname = conf.isTlsHostnameVerificationEnable() ? null : serviceNameResolver
- .resolveHostUri().getHost();
- SslEngineFactory sslEngineFactory =
- new PulsarHttpAsyncSslEngineFactory(sslFactory, hostname, conf.isTlsHostnameVerificationEnable());
- confBuilder.setSslEngineFactory(sslEngineFactory);
- confBuilder.setUseInsecureTrustManager(conf.isTlsAllowInsecureConnection());
- confBuilder.setDisableHttpsEndpointIdentificationAlgorithm(!conf.isTlsHostnameVerificationEnable());
+ // The factory needs a framework scheduler for material-rotation polling and an executor for blocking
+ // material loading; a connector that resolves its own owns a small single-thread scheduled executor for
+ // both (there is no shared client scheduler here, unlike the PulsarClientImpl funnel).
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
+ new DefaultThreadFactory("pulsar-admin-tls-factory"));
+ PulsarTlsFactory factory = ClientTlsFactorySupport.resolveClientTlsFactory(conf, executor,
+ executor, conf.getOpenTelemetry());
+ this.tlsFactoryOwnership = TlsFactoryOwnership.owning(factory, executor);
+ return factory;
+ }
+
+ /**
+ * New PIP-478 HTTPS TLS setup: subscribe once to the {@link TlsPurpose#CLIENT_DEFAULT} Netty
+ * {@code SslContext} — the callback refreshes a volatile on rotation — and back the AsyncHttpClient
+ * {@link SslEngineFactory} with it, mirroring {@code org.apache.pulsar.client.impl.HttpClient} and the
+ * framework {@code FrameworkHttpClientFactory}. Each connection's engine is built from the most recently
+ * delivered (possibly rotated) context; hostname verification, insecure trust, ciphers and protocols are
+ * baked into the factory-built context per the {@code TlsPolicy}, so the AsyncHttpClient
+ * endpoint-identification / insecure-trust-manager flags are not applied on this path.
+ *
+ * <p>Acquisition goes through {@link TlsContextAcquisition} so a custom factory that supplies only the
+ * JDK {@code SSLContext} is served a framework-synthesized Netty context carrying the client's
+ * hostname-verification setting.
+ */
+ @SneakyThrows
+ private void configureAsyncHttpClientWithTlsFactory(DefaultAsyncHttpClientConfig.Builder confBuilder,
+ PulsarTlsFactory factory, ClientConfigurationData conf) {
+ this.tlsFactorySubscription = TlsContextAcquisition.acquireNettyContext(factory, TlsPurpose.CLIENT_DEFAULT,
+ TlsSynthesisSpec.client(conf.isTlsHostnameVerificationEnable()),
+ ctx -> this.tlsFactorySslContext = ctx)
+ .get()
+ .orElseThrow(() -> new IllegalStateException(
+ "Admin TLS factory supplied no Netty SslContext for purpose " + TlsPurpose.CLIENT_DEFAULT));
+ confBuilder.setSslEngineFactory(new SslEngineFactory() {
+ @Override
+ public SSLEngine newSslEngine(AsyncHttpClientConfig ahcConfig, String peerHost, int peerPort) {
+ // Client mode, SNI and baked-in hostname verification all come from the Netty context. Pin the
+ // context across newEngine so a concurrent rotation cannot free the native OpenSSL context
+ // mid-build (use-after-free guard).
+ return TlsContextAcquisition.withPinnedContext(() -> tlsFactorySslContext,
+ ctx -> ctx.newEngine(ByteBufAllocator.DEFAULT, peerHost, peerPort));
+ }
+ });
+ // Bound how long an established pooled HTTPS connection keeps pre-rotation material: new
+ // connections pick up rotation via the SslEngineFactory above, but a pooled connection would otherwise
+ // hold pre-rotation trust/cert indefinitely. Aligns with FrameworkHttpClientFactory.
+ confBuilder.setConnectionTtl(Duration.ofMillis(TlsContextAcquisition.httpTlsRotationConnectionTtlMillis()));
+ }
+
+ /**
+ * @return the resolved PIP-478 TLS factory when this connector is on the new TLS path, or {@code null} on
+ * the legacy PIP-337 path
+ */
+ @VisibleForTesting
+ public PulsarTlsFactory getTlsFactory() {
+ return tlsFactoryOwnership.factory();
+ }
+
+ /**
+ * @return the AsyncHttpClient connection-TTL (millis) bounding how long a pooled HTTPS connection keeps
+ * pre-rotation TLS material on the new factory path (negative when unset, i.e. the plaintext path)
+ */
+ @VisibleForTesting
+ public int getConnectionTtlMs() {
+ return (int) httpClient.getConfig().getConnectionTtl().toMillis();
}
@Override
@@ -622,9 +769,14 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
try {
httpClient.close();
delayer.shutdownNow();
- if (sslRefresher != null) {
- sslRefresher.shutdownNow();
+ // PIP-478 (new TLS path): release the CLIENT_DEFAULT subscription, close the factory and
+ // stop its rotation executor (all no-ops when the admin URL is not https).
+ if (tlsFactorySubscription != null) {
+ tlsFactorySubscription.dispose();
}
+ // The subscription above is this connector's own either way; the handle knows whether the factory
+ // and its executor are ours to release, so this is a no-op when they are borrowed.
+ tlsFactoryOwnership.close();
if (createdEventLoopGroup && eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
eventLoopGroup.shutdownGracefully();
}
@@ -633,39 +785,6 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
}
}
- protected PulsarSslConfiguration buildSslConfiguration(ClientConfigurationData conf, String host)
- throws PulsarClientException {
- return PulsarSslConfiguration.builder()
- .tlsProvider(conf.getSslProvider())
- .tlsKeyStoreType(conf.getTlsKeyStoreType())
- .tlsKeyStorePath(conf.getTlsKeyStorePath())
- .tlsKeyStorePassword(conf.getTlsKeyStorePassword())
- .tlsTrustStoreType(conf.getTlsTrustStoreType())
- .tlsTrustStorePath(conf.getTlsTrustStorePath())
- .tlsTrustStorePassword(conf.getTlsTrustStorePassword())
- .tlsCiphers(conf.getTlsCiphers())
- .tlsProtocols(conf.getTlsProtocols())
- .tlsTrustCertsFilePath(conf.getTlsTrustCertsFilePath())
- .tlsCertificateFilePath(conf.getTlsCertificateFilePath())
- .tlsKeyFilePath(conf.getTlsKeyFilePath())
- .allowInsecureConnection(conf.isTlsAllowInsecureConnection())
- .requireTrustedClientCertOnConnect(false)
- .tlsEnabledWithKeystore(conf.isUseKeyStoreTls())
- .authData(conf.getAuthentication().getAuthData(host))
- .tlsCustomParams(conf.getSslFactoryPluginParams())
- .serverMode(false)
- .isHttps(true)
- .build();
- }
-
- protected void refreshSslContext() {
- try {
- this.sslFactory.update();
- } catch (Exception e) {
- log.error().exception(e).log("Failed to refresh SSL context");
- }
- }
-
@Data
private static class SharedResourceHolder {
static final SharedResourceHolder EMPTY = new SharedResourceHolder(null, null, false);
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
index bf5efe3ffd6d..ba31638d1f90 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
@@ -19,10 +19,15 @@
package org.apache.pulsar.client.admin.internal.http;
import com.google.common.annotations.VisibleForTesting;
+import io.netty.util.concurrent.DefaultThreadFactory;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Configuration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
+import org.apache.pulsar.tls.PulsarTlsFactory;
import org.glassfish.jersey.client.spi.Connector;
import org.glassfish.jersey.client.spi.ConnectorProvider;
@@ -36,6 +41,14 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
private final int autoCertRefreshTimeSeconds;
private final boolean acceptGzipCompression;
private boolean followRedirects = true;
+ // PIP-478: one TLS factory per PulsarAdmin. A PulsarAdmin ends up with two connectors — the one Jersey
+ // creates lazily on the first request, and the one PulsarAdminImpl builds directly — and each used to
+ // resolve its own. For a by-name custom factory that meant constructing and initializing it twice, against
+ // the SPI's "initialize is called once"; for a factory adopted from the broker's admin attach it meant
+ // closing the same instance twice. The provider is the natural owner: it is per-admin and creates both
+ // connectors. Resolved lazily, on the first connector that needs TLS, so a plaintext admin with nothing to
+ // serve still allocates nothing.
+ private TlsFactoryOwnership sharedTlsFactory;
public AsyncHttpConnectorProvider(ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
boolean acceptGzipCompression) {
@@ -47,7 +60,8 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
@Override
public Connector getConnector(Client client, Configuration runtimeConfig) {
if (connector == null) {
- connector = new AsyncHttpConnector(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression);
+ connector = new AsyncHttpConnector(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression,
+ sharedTlsFactory());
connector.setFollowRedirects(followRedirects);
}
return connector;
@@ -57,7 +71,65 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
public AsyncHttpConnector getConnector(int connectTimeoutMs, int readTimeoutMs, int requestTimeoutMs,
int autoCertRefreshTimeSeconds, PulsarClientSharedResourcesImpl sharedResources) {
return new AsyncHttpConnector(connectTimeoutMs, readTimeoutMs, requestTimeoutMs, autoCertRefreshTimeSeconds,
- conf, acceptGzipCompression, sharedResources);
+ conf, acceptGzipCompression, sharedResources, sharedTlsFactory());
+ }
+
+ /**
+ * The single {@link PulsarTlsFactory} shared by every connector this provider creates, or {@code null}
+ * when this admin configuration needs none. Resolved at most once; the connectors take their own
+ * subscriptions from it but never close it.
+ *
+ * @return the shared TLS factory, or {@code null} when none is needed
+ */
+ /**
+ * The TLS factory this provider owns and shares with its connectors, resolved if it has not been yet.
+ *
+ * <p>Exposed because {@code PulsarAdminImpl} needs it before any connector exists: an OAuth2 plugin's
+ * {@code start()} fetches IdP metadata through the framework HTTP client, which resolves
+ * {@code CLIENT_OAUTH2} against this factory, and that happens while the admin is still being
+ * constructed.
+ *
+ * @return the shared factory, or {@code null} when this configuration needs none
+ */
+ public PulsarTlsFactory tlsFactory() {
+ return sharedTlsFactory().factory();
+ }
+
+ @VisibleForTesting
+ synchronized TlsFactoryOwnership sharedTlsFactory() {
+ if (sharedTlsFactory != null) {
+ // Already resolved. The connectors borrow it: this provider stays the owner.
+ return TlsFactoryOwnership.borrowing(sharedTlsFactory.factory());
+ }
+ if (!AsyncHttpConnector.needsTlsFactory(conf)) {
+ sharedTlsFactory = TlsFactoryOwnership.none();
+ return sharedTlsFactory;
+ }
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
+ new DefaultThreadFactory("pulsar-admin-tls-factory"));
+ try {
+ sharedTlsFactory = TlsFactoryOwnership.owning(
+ ClientTlsFactorySupport.resolveClientTlsFactory(conf, executor, executor,
+ conf.getOpenTelemetry()),
+ executor);
+ } catch (Exception e) {
+ executor.shutdownNow();
+ throw new RuntimeException("Failed to resolve the admin client TLS factory", e);
+ }
+ return TlsFactoryOwnership.borrowing(sharedTlsFactory.factory());
+ }
+
+ /**
+ * Release the shared TLS factory and the executor driving its rotation. Called when the owning
+ * {@code PulsarAdmin} closes; the connectors borrowed the factory and dispose only their own
+ * subscriptions.
+ */
+ public synchronized void close() {
+ if (sharedTlsFactory == null) {
+ return;
+ }
+ sharedTlsFactory.close();
+ sharedTlsFactory = TlsFactoryOwnership.none();
}
@VisibleForTesting
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java
new file mode 100644
index 000000000000..50f43beb57ed
--- /dev/null
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.admin.internal.http;
+
+import java.util.concurrent.ScheduledExecutorService;
+import lombok.CustomLog;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+
+/**
+ * A {@link PulsarTlsFactory} together with the responsibility for closing it (PIP-478).
+ *
+ * <p>The admin client has more than one holder of the same factory: a {@code PulsarAdmin} ends up with two
+ * {@link AsyncHttpConnector} instances — the one {@code PulsarAdminImpl} builds and the one Jersey creates
+ * lazily on the first request — and the factory may in turn have been handed in from outside, by the broker's
+ * admin attach. Every one of those holders needs the factory; exactly one may close it. Carrying the
+ * factory and that responsibility together makes the handoff say which one it is, so a holder cannot acquire
+ * a reference without also learning whether closing is its job.
+ *
+ * <p>{@link #close()} is therefore always safe to call: it releases the factory and its rotation executor
+ * when this handle {@linkplain #owning owns} them, and does nothing when it is only {@linkplain #borrowing
+ * borrowing} them.
+ */
+@CustomLog
+final class TlsFactoryOwnership implements AutoCloseable {
+
+ private static final TlsFactoryOwnership NONE = new TlsFactoryOwnership(null, null, false);
+
+ private final PulsarTlsFactory factory;
+ private final ScheduledExecutorService executor;
+ private final boolean owned;
+
+ private TlsFactoryOwnership(PulsarTlsFactory factory, ScheduledExecutorService executor, boolean owned) {
+ this.factory = factory;
+ this.executor = executor;
+ this.owned = owned;
+ }
+
+ /**
+ * @return a handle on no factory at all — this configuration needs none
+ */
+ static TlsFactoryOwnership none() {
+ return NONE;
+ }
+
+ /**
+ * Take responsibility for a factory this holder resolved itself, and for the executor driving its
+ * material rotation. {@link #close()} releases both.
+ *
+ * @param factory the resolved factory
+ * @param executor the executor driving its rotation, released with it
+ * @return an owning handle
+ */
+ static TlsFactoryOwnership owning(PulsarTlsFactory factory, ScheduledExecutorService executor) {
+ return factory == null ? NONE : new TlsFactoryOwnership(factory, executor, true);
+ }
+
+ /**
+ * Use a factory somebody else owns and will close — here, the one
+ * {@link AsyncHttpConnectorProvider} shares across the connectors of a single {@code PulsarAdmin}.
+ * {@link #close()} does nothing.
+ *
+ * @param factory the borrowed factory, or {@code null} for {@link #none()}
+ * @return a borrowing handle
+ */
+ static TlsFactoryOwnership borrowing(PulsarTlsFactory factory) {
+ return factory == null ? NONE : new TlsFactoryOwnership(factory, null, false);
+ }
+
+ /**
+ * @return the factory, or {@code null} when there is none
+ */
+ PulsarTlsFactory factory() {
+ return factory;
+ }
+
+ /**
+ * @return whether this handle carries a factory
+ */
+ boolean isPresent() {
+ return factory != null;
+ }
+
+ @Override
+ public void close() {
+ if (!owned) {
+ return;
+ }
+ try {
+ factory.close();
+ } catch (Exception e) {
+ log.warn().exception(e).log("Failed to close the admin client TLS factory");
+ } finally {
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ }
+ }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java
new file mode 100644
index 000000000000..36834697ab3d
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.admin.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 / issue #24944: a {@code PulsarAdmin} using OAuth2 with its own IdP TLS material
+ * ({@code trustCertsFilePath} / {@code tlsCertFile} / {@code tlsKeyFile}) must keep honouring it.
+ *
+ * <p>The remedy mirrors {@code PulsarClientImpl}: fold the IdP policy into the admin's own TLS factory under
+ * {@link TlsPurpose#CLIENT_OAUTH2}, rather than leaving the plugin to self-provision a standalone factory.
+ * Folding preserves the admin's transport settings — notably its SOCKS5 proxy scope, which a fresh
+ * {@code ClientConfigurationData} would have reset to {@code BINARY_ONLY} — and avoids a duplicate factory
+ * and refresh scheduler per admin.
+ */
+public class AdminOAuth2IdpTlsBindingTest {
+
+ private static final String ISSUER = "{\"type\":\"client_credentials\","
+ + "\"issuerUrl\":\"https://idp.example.com\","
+ + "\"privateKey\":\"data:application/json;base64,e30=\","
+ + "\"audience\":\"test\"";
+
+ @Test
+ public void idpTrustMaterialIsFoldedIntoTheAdminFactory() throws Exception {
+ try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER + ",\"trustCertsFilePath\":\"/tls/idp-ca.pem\"}"))) {
+ TlsPolicy idp = admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2);
+ assertThat(idp).as("the IdP policy must reach the admin's own factory").isNotNull();
+ assertThat(idp.trustCertsFilePath()).isEqualTo("/tls/idp-ca.pem");
+ }
+ }
+
+ @Test
+ public void idpMtlsMaterialIsFoldedToo() throws Exception {
+ try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER
+ + ",\"tlsCertFile\":\"/tls/idp-client.pem\",\"tlsKeyFile\":\"/tls/idp-client.key\"}"))) {
+ TlsPolicy idp = admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2);
+ assertThat(idp).isNotNull();
+ assertThat(idp.certificateFilePath()).isEqualTo("/tls/idp-client.pem");
+ assertThat(idp.keyFilePath()).isEqualTo("/tls/idp-client.key");
+ }
+ }
+
+ @Test
+ public void anExplicitOauth2PolicyWins() throws Exception {
+ // The fold is putIfAbsent, so a policy the caller supplied explicitly must not be overwritten by the
+ // one derived from authParams.
+ TlsPolicy explicit = TlsPolicy.builder().trustCertsFilePath("/tls/explicit-ca.pem").build();
+ PulsarAdminBuilder builder = PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:8080")
+ .authentication(oauth2(ISSUER + ",\"trustCertsFilePath\":\"/tls/idp-ca.pem\"}"));
+ ((PulsarAdminBuilderImpl) builder).getConf()
+ .setTlsPolicyMap(new java.util.LinkedHashMap<>(java.util.Map.of(TlsPurpose.CLIENT_OAUTH2, explicit)));
+ try (PulsarAdminImpl admin = (PulsarAdminImpl) builder.build()) {
+ assertThat(admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2)
+ .trustCertsFilePath()).isEqualTo("/tls/explicit-ca.pem");
+ }
+ }
+
+ @Test
+ public void noIdpMaterialAddsNoPolicy() throws Exception {
+ try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER + "}"))) {
+ var map = admin.getClientConfigData().getTlsPolicyMap();
+ assertThat(map == null || map.get(TlsPurpose.CLIENT_OAUTH2) == null)
+ .as("nothing to fold, so no CLIENT_OAUTH2 policy is invented").isTrue();
+ }
+ }
+
+ private static PulsarAdminImpl adminWith(AuthenticationOAuth2 auth) throws Exception {
+ return (PulsarAdminImpl) PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:8080")
+ .authentication(auth)
+ .build();
+ }
+
+ /** start() is stubbed: it would fetch OAuth2 server metadata; the fold runs before it. */
+ private static AuthenticationOAuth2 oauth2(String params) {
+ AuthenticationOAuth2 auth = new AuthenticationOAuth2() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void start() {
+ }
+ };
+ auth.configure(params);
+ return auth;
+ }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java
new file mode 100644
index 000000000000..40d7b3e572da
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.admin.internal;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.moreThanOrExactly;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.google.common.io.Resources;
+import java.io.File;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.client.impl.auth.v5.FrameworkHttpClientFactory;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.http.HttpRequest;
+import org.apache.pulsar.http.HttpResponse;
+import org.apache.pulsar.http.PulsarHttpClient;
+import org.apache.pulsar.http.PulsarHttpClientConfig;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 / issue #24944, end to end: the admin counterpart of {@code OAuth2IdpTlsFrameworkClientTest}.
+ * {@link AdminOAuth2IdpTlsBindingTest} asserts the <em>binding decision</em> — that the IdP policy is folded
+ * into the admin's configuration under {@link TlsPurpose#CLIENT_OAUTH2}. That is necessary but not
+ * sufficient: the fold is only meaningful if the admin also has a TLS factory to resolve that purpose
+ * against, and if the framework HTTP client reads it. This test closes that gap by making a real HTTPS
+ * request to a WireMock IdP whose certificate is signed by the shared test CA — trusted only via the
+ * plugin's {@code trustCertsFilePath}.
+ *
+ * <p>The admin's own service URL is plaintext {@code http://} on purpose: that is where the gap lived. The
+ * connector composed a TLS factory only for an {@code https://} admin URL, so a plaintext admin folded the
+ * IdP policy into a configuration that no factory ever read, and the IdP connection silently fell back to
+ * platform-default trust.
+ */
+public class AdminOAuth2IdpTlsEndToEndTest {
+
+ // broker.cert.pem carries SAN:localhost and chains to ca.cert.pem, so both trust and hostname verification
+ // pass against the WireMock server (the shared broker.keystore.jks has no SAN).
+ private static final String BROKER_CERT = resource("certificate-authority/server-keys/broker.cert.pem");
+ private static final String BROKER_KEY = resource("certificate-authority/server-keys/broker.key-pk8.pem");
+ private static final String CA_CERT = resource("certificate-authority/certs/ca.cert.pem");
+ private static final String STORE_PW = "111111";
+
+ private WireMockServer idp;
+ private Path serverKeystore;
+
+ @BeforeMethod
+ public void setUp() throws Exception {
+ serverKeystore = buildServerKeystore();
+ idp = new WireMockServer(wireMockConfig().dynamicHttpsPort()
+ .keystorePath(serverKeystore.toString()).keystoreType("JKS")
+ .keyManagerPassword(STORE_PW).keystorePassword(STORE_PW));
+ idp.start();
+ idp.stubFor(get(urlEqualTo("/probe")).willReturn(aResponse().withStatus(200).withBody("ok")));
+ // Enough of an OIDC discovery document for ClientCredentialsFlow.initialize() to complete, so a test
+ // can exercise the real AuthenticationOAuth2.start() rather than stubbing it out.
+ idp.stubFor(get(urlEqualTo("/.well-known/openid-configuration")).willReturn(aResponse().withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"issuer\":\"" + idp.baseUrl() + "\",\"token_endpoint\":\""
+ + idp.baseUrl() + "/oauth/token\"}")));
+ }
+
+ @AfterMethod(alwaysRun = true)
+ public void tearDown() throws Exception {
+ if (idp != null) {
+ idp.stop();
+ }
+ if (serverKeystore != null) {
+ Files.deleteIfExists(serverKeystore);
+ }
+ }
+
+ @Test
+ public void theFoldedIdpTrustReachesTheIdpThroughTheAdminsFrameworkHttpClient() throws Exception {
+ try (PulsarAdminImpl admin = adminWith(oauth2WithTrust(CA_CERT))) {
+ HttpResponse response = probe(admin);
+ assertThat(response.statusCode())
+ .as("the admin's framework HTTP client must reach the IdP using the folded CLIENT_OAUTH2 "
+ + "trust; a null factory would leave it on platform-default trust")
+ .isEqualTo(200);
+ }
+ }
+
+ @Test
+ public void withoutTheIdpTrustTheAdminRejectsTheIdpCertificate() throws Exception {
+ // The contrast that proves the folded policy — and not some ambient trust — is what carries the CA.
+ try (PulsarAdminImpl admin = adminWith(oauth2WithTrust(null))) {
+ assertThatThrownBy(() -> probe(admin))
+ .as("platform-default trust must reject the test-CA-signed IdP certificate")
+ .satisfies(t -> assertThat(causeChain(t)).containsAnyOf("SSL", "certificat", "PKIX",
+ "unable to find valid certification path"));
+ }
+ }
+
+ @Test
+ public void theRealOauth2StartFetchesIdpMetadataOverTheFoldedTrust() throws Exception {
+ // The other two tests stub start(), so they exercise only the per-request path. start() is the
+ // earlier — and stricter — user of the factory: ClientCredentialsFlow.initialize() fetches the IdP
+ // discovery document through the framework HTTP client while the PulsarAdmin is still being
+ // constructed. That is before any AsyncHttpConnector exists, so the factory has to come from the
+ // provider; sourcing it from a connector left it null here and fell back to platform trust.
+ AuthenticationOAuth2 auth = new AuthenticationOAuth2();
+ auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+ + "\",\"privateKey\":\"data:application/json;base64,e30=\",\"trustCertsFilePath\":\""
+ + CA_CERT + "\"}");
+
+ try (PulsarAdmin admin = PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:8080")
+ .authentication(auth)
+ .build()) {
+ assertThat(admin).isNotNull();
+ }
+ // The count is not the point (the resolver may probe more than once) — reaching the IdP at all over
+ // HTTPS is, since the handshake only succeeds on the folded trust.
+ idp.verify(moreThanOrExactly(1), getRequestedFor(urlEqualTo("/.well-known/openid-configuration")));
+ }
+
+ @Test
+ public void withoutTheIdpTrustTheRealOauth2StartFailsToReachTheIdp() throws Exception {
+ // The contrast for the start() path: same flow, no trustCertsFilePath, so the metadata fetch must be
+ // rejected by platform-default trust and fail the build rather than silently succeeding.
+ AuthenticationOAuth2 auth = new AuthenticationOAuth2();
+ auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+ + "\",\"privateKey\":\"data:application/json;base64,e30=\"}");
+
+ assertThatThrownBy(() -> PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:8080")
+ .authentication(auth)
+ .build())
+ .as("platform-default trust must reject the test-CA-signed IdP certificate, failing the build")
+ // The OAuth2 metadata resolver reports the failure without chaining the TLS cause, so this
+ // asserts the observable outcome: with the folded trust the same flow reaches the IdP (test
+ // above), without it the handshake fails and the build does not complete.
+ .satisfies(t -> assertThat(causeChain(t)).contains("OAuth 2.0 server metadata"));
+ }
+
+ /** Issue the IdP request through exactly the client the OAuth2 plugin would use for its token fetch. */
+ private HttpResponse probe(PulsarAdminImpl admin) throws Exception {
+ FrameworkHttpClientFactory factory = admin.authHttpClientFactoryForTest();
+ assertThat(factory).as("the framework HTTP client factory must be bound").isNotNull();
+ PulsarHttpClient client = factory.newHttpClient(PulsarHttpClientConfig.builder(TlsPurpose.CLIENT_OAUTH2)
+ .connectTimeout(Duration.ofSeconds(10))
+ .readTimeout(Duration.ofSeconds(10))
+ .requestTimeout(Duration.ofSeconds(10))
+ .userAgent("admin-oauth2-idp-tls-test")
+ .build());
+ return client.execute(HttpRequest.builder(HttpRequest.Method.GET,
+ URI.create(idp.baseUrl() + "/probe")).build()).get(30, TimeUnit.SECONDS);
+ }
+
+ private static PulsarAdminImpl adminWith(AuthenticationOAuth2 auth) throws Exception {
+ // Plaintext admin URL on purpose — see the class javadoc.
+ return (PulsarAdminImpl) PulsarAdmin.builder()
+ .serviceHttpUrl("http://localhost:8080")
+ .authentication(auth)
+ .build();
+ }
+
+ /** start() is stubbed: it would fetch OAuth2 server metadata; the fold runs before it. */
+ private AuthenticationOAuth2 oauth2WithTrust(String trustCertsFilePath) {
+ AuthenticationOAuth2 auth = new AuthenticationOAuth2() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void start() {
+ }
+ };
+ auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+ + "\",\"privateKey\":\"data:application/json;base64,e30=\""
+ + (trustCertsFilePath == null ? "" : ",\"trustCertsFilePath\":\"" + trustCertsFilePath + "\"")
+ + "}");
+ return auth;
+ }
+
+ /** Build a JKS keystore holding broker.cert.pem (SAN:localhost) + its key, chained to the test CA. */
+ private static Path buildServerKeystore() throws Exception {
+ PrivateKey key = PemReader.loadPrivateKeyFromPemFile(BROKER_KEY);
+ X509Certificate[] leaf = PemReader.loadCertificatesFromPemFile(BROKER_CERT);
+ X509Certificate[] ca = PemReader.loadCertificatesFromPemFile(CA_CERT);
+ Certificate[] chain = new Certificate[leaf.length + ca.length];
+ System.arraycopy(leaf, 0, chain, 0, leaf.length);
+ System.arraycopy(ca, 0, chain, leaf.length, ca.length);
+ KeyStore keyStore = KeyStore.getInstance("JKS");
+ keyStore.load(null, null);
+ keyStore.setKeyEntry("broker", key, STORE_PW.toCharArray(), chain);
+ Path path = Files.createTempFile("pip478-admin-oauth2-idp-keystore", ".jks");
+ try (OutputStream out = Files.newOutputStream(path)) {
+ keyStore.store(out, STORE_PW.toCharArray());
+ }
+ return path;
+ }
+
+ private static String causeChain(Throwable t) {
+ StringBuilder sb = new StringBuilder();
+ for (Throwable cur = t; cur != null; cur = cur.getCause()) {
+ sb.append(cur.getClass().getSimpleName()).append(':').append(String.valueOf(cur.getMessage())).append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static String resource(String name) {
+ return new File(Resources.getResource(name).getPath()).getAbsolutePath();
+ }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java
new file mode 100644
index 000000000000..1432845e18cf
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.admin.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslContextBuilder;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: a {@code PulsarAdmin} must construct, initialize and close its {@link PulsarTlsFactory} exactly
+ * once.
+ *
+ * <p>This is not automatic, because an admin ends up with <em>two</em> {@code AsyncHttpConnector} instances:
+ * the one {@code PulsarAdminImpl} builds directly, and the one Jersey creates lazily on the first request.
+ * Each used to resolve its own factory, which meant a by-name custom factory was constructed and
+ * {@code initialize()}d twice — against the SPI's "called once by the framework" — and a factory adopted from
+ * the broker's admin attach had {@code close()} called on it twice. The v4 (PIP-337) code duplicated the
+ * factory the same way, but never closed it, so only the duplication existed there and not the double close.
+ *
+ * <p>{@code AsyncHttpConnectorProvider} now resolves one factory per admin and owns it; the connectors take
+ * their own subscriptions but never close it. Both tests below issue a request precisely to force the
+ * Jersey-side connector into existence — without it the duplication is invisible.
+ */
+public class AdminTlsFactoryLifecycleTest {
+
+ @BeforeMethod
+ public void setUp() {
+ CountingFactory.reset();
+ }
+
+ @Test
+ public void aByNameFactoryIsConstructedInitializedAndClosedExactlyOncePerAdmin() throws Exception {
+ PulsarAdmin admin = PulsarAdmin.builder()
+ .serviceHttpUrl("https://localhost:8443")
+ .tlsFactoryClassName(CountingFactory.class.getName())
+ .build();
+ forceJerseyConnector(admin);
+
+ assertThat(CountingFactory.INSTANTIATED).as("one factory per admin, not one per connector").hasValue(1);
+ assertThat(CountingFactory.INITIALIZED).as("the SPI says initialize is called once").hasValue(1);
+
+ admin.close();
+ assertThat(CountingFactory.CLOSED).as("and it is closed exactly once").hasValue(1);
+ }
+
+ @Test
+ public void anAdoptedFactoryIsInitializedAndClosedExactlyOnce() throws Exception {
+ // How the broker's admin attach hands in a factory it built (PulsarService.applyBrokerClientTlsFactoryToAdmin).
+ CountingFactory adopted = new CountingFactory();
+ PulsarAdminBuilder builder = PulsarAdmin.builder().serviceHttpUrl("https://localhost:8443");
+ ((PulsarAdminBuilderImpl) builder).getConf().setTlsFactory(adopted);
+ PulsarAdmin admin = builder.build();
+ forceJerseyConnector(admin);
+
+ assertThat(CountingFactory.INSTANTIATED).as("the adopted instance is used as-is").hasValue(1);
+ assertThat(CountingFactory.INITIALIZED).hasValue(1);
+
+ admin.close();
+ assertThat(CountingFactory.CLOSED)
+ .as("closing the admin must not close the adopted factory twice").hasValue(1);
+ }
+
+ @Test
+ public void aFailedAdminBuildDoesNotLeakTheResolvedFactory() throws Exception {
+ // close() is unreachable when the constructor throws, so whatever the provider resolved before the
+ // failure has to be released on the way out. The factory below fails the build by refusing to serve
+ // CLIENT_DEFAULT for an https admin URL, which is where the eager context acquisition throws.
+ CountingFactory.FAIL_CLIENT_DEFAULT.set(true);
+ assertThatThrownBy(() -> PulsarAdmin.builder()
+ .serviceHttpUrl("https://localhost:8443")
+ .tlsFactoryClassName(CountingFactory.class.getName())
+ .build())
+ .as("the build must fail for this test to be about the failure path").isNotNull();
+
+ assertThat(CountingFactory.INSTANTIATED).as("the factory was resolved before the failure").hasValue(1);
+ assertThat(CountingFactory.CLOSED)
+ .as("and must be closed on the way out: a failed build leaves no handle to close it later, so "
+ + "it would otherwise leak the factory and its non-daemon rotation thread")
+ .hasValue(1);
+ }
+
+ /** Jersey creates its connector lazily on the first request; the request itself is expected to fail. */
+ private static void forceJerseyConnector(PulsarAdmin admin) {
+ try {
+ admin.clusters().getClusters();
+ } catch (Exception expected) {
+ // No broker is listening — reaching the transport is all this needs.
+ }
+ }
+
+ /** Counts its own construction, initialization and closing. */
+ public static class CountingFactory implements PulsarTlsFactory {
+
+ static final AtomicInteger INSTANTIATED = new AtomicInteger();
+ static final AtomicInteger INITIALIZED = new AtomicInteger();
+ static final AtomicInteger CLOSED = new AtomicInteger();
+ static final java.util.concurrent.atomic.AtomicBoolean FAIL_CLIENT_DEFAULT =
+ new java.util.concurrent.atomic.AtomicBoolean();
+
+ static void reset() {
+ INSTANTIATED.set(0);
+ INITIALIZED.set(0);
+ CLOSED.set(0);
+ FAIL_CLIENT_DEFAULT.set(false);
+ }
+
+ public CountingFactory() {
+ INSTANTIATED.incrementAndGet();
+ }
+
+ @Override
+ public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+ INITIALIZED.incrementAndGet();
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+ Class<T> instanceClass) {
+ if (FAIL_CLIENT_DEFAULT.get() && TlsPurpose.CLIENT_DEFAULT.equals(purpose)) {
+ return CompletableFuture.failedFuture(new IllegalStateException("cannot serve CLIENT_DEFAULT"));
+ }
+ if (instanceClass != SslContext.class) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ try {
+ SslContext context = SslContextBuilder.forClient().build();
+ TlsHandle<T> handle = new TlsHandle<>() {
+ @Override
+ @SuppressWarnings("unchecked")
+ public T get() {
+ return (T) context;
+ }
+
+ @Override
+ public void dispose() {
+ }
+ };
+ return CompletableFuture.completedFuture(Optional.of(handle));
+ } catch (Exception e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+ Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+ return createInstance(purpose, instanceClass).thenApply(opt -> {
+ opt.ifPresent(handle -> onLoadOrReload.accept(handle.get()));
+ return opt;
+ });
+ }
+
+ @Override
+ public void close() {
+ CLOSED.incrementAndGet();
+ }
+ }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
index 38243976f569..96e2f734c8d3 100644
--- a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
@@ -47,6 +47,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.Cleanup;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
import org.apache.pulsar.common.util.FutureUtil;
import org.asynchttpclient.Request;
import org.asynchttpclient.RequestBuilder;
@@ -244,6 +245,33 @@ public class AsyncHttpConnectorTest {
}
}
+ @Test
+ void tlsFactoryPathBoundsPooledConnectionTtl() throws Exception {
+ // PIP-478: on the https (new TLS-factory) path the AHC connection TTL must be set so a pooled
+ // HTTPS connection cannot keep pre-rotation TLS material indefinitely — new connections pick up
+ // rotation, and this TTL bounds how long an established one survives. Inject a short TTL and assert it
+ // is honored (the default is 5 minutes).
+ int injectedTtlMs = 1234;
+ String property = TlsContextAcquisition.HTTP_TLS_ROTATION_CONNECTION_TTL_PROPERTY;
+ String previous = System.getProperty(property);
+ System.setProperty(property, Integer.toString(injectedTtlMs));
+ try {
+ ClientConfigurationData conf = new ClientConfigurationData();
+ conf.setServiceUrl("https://localhost:65535");
+ @Cleanup
+ AsyncHttpConnector connector = new AsyncHttpConnector(5000, 5000, 5000, 0, conf, false, null);
+ assertTrue(connector.getTlsFactory() != null, "https uses the new PIP-478 TLS-factory path");
+ assertEquals(connector.getConnectionTtlMs(), injectedTtlMs,
+ "pooled HTTPS connection TTL is bounded on the rotating factory path");
+ } finally {
+ if (previous == null) {
+ System.clearProperty(property);
+ } else {
+ System.setProperty(property, previous);
+ }
+ }
+ }
+
@Test
void testRelativeRedirect() throws ExecutionException, InterruptedException {
doTestRedirect("path2");
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api-v5/build.gradle.kts
index 6b2323163684..ed0a215ea2f8 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api-v5/build.gradle.kts
@@ -22,6 +22,11 @@ plugins {
}
dependencies {
+ // PIP-478: the auth SPI's AuthenticationInitContext exposes PulsarHttpClientFactory and the
+ // client builder accepts a TlsPolicy, so the HTTP + TLS factory SPIs are part of this API's
+ // surface; both SPI modules are published public libraries like this one.
+ api(project(":pulsar-tls-factory-api"))
+ api(project(":pulsar-http-client-api"))
compileOnly(libs.protobuf.java)
compileOnly(libs.opentelemetry.api)
}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
index 14dcf8a0cd68..dc96427883ee 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
@@ -23,8 +23,10 @@ import java.time.Duration;
import org.apache.pulsar.client.api.v5.auth.Authentication;
import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
import org.apache.pulsar.client.api.v5.config.TransactionPolicy;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
/**
* Builder for configuring and creating a {@link PulsarClient}.
@@ -55,9 +57,17 @@ public interface PulsarClientBuilder {
PulsarClientBuilder serviceUrl(String serviceUrl);
/**
- * Set the authentication provider.
+ * Set the authentication provider from an instance supplied programmatically.
*
- * @param authentication the authentication provider to use for connecting to the broker
+ * <p><b>Programmatic-path contract.</b> The instance is presumed already configured — the framework
+ * does NOT call {@link Authentication#configure(java.util.Map)} on it (that step runs only for the
+ * reflective {@link #authentication(String, String)} path). The built client <em>adopts</em> the
+ * instance: it drives {@code initializeAsync(...)} before first use and {@link Authentication#close()}
+ * when the client is closed, so callers must not close the instance themselves or share one instance
+ * across clients with differing lifetimes.
+ *
+ * @param authentication the authentication provider to use for connecting to the broker; must already
+ * be configured
* @return this builder instance for chaining
*/
PulsarClientBuilder authentication(Authentication authentication);
@@ -100,16 +110,41 @@ public interface PulsarClientBuilder {
PulsarClientBuilder transactionPolicy(TransactionPolicy policy);
/**
- * Configure TLS for the client connection.
+ * Configure TLS for the client connection (PIP-478), using the neutral
+ * {@link org.apache.pulsar.tls.TlsPolicy} value shared by the client builder and the
+ * server components. The policy is bound to the default client purposes.
*
* @param policy the TLS policy to apply to broker connections
* @return this builder instance for chaining
- * @see TlsPolicy#of(String)
- * @see TlsPolicy#ofMutualTls(String, String, String)
- * @see TlsPolicy#ofInsecure()
+ * @see TlsPolicy#pem(String, String, String)
+ * @see TlsPolicy#keyStore(String, String, String, String, String)
+ * @see TlsPolicy#insecure()
*/
PulsarClientBuilder tlsPolicy(TlsPolicy policy);
+ /**
+ * Configure TLS for a specific {@link TlsPurpose} (PIP-478). Most users call
+ * {@link #tlsPolicy(TlsPolicy)}, which binds the policy to {@link TlsPurpose#CLIENT_DEFAULT}; this
+ * overload additionally lets a user configure a distinct trust domain — for example
+ * {@link TlsPurpose#CLIENT_OAUTH2} for the identity provider — with its own material.
+ *
+ * @param purpose the client TLS purpose the policy applies to
+ * @param policy the TLS policy to apply
+ * @return this builder instance for chaining
+ */
+ PulsarClientBuilder tlsPolicy(TlsPurpose purpose, TlsPolicy policy);
+
+ /**
+ * Provide a custom {@link PulsarTlsFactory} implementation (PIP-478), for advanced deployments that
+ * source TLS material from something other than files (a KMS, an HSM-backed key manager, a
+ * per-destination workload identity). The supplied factory is <em>adopted</em>: the client
+ * initializes it and closes it when the client closes.
+ *
+ * @param factory the TLS factory to adopt
+ * @return this builder instance for chaining
+ */
+ PulsarClientBuilder tlsFactory(PulsarTlsFactory factory);
+
/**
* Provide a custom {@link OpenTelemetry} instance for metrics and tracing.
*
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
index 0e6005cec3a8..f7484d9c3035 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
@@ -83,6 +83,39 @@ public class PulsarClientException extends IOException {
public AuthenticationException(String message) {
super(message);
}
+
+ public AuthenticationException(Throwable cause) {
+ super(cause);
+ }
+ }
+
+ /**
+ * Transient failure while acquiring a credential (token endpoint timeout, ZTS unavailable).
+ * The caller may retry; the connection layer treats it like a network error.
+ */
+ public static final class GettingAuthenticationDataException extends PulsarClientException {
+ public GettingAuthenticationDataException(String message) {
+ super(message);
+ }
+
+ public GettingAuthenticationDataException(Throwable cause) {
+ super(cause);
+ }
+ }
+
+ /**
+ * The requested authentication capability is not supported by the plugin (used by the legacy v4
+ * adapter when a wrapped v4 provider returns null/false from the corresponding {@code hasData*}
+ * method).
+ */
+ public static final class UnsupportedAuthenticationException extends PulsarClientException {
+ public UnsupportedAuthenticationException(String message) {
+ super(message);
+ }
+
+ public UnsupportedAuthenticationException(Throwable cause) {
+ super(cause);
+ }
}
public static final class AuthorizationException extends PulsarClientException {
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
similarity index 56%
copy from pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
copy to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
index f7f22f13a371..a08185d72a0d 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
@@ -16,15 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pulsar.client.api.v5.auth;
/**
- * Authentication and encryption types.
+ * A binary-protocol {@code CommandAuthChallenge} payload handed to a
+ * {@link BinaryAuthChallengeHandler} (PIP-478).
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The framework transfers ownership of
+ * {@code bytes} to this value on construction and the handler reads it without copying; neither
+ * side may mutate the array afterwards. Because the component is an array, the record's generated
+ * {@code equals}/{@code hashCode} are reference-based, not value-based; do not compare
+ * {@link AuthChallenge} instances for value equality.
*
- * <p>Provides pluggable authentication via {@link org.apache.pulsar.client.api.v5.auth.Authentication}
- * and convenience factories in {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory},
- * as well as end-to-end encryption support via
- * {@link org.apache.pulsar.client.api.v5.auth.PublicKeyProvider} (producer side) and
- * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side).
- * For local PEM-file-backed setups, use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
+ * @param bytes the challenge bytes sent by the broker
*/
-package org.apache.pulsar.client.api.v5.auth;
+public record AuthChallenge(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
index 8f4f0bf19d8d..043bb91d09b0 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
@@ -18,53 +18,95 @@
*/
package org.apache.pulsar.client.api.v5.auth;
-import java.io.Closeable;
-import org.apache.pulsar.client.api.v5.PulsarClientException;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
/**
- * Pluggable authentication provider for Pulsar clients.
+ * Pluggable authentication provider for the Pulsar v5 client (PIP-478).
*
- * <p>Implementations must be thread-safe.
+ * <p>The core interface carries only lifecycle. The actual credential work lives on opt-in
+ * <em>capability</em> interfaces — segregated by transport (Pulsar binary protocol vs HTTP) and by
+ * style (single-pass vs multi-round challenge/response) — that an implementation declares only when it
+ * supports them:
+ * <ul>
+ * <li>{@link BinaryAuthDataProvider} — single-pass credential for the binary protocol</li>
+ * <li>{@link HttpAuthHeadersProvider} — single-pass credential for HTTP</li>
+ * <li>{@link BinaryAuthChallengeHandler} — multi-round challenge/response for the binary protocol</li>
+ * <li>{@link HttpAuthChallengeHandler} — SASL-style multi-round over HTTP</li>
+ * </ul>
+ * The convenience composite {@link SinglePassAuthentication} bundles the common single-pass
+ * combination.
+ *
+ * <p>All capability methods are asynchronous ({@link CompletableFuture}), so credential acquisition
+ * never blocks the Netty event loop.
*/
-public interface Authentication extends Closeable {
+public interface Authentication extends AutoCloseable {
/**
- * The authentication method name (e.g., "token", "tls").
+ * Configuration step. Called once by the framework AFTER no-arg construction when the plugin was
+ * loaded reflectively from {@code authPluginClassName} + {@code authParams}. NOT called when the
+ * plugin was constructed programmatically by the user — that path presumes the instance is already
+ * configured. Default: no-op. Implementations override to read their parameters.
*
- * @return the authentication method identifier string
- */
- String authMethodName();
-
- /**
- * Get the authentication data to be sent to the broker.
+ * <p>Configuration is intentionally separate from {@link #initializeAsync}: configuration is static
+ * plugin-level data (file paths, URLs, scopes); initialization gives the plugin runtime services
+ * and may do I/O.
*
- * @return the authentication data containing credentials for the broker
- * @throws PulsarClientException if the authentication data could not be obtained
+ * @param authParams the parsed authentication parameters
*/
- AuthenticationData authData() throws PulsarClientException;
+ default void configure(Map<String, String> authParams) {
+ }
/**
- * Get the authentication data for a specific broker host.
+ * Initialization step. Called once by the framework with runtime services after configuration. May
+ * do I/O; the returned future completes when the implementation is ready to serve credentials.
+ * Never throws synchronously: all failures are reported by completing the returned future
+ * exceptionally, never by throwing on the calling thread.
+ *
+ * <p><b>Must not block the calling thread.</b> The framework may call this from an I/O thread — the
+ * binary driver initializes on the connection path, which runs on a Netty event loop — and it never
+ * waits for the returned future there: the connection's authentication rounds are chained onto it
+ * instead. So start any I/O and return promptly, off-loading blocking work to
+ * {@link AuthenticationInitContext#blockingExecutor()}. Blocking inside this method before returning
+ * the future stalls every connection sharing that event loop.
*
- * <p>The default implementation delegates to {@link #authData()}.
+ * <p>Called at most once per driver while it succeeds. If the returned future completes exceptionally
+ * the framework does not cache the failure: the next connection attempt calls this again, so a
+ * transient initialization failure (an unreachable credential endpoint at startup) recovers on
+ * reconnect rather than poisoning the plugin for the client's lifetime.
*
- * @param brokerHostName the hostname of the broker to authenticate against
- * @return the authentication data containing credentials for the specified broker
- * @throws PulsarClientException if the authentication data could not be obtained
+ * @param ctx the runtime services context
+ * @return a future completing when the plugin is ready
*/
- default AuthenticationData authData(String brokerHostName) throws PulsarClientException {
- return authData();
- }
+ CompletableFuture<Void> initializeAsync(AuthenticationInitContext ctx);
/**
- * Initialize the authentication provider. Called once when the client is created.
+ * Capability factory — the framework's ONLY discovery mechanism. The framework never uses
+ * {@code instanceof} on a plugin instance; it asks this method for each capability it may drive. An
+ * implementation may therefore serve a capability from a separate internal class, or forward a
+ * wrapped delegate's capability, rather than implementing it on this type. The default
+ * implementation returns {@code this} when the plugin implements the requested interface directly,
+ * covering the common case with no override.
+ *
+ * <p>Contract: results are STABLE once {@link #initializeAsync} has completed — the framework may
+ * look a capability up once and cache it for the client's lifetime. The framework never queries
+ * capabilities before {@code initializeAsync} completes; a result returned before initialization is
+ * unspecified. One object may serve several
+ * capabilities. Capability objects are owned by this plugin and released by {@link #close()}; the
+ * framework never closes them individually. All capability methods must tolerate concurrent
+ * invocation (multiple connections authenticate in parallel); only the rounds of a single exchange
+ * are serialized by the framework.
*
- * @throws PulsarClientException if initialization fails
+ * @param kind the capability interface to look up
+ * @param <T> the capability type
+ * @return the capability if supported, otherwise {@link Optional#empty()}
*/
- default void initialize() throws PulsarClientException {
+ default <T> Optional<T> capability(Class<T> kind) {
+ return kind.isInstance(this) ? Optional.of(kind.cast(this)) : Optional.empty();
}
@Override
- default void close() {
+ default void close() throws Exception {
}
}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java
new file mode 100644
index 000000000000..5ded4921c01c
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.Optional;
+
+/**
+ * The per-call context for binary-protocol authentication (PIP-478).
+ *
+ * <p>Carries the broker host and exposes a per-exchange state slot for retaining
+ * conversation state across challenge-response rounds. Most implementations produce the same credential
+ * for every call and never inspect the broker host.
+ */
+public interface AuthenticationCallContext {
+
+ /**
+ * @return the broker host this connection is being established to
+ */
+ String brokerHost();
+
+ /**
+ * Retrieve an implementation-controlled state object previously stored with
+ * {@link #setStateObject}. The slot is keyed by class so the pieces of one plugin — which, under
+ * the capability-factory model, may be separate internal classes participating in the same exchange
+ * (e.g. the initial-data provider and the challenge handler on one binary connect) — can each keep
+ * state without collision; impls typically store one object of their own type (e.g. their SASL
+ * conversation state).
+ *
+ * <p>The slot's lifetime equals one authentication <em>exchange</em>: for the binary protocol, one
+ * connection attempt and all of its ordinary {@code CommandAuthChallenge} / {@code CommandAuthResponse}
+ * challenge rounds. A broker {@code REFRESH_AUTH_DATA} sentinel does not continue this exchange —
+ * {@code ClientCnx} begins a <em>new</em> exchange with a fresh context and a fresh slot — so
+ * conversation state does not survive a refresh. Concurrent authentications to different brokers get
+ * their own context with their own slots, so multiple in-flight handshakes don't collide.
+ *
+ * @param clazz the state object's key class
+ * @param <T> the state object type
+ * @return the stored object, if present
+ */
+ <T> Optional<T> getStateObject(Class<T> clazz);
+
+ /**
+ * Store a state object keyed by its (or any) class; a {@code null} value removes the entry.
+ * Implementations should key with a private class of their own (e.g. their conversation record) to
+ * avoid collisions. Rounds of one exchange are serialized by the framework, so slot access within an
+ * exchange needs no synchronization.
+ *
+ * @param clazz the key class
+ * @param value the state object, or {@code null} to remove the entry
+ * @param <T> the state object type
+ */
+ <T> void setStateObject(Class<T> clazz, T value);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java
deleted file mode 100644
index c8ab63410cf7..000000000000
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.client.api.v5.auth;
-
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Provides authentication credentials for different transport mechanisms.
- */
-public interface AuthenticationData {
-
- // --- HTTP authentication ---
-
- /**
- * Check whether this authentication data provides HTTP headers.
- *
- * @return {@code true} if HTTP authentication headers are available, {@code false} otherwise
- */
- default boolean hasDataForHttp() {
- return false;
- }
-
- /**
- * Get the HTTP authentication headers to include in requests.
- *
- * @return a set of header name-value entries for HTTP authentication, or an empty set if none
- */
- default Set<Map.Entry<String, String>> getHttpHeaders() {
- return Set.of();
- }
-
- // --- TLS mutual authentication ---
-
- /**
- * Check whether this authentication data provides TLS client certificates.
- *
- * @return {@code true} if TLS certificate data is available, {@code false} otherwise
- */
- default boolean hasDataForTls() {
- return false;
- }
-
- /**
- * Get the TLS client certificate chain for mutual authentication.
- *
- * @return the client certificate chain, or {@code null} if not available
- */
- default Certificate[] getTlsCertificates() {
- return null;
- }
-
- /**
- * Get the TLS client private key for mutual authentication.
- *
- * @return the client private key, or {@code null} if not available
- */
- default PrivateKey getTlsPrivateKey() {
- return null;
- }
-
- // --- Binary protocol authentication ---
-
- /**
- * Check whether this authentication data provides binary protocol command data.
- *
- * @return {@code true} if command data is available for the Pulsar binary protocol, {@code false} otherwise
- */
- default boolean hasDataFromCommand() {
- return false;
- }
-
- /**
- * Get the authentication data to include in binary protocol commands.
- *
- * @return the command data string for binary protocol authentication, or {@code null} if not available
- */
- default String getCommandData() {
- return null;
- }
-}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
index 705b2aa429ee..23433a62b824 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
@@ -55,14 +55,18 @@ public final class AuthenticationFactory {
}
/**
- * Create TLS mutual authentication.
+ * Create the built-in TLS mutual-authentication marker plugin (PIP-478).
*
- * @param certFilePath the path to the client certificate file (PEM format)
- * @param keyFilePath the path to the client private key file (PEM format)
- * @return an {@link Authentication} instance configured for TLS mutual authentication
+ * <p>This plugin carries no TLS material of its own: it only advertises the {@code "tls"}
+ * authentication method so the broker authenticates from the client certificate presented during the
+ * TLS handshake. Configure the client certificate, private key, and trust material through the client
+ * builder's {@code tlsPolicy(...)} (e.g. {@code TlsPolicy.pem(trustCerts, certFile, keyFile)}); the
+ * transport reads its material from the client TLS factory.
+ *
+ * @return an {@link Authentication} instance that advertises the {@code "tls"} method
*/
- public static Authentication tls(String certFilePath, String keyFilePath) {
- return PulsarClientProvider.get().authenticationTls(certFilePath, keyFilePath);
+ public static Authentication tls() {
+ return PulsarClientProvider.get().authenticationTls();
}
/**
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java
new file mode 100644
index 000000000000..bccd0845fe7c
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * Runtime services handed to {@link Authentication#initializeAsync} once when the client is built
+ * (PIP-478).
+ *
+ * <p>The framework owns and closes these shared services (HTTP clients, scheduler, blocking executor);
+ * the implementation may retain references for the lifetime of the client and releases its own
+ * resources in {@link Authentication#close()}.
+ */
+public interface AuthenticationInitContext {
+
+ /**
+ * Factory for {@link org.apache.pulsar.http.PulsarHttpClient} instances. The
+ * framework manages lifecycle (event loop, timer, DNS cache, TLS material refresh integration);
+ * plugins describe what they need via a
+ * {@link org.apache.pulsar.http.PulsarHttpClientConfig} and receive a configured
+ * instance owned by the framework. Multiple instances with different TLS / timeouts may be
+ * obtained for different uses (e.g. OAuth2 mTLS to the IdP vs HTTP topic lookup).
+ *
+ * <p>Plugins MUST NOT construct private HTTP clients directly — doing so defeats the framework's
+ * shared event-loop / DNS / refresh integration.
+ *
+ * @return the framework's HTTP client factory
+ */
+ PulsarHttpClientFactory httpClientFactory();
+
+ /**
+ * Scheduler for delayed / periodic authentication work (e.g. proactive credential renewal). Never
+ * the Netty event loop. Reserved for scheduled tasks — potentially-blocking work belongs on
+ * {@link #blockingExecutor()}.
+ *
+ * @return the framework-owned scheduler
+ */
+ ScheduledExecutorService scheduler();
+
+ /**
+ * Dedicated executor for off-loading potentially-blocking authentication work, kept separate from
+ * {@link #scheduler()} so that blocking calls cannot starve the scheduler's threads or delay
+ * scheduled tasks. The {@code LegacyV4AuthenticationAdapter} offloads every synchronous v4 plugin
+ * call here. Never the Netty event loop. Framework-owned and shared per {@code PulsarClient}.
+ *
+ * @return the framework-owned blocking executor
+ */
+ Executor blockingExecutor();
+
+ /**
+ * @return the clock used by implementations that schedule against wall-clock time
+ */
+ Clock clock();
+
+ /**
+ * @return the telemetry root; the framework defaults to {@link OpenTelemetry#noop()} if unset
+ */
+ OpenTelemetry openTelemetry();
+
+ /**
+ * @return a stable id of the owning {@code PulsarClient}, for logging correlation
+ */
+ String clientInstanceId();
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java
new file mode 100644
index 000000000000..32c38c8be7a5
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: multi-round challenge/response (SASL-style and custom protocols) for the Pulsar binary
+ * protocol (PIP-478).
+ *
+ * <p>The broker's {@code REFRESH_AUTH_DATA} refresh sentinel is <em>never</em> delivered here (binary
+ * routing rule 2): a refresh re-produces the current credential through
+ * {@link BinaryAuthDataProvider} on a fresh exchange, so this handler only ever sees a genuine challenge
+ * payload — a real round of the challenge conversation.
+ */
+public interface BinaryAuthChallengeHandler {
+
+ /**
+ * Respond to a binary-protocol {@code CommandAuthChallenge}.
+ *
+ * <p>The framework places the returned bytes in {@code CommandAuthResponse}. Completion is decided
+ * by the broker (it replies with {@code CommandConnected} when satisfied), so the handler does not
+ * signal it; cross-round conversation state is kept in the context's state slot. Never throws
+ * synchronously: all failures are reported by completing the returned future exceptionally, never by
+ * throwing on the calling thread.
+ *
+ * @param ctx the per-call context (holds cross-round state)
+ * @param authChallenge the challenge from the broker
+ * @return a future of the response for the next {@code CommandAuthResponse}
+ */
+ CompletableFuture<ChallengeResponse> respondToChallengeAsync(AuthenticationCallContext ctx,
+ AuthChallenge authChallenge);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java
new file mode 100644
index 000000000000..a3a001da8a5f
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+/**
+ * The binary-protocol credential: the {@code auth_data} bytes for {@code CommandConnect} (PIP-478).
+ *
+ * <p>The auth-method name is deliberately NOT part of this value — it comes from exactly one place,
+ * {@link BinaryAuthDataProvider#authMethodName()}, so a plugin cannot contradict itself (an earlier
+ * draft carried the name on both the provider and the data value, leaving "which one wins?" to the
+ * javadoc). Kept as a record rather than a bare {@code byte[]} so fields can be added later without an
+ * API break.
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The producer transfers ownership of
+ * {@code bytes} to this value on construction, and the framework reads it without copying; neither
+ * side may mutate the array after construction. (Copying is deliberately avoided — this is a
+ * single-producer-to-single-consumer transfer, matching the v4 {@code AuthData} contract.) Because the
+ * component is an array, the record's generated {@code equals}/{@code hashCode} are reference-based on
+ * the bytes, not value-based; do not compare {@link BinaryAuthData} instances for value equality.
+ *
+ * @param bytes the credential bytes sent in {@code CommandConnect.auth_data}
+ */
+public record BinaryAuthData(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java
new file mode 100644
index 000000000000..7e06b6febd3b
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: single-pass credential exchange for the Pulsar binary protocol (PIP-478).
+ *
+ * <p>A plugin implements this when it can present a credential the broker accepts or rejects in a
+ * single round trip (token, basic, OAuth2 access token, Athenz role token, or mTLS via the built-in
+ * {@code TlsAuthentication}). This capability is required for any plugin used on the binary transport.
+ */
+public interface BinaryAuthDataProvider {
+
+ /**
+ * @return the stable identifier sent in {@code CommandConnect.auth_method_name}
+ */
+ String authMethodName();
+
+ /**
+ * Produce a credential for the binary-protocol connection. The returned {@link BinaryAuthData}
+ * carries the {@code auth_data} bytes for {@code CommandConnect}; the framework pairs them with
+ * {@link #authMethodName()}. Completes exceptionally on failure (see the error model in
+ * {@link org.apache.pulsar.client.api.v5.PulsarClientException}). Never throws synchronously: all
+ * failures are reported by completing the returned future exceptionally, never by throwing on the
+ * calling thread.
+ *
+ * @param ctx the per-call context
+ * @return a future of the binary-protocol credential
+ */
+ CompletableFuture<BinaryAuthData> getAuthDataAsync(AuthenticationCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java
new file mode 100644
index 000000000000..b9bc8d58ca1d
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+/**
+ * The reply from {@link BinaryAuthChallengeHandler#respondToChallengeAsync} — the bytes for the next
+ * {@code CommandAuthResponse} (PIP-478).
+ *
+ * <p>There is no completion flag: the broker decides when the handshake is finished (it sends
+ * {@code CommandConnected}); the implementation tracks any cross-round state of its own in the call
+ * context's state slot. Kept as a record rather than a bare {@code byte[]} so fields can be added later
+ * without an API break.
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The handler transfers ownership of
+ * {@code bytes} to this value on construction and the framework reads it without copying;
+ * neither side may mutate the array afterwards. Because the component is an array, the record's
+ * generated {@code equals}/{@code hashCode} are reference-based, not value-based; do not compare
+ * {@link ChallengeResponse} instances for value equality.
+ *
+ * @param bytes the bytes to send in the next {@code CommandAuthResponse}
+ */
+public record ChallengeResponse(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java
new file mode 100644
index 000000000000..bb65ce694e4e
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.net.URI;
+import java.util.Optional;
+
+/**
+ * The per-call context for HTTP authentication (PIP-478).
+ *
+ * <p>Carries the request URI, a per-exchange state slot (with the same contract as on
+ * {@link AuthenticationCallContext}), and — for SASL-style challenge/response — the server's challenge
+ * headers from the prior {@code 401} response.
+ */
+public interface HttpAuthCallContext {
+
+ /**
+ * @return the URI of the request being authenticated
+ */
+ URI requestUri();
+
+ /**
+ * For SASL-style HTTP challenge/response: the challenge headers from the server's prior
+ * {@code 401} response (for example {@code SASL-Token} / {@code State} / {@code SASL-Server-ID}).
+ * Empty on the first request, before any challenge.
+ *
+ * @return the server's challenge headers, if a challenge has been received
+ */
+ Optional<HttpAuthHeaders> serverChallengeHeaders();
+
+ /**
+ * Retrieve an implementation-controlled state object previously stored with
+ * {@link #setStateObject}. The slot is keyed by class; its lifetime equals one HTTP request's retry
+ * sequence. See {@link AuthenticationCallContext#getStateObject} for the full contract.
+ *
+ * @param clazz the state object's key class
+ * @param <T> the state object type
+ * @return the stored object, if present
+ */
+ <T> Optional<T> getStateObject(Class<T> clazz);
+
+ /**
+ * Store a state object keyed by its (or any) class; a {@code null} value removes the entry. Rounds
+ * of one exchange are serialized by the framework, so slot access within an exchange needs no
+ * synchronization.
+ *
+ * @param clazz the key class
+ * @param value the state object, or {@code null} to remove the entry
+ * @param <T> the state object type
+ */
+ <T> void setStateObject(Class<T> clazz, T value);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java
new file mode 100644
index 000000000000..1ba69c4df0d8
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: SASL-style multi-round challenge/response over HTTP (PIP-478).
+ *
+ * <p>Handles the existing Pulsar SASL-over-HTTP mechanism, where the server returns HTTP {@code 401}
+ * carrying the challenge in Pulsar's custom SASL headers (for example {@code SASL-Token} /
+ * {@code State} / {@code SASL-Server-ID}) rather than the standard {@code WWW-Authenticate} header.
+ *
+ * <p>Standard {@code WWW-Authenticate}-based schemes (for example HTTP digest) are expected to be
+ * served by a separate sibling interface; the framework's HTTP auth driver dispatches to whichever a
+ * plugin implements.
+ */
+public interface HttpAuthChallengeHandler {
+
+ /**
+ * Compute the headers for the next round.
+ *
+ * <p>The server's challenge headers from the prior {@code 401} are available via
+ * {@link HttpAuthCallContext#serverChallengeHeaders()}; the returned {@link HttpAuthHeaders} are
+ * attached to the resubmitted request. Cross-round conversation state is kept in the context's
+ * state slot. Never throws synchronously: all failures are reported by completing the returned
+ * future exceptionally, never by throwing on the calling thread.
+ *
+ * @param ctx the per-call context (holds the server challenge and cross-round state)
+ * @return a future of the headers for the resubmitted request
+ */
+ CompletableFuture<HttpAuthHeaders> respondToHttpChallengeAsync(HttpAuthCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java
new file mode 100644
index 000000000000..82b3d5031961
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * HTTP authentication headers an implementation produces for an outgoing request (and, for
+ * challenge/response, the headers carrying a server's challenge) (PIP-478).
+ *
+ * <p>Header names are canonicalised on construction (RFC 7230 §3.2 treats them case-insensitively);
+ * {@link #get(String)} is case-insensitive; {@link #asMap()} returns a stable canonical-cased view.
+ *
+ * <p><b>Single value per name.</b> This collapses to one value per canonical name: when the source map
+ * passed to {@link #of(Map)} holds two entries that canonicalise to the same name (case-variants such as
+ * {@code Authorization} and {@code authorization}), the one visited last in the source map's iteration
+ * order wins. Multi-valued headers are not represented.
+ */
+public final class HttpAuthHeaders {
+
+ private static final HttpAuthHeaders EMPTY = new HttpAuthHeaders(Collections.emptyMap());
+
+ private final Map<String, String> headers;
+
+ private HttpAuthHeaders(Map<String, String> headers) {
+ this.headers = headers;
+ }
+
+ /**
+ * @return a shared empty instance
+ */
+ public static HttpAuthHeaders empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Create headers with a single entry.
+ *
+ * @param name the header name
+ * @param value the header value
+ * @return a new {@link HttpAuthHeaders}
+ */
+ public static HttpAuthHeaders of(String name, String value) {
+ Map<String, String> map = new LinkedHashMap<>(1);
+ map.put(canonical(name), value);
+ return new HttpAuthHeaders(Collections.unmodifiableMap(map));
+ }
+
+ /**
+ * Create headers from a map. Names are canonicalised; two source entries that canonicalise to the
+ * same name (case-variants) collapse to one, and the entry visited last in {@code headers}' iteration
+ * order wins.
+ *
+ * @param headers the source headers (names canonicalised)
+ * @return a new {@link HttpAuthHeaders}
+ */
+ public static HttpAuthHeaders of(Map<String, String> headers) {
+ if (headers == null || headers.isEmpty()) {
+ return EMPTY;
+ }
+ Map<String, String> map = new LinkedHashMap<>(headers.size());
+ headers.forEach((k, v) -> map.put(canonical(k), v));
+ return new HttpAuthHeaders(Collections.unmodifiableMap(map));
+ }
+
+ /**
+ * Look up a header value, case-insensitively.
+ *
+ * @param name the header name
+ * @return the header value if present
+ */
+ public Optional<String> get(String name) {
+ return Optional.ofNullable(headers.get(canonical(name)));
+ }
+
+ /**
+ * @return an unmodifiable, canonical-cased view of the headers
+ */
+ public Map<String, String> asMap() {
+ return headers;
+ }
+
+ /**
+ * @return {@code true} if there are no headers
+ */
+ public boolean isEmpty() {
+ return headers.isEmpty();
+ }
+
+ private static String canonical(String name) {
+ return name == null ? null : name.toLowerCase(Locale.ROOT);
+ }
+
+ @Override
+ public String toString() {
+ return "HttpAuthHeaders" + headers.keySet();
+ }
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java
new file mode 100644
index 000000000000..b13a8eb974b6
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: single-pass credential exchange for the Pulsar HTTP transport (PIP-478).
+ */
+public interface HttpAuthHeadersProvider {
+
+ /**
+ * Produce the authentication headers for an outgoing HTTP request (for example
+ * {@code Authorization: Bearer <jwt>}, or a custom header such as {@code Athenz-Role-Auth}).
+ *
+ * <p>The returned {@link HttpAuthHeaders} are attached to the request; most implementations produce
+ * the same credential for every call. Completes exceptionally on failure (see the error model in
+ * {@link org.apache.pulsar.client.api.v5.PulsarClientException}). Never throws synchronously: all
+ * failures are reported by completing the returned future exceptionally, never by throwing on the
+ * calling thread.
+ *
+ * @param ctx the per-call context
+ * @return a future of the HTTP authentication headers
+ */
+ CompletableFuture<HttpAuthHeaders> getHttpHeadersAsync(HttpAuthCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
similarity index 65%
copy from pulsar-client-api-v5/build.gradle.kts
copy to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
index 6b2323163684..caad9ea92f5f 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
@@ -16,12 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pulsar.client.api.v5.auth;
-plugins {
- id("pulsar.public-java-library-conventions")
-}
-
-dependencies {
- compileOnly(libs.protobuf.java)
- compileOnly(libs.opentelemetry.api)
+/**
+ * Convenience composite: a single-pass credential served over both transports (PIP-478).
+ *
+ * <p>Adds no methods — an implementation may declare this instead of listing
+ * {@link BinaryAuthDataProvider} and {@link HttpAuthHeadersProvider} separately.
+ */
+public interface SinglePassAuthentication
+ extends Authentication, BinaryAuthDataProvider, HttpAuthHeadersProvider {
}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
index f7f22f13a371..68c57aa8f163 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
@@ -18,13 +18,58 @@
*/
/**
- * Authentication and encryption types.
+ * Pluggable authentication (and end-to-end encryption) types for the v5 client (PIP-478).
*
- * <p>Provides pluggable authentication via {@link org.apache.pulsar.client.api.v5.auth.Authentication}
- * and convenience factories in {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory},
- * as well as end-to-end encryption support via
+ * <p>{@link org.apache.pulsar.client.api.v5.auth.Authentication} carries only lifecycle
+ * ({@code configure} / {@code initializeAsync} / {@code capability} / {@code close}); the credential work
+ * lives on opt-in <em>capability</em> interfaces the framework discovers <em>only</em> through
+ * {@link org.apache.pulsar.client.api.v5.auth.Authentication#capability(Class)} — never
+ * {@code instanceof}. The capabilities are segregated on two axes (transport × style):
+ *
+ * <table border="1">
+ * <caption>The capability matrix</caption>
+ * <tr><th></th><th>Pulsar binary protocol</th><th>HTTP</th></tr>
+ * <tr><td><b>Single-pass</b></td>
+ * <td>{@link org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider}</td>
+ * <td>{@link org.apache.pulsar.client.api.v5.auth.HttpAuthHeadersProvider}</td></tr>
+ * <tr><td><b>Multi-round challenge/response</b></td>
+ * <td>{@link org.apache.pulsar.client.api.v5.auth.BinaryAuthChallengeHandler}</td>
+ * <td>{@link org.apache.pulsar.client.api.v5.auth.HttpAuthChallengeHandler}</td></tr>
+ * </table>
+ *
+ * <p>The convenience composite {@link org.apache.pulsar.client.api.v5.auth.SinglePassAuthentication}
+ * bundles the common single-pass-over-both-transports combination. All capability methods are
+ * asynchronous, so credential acquisition never blocks the Netty event loop.
+ *
+ * <p><b>Two configuration paths.</b>
+ * <ul>
+ * <li><em>Programmatic</em> — build and configure an instance yourself, then pass it to the builder's
+ * {@code authentication(Authentication)}. {@link org.apache.pulsar.client.api.v5.auth.Authentication#configure}
+ * is NOT called (the instance is presumed already configured). The convenience factories in
+ * {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory} return such instances.</li>
+ * <li><em>Reflective by class name</em> — {@code authentication(authPluginClassName, authParams)}: the
+ * framework loads the class, constructs it no-arg, and calls
+ * {@link org.apache.pulsar.client.api.v5.auth.Authentication#configure} with the parsed params. A
+ * class implementing only this v5 SPI works over this path exactly like a built-in.</li>
+ * </ul>
+ *
+ * <p><b>Binary routing rules (normative).</b> For every plugin, {@code ClientCnx} routes as:
+ * <ol>
+ * <li><b>Initial connect</b> → {@link org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider};
+ * this capability is required for the binary transport — a plugin that does not expose it fails at
+ * client build with an {@code UnsupportedAuthenticationException}.</li>
+ * <li><b>The refresh sentinel</b> ({@code AuthData.REFRESH_AUTH_DATA_BYTES}) → the current
+ * credential is re-produced through {@code BinaryAuthDataProvider} on a <em>fresh</em> exchange; the
+ * sentinel is filtered out and never reaches the challenge handler.</li>
+ * <li><b>Any other challenge</b> →
+ * {@link org.apache.pulsar.client.api.v5.auth.BinaryAuthChallengeHandler}; a plugin that receives a
+ * challenge without exposing the handler fails the connection with an
+ * {@code AuthenticationException}.</li>
+ * </ol>
+ *
+ * <p>The package also provides end-to-end encryption support via
* {@link org.apache.pulsar.client.api.v5.auth.PublicKeyProvider} (producer side) and
- * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side).
- * For local PEM-file-backed setups, use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
+ * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side); for local
+ * PEM-file-backed setups use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
*/
package org.apache.pulsar.client.api.v5.auth;
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java
deleted file mode 100644
index ebd2e8ca1963..000000000000
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.client.api.v5.config;
-
-import lombok.EqualsAndHashCode;
-import lombok.ToString;
-
-/**
- * TLS configuration for the Pulsar client connection.
- *
- * <p>Construct via {@link #builder()}, or use {@link #ofInsecure()} for development.
- */
-@EqualsAndHashCode
-@ToString
-public final class TlsPolicy {
-
- private final String trustCertsFilePath;
- private final String keyFilePath;
- private final String certificateFilePath;
- private final boolean allowInsecureConnection;
- private final boolean enableHostnameVerification;
-
- private TlsPolicy(String trustCertsFilePath, String keyFilePath, String certificateFilePath,
- boolean allowInsecureConnection, boolean enableHostnameVerification) {
- this.trustCertsFilePath = trustCertsFilePath;
- this.keyFilePath = keyFilePath;
- this.certificateFilePath = certificateFilePath;
- this.allowInsecureConnection = allowInsecureConnection;
- this.enableHostnameVerification = enableHostnameVerification;
- }
-
- /**
- * @return path to the trusted CA certificate file (PEM), or {@code null} when not set
- */
- public String trustCertsFilePath() {
- return trustCertsFilePath;
- }
-
- /**
- * @return path to the client private key file (PEM), or {@code null} when not using mTLS
- */
- public String keyFilePath() {
- return keyFilePath;
- }
-
- /**
- * @return path to the client certificate file (PEM), or {@code null} when not using mTLS
- */
- public String certificateFilePath() {
- return certificateFilePath;
- }
-
- /**
- * @return whether connections to brokers with untrusted certificates are allowed
- */
- public boolean allowInsecureConnection() {
- return allowInsecureConnection;
- }
-
- /**
- * @return whether the broker hostname is verified against the certificate
- */
- public boolean enableHostnameVerification() {
- return enableHostnameVerification;
- }
-
- /**
- * Create an insecure TLS policy that accepts any certificate (for development only).
- *
- * @return a {@link TlsPolicy} with insecure connections allowed and hostname verification disabled
- */
- public static TlsPolicy ofInsecure() {
- return new TlsPolicy(null, null, null, true, false);
- }
-
- /**
- * @return a new builder for constructing a {@link TlsPolicy}
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Builder for {@link TlsPolicy}.
- */
- public static final class Builder {
- private String trustCertsFilePath;
- private String keyFilePath;
- private String certificateFilePath;
- private boolean allowInsecureConnection = false;
- private boolean enableHostnameVerification = true;
-
- private Builder() {
- }
-
- /**
- * Path to the trusted CA certificate file (PEM format).
- *
- * @param trustCertsFilePath the trust store path
- * @return this builder
- */
- public Builder trustCertsFilePath(String trustCertsFilePath) {
- this.trustCertsFilePath = trustCertsFilePath;
- return this;
- }
-
- /**
- * Path to the client private key file (PEM format) for mutual TLS.
- *
- * @param keyFilePath the client key path
- * @return this builder
- */
- public Builder keyFilePath(String keyFilePath) {
- this.keyFilePath = keyFilePath;
- return this;
- }
-
- /**
- * Path to the client certificate file (PEM format) for mutual TLS.
- *
- * @param certificateFilePath the client certificate path
- * @return this builder
- */
- public Builder certificateFilePath(String certificateFilePath) {
- this.certificateFilePath = certificateFilePath;
- return this;
- }
-
- /**
- * Whether to allow connecting to brokers with untrusted certificates.
- * Default {@code false}. Enable only for development.
- *
- * @param allowInsecureConnection whether to skip certificate validation
- * @return this builder
- */
- public Builder allowInsecureConnection(boolean allowInsecureConnection) {
- this.allowInsecureConnection = allowInsecureConnection;
- return this;
- }
-
- /**
- * Whether to verify the broker hostname against the certificate. Default
- * {@code true}.
- *
- * @param enableHostnameVerification whether to verify the hostname
- * @return this builder
- */
- public Builder enableHostnameVerification(boolean enableHostnameVerification) {
- this.enableHostnameVerification = enableHostnameVerification;
- return this;
- }
-
- /**
- * @return a new {@link TlsPolicy} instance
- */
- public TlsPolicy build() {
- return new TlsPolicy(trustCertsFilePath, keyFilePath, certificateFilePath,
- allowInsecureConnection, enableHostnameVerification);
- }
- }
-}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
index afc47322a1c0..c2eacd980e15 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
@@ -22,8 +22,8 @@
*
* <p>Immutable records used to group related configuration parameters in builder APIs,
* such as {@link org.apache.pulsar.client.api.v5.config.BatchingPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.TlsPolicy}, and
- * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}.
+ * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy}, and
+ * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}. TLS configuration lives in the
+ * neutral {@link org.apache.pulsar.tls.TlsPolicy} value (PIP-478).
*/
package org.apache.pulsar.client.api.v5.config;
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java
new file mode 100644
index 000000000000..3e8a00bc8139
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.internal;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * The framework-owned runtime services a {@code PulsarClient} late-binds into its authentication driver
+ * once the client instance exists (PIP-478).
+ *
+ * <p>An {@code Authentication} plugin is configured on the client builder <em>before</em> the client is
+ * constructed (via {@code conf.setAuthentication(...)}), so these services cannot be supplied at plugin
+ * construction time. The client therefore binds them right after it has created its own executors and
+ * HTTP client factory, but <em>before</em> it starts the plugin — see
+ * {@link ClientAuthenticationServicesAware}. The services mirror the fields the plugin ultimately sees
+ * through its {@link org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext}.
+ *
+ * <p>The framework owns and closes these shared services; a driver may retain the reference for the
+ * lifetime of the client.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not consume
+ * this type; it is passed between the framework's own client and its authentication bridges.
+ */
+public interface ClientAuthenticationServices {
+
+ /**
+ * @return the framework's AsyncHttpClient-backed HTTP client factory, sharing the owning client's
+ * event loop / timer / DNS resources; never {@code null}
+ */
+ PulsarHttpClientFactory httpClientFactory();
+
+ /**
+ * @return the framework-owned scheduler for delayed / periodic authentication work; never the Netty
+ * event loop
+ */
+ ScheduledExecutorService scheduler();
+
+ /**
+ * @return the framework-owned bounded executor for off-loading potentially-blocking authentication
+ * work (credential I/O, legacy v4 plugin calls); never the Netty event loop
+ */
+ Executor blockingExecutor();
+
+ /**
+ * @return the clock implementations schedule against
+ */
+ Clock clock();
+
+ /**
+ * @return the telemetry root ({@link OpenTelemetry#noop()} when unset)
+ */
+ OpenTelemetry openTelemetry();
+
+ /**
+ * @return a stable id of the owning {@code PulsarClient}, for logging correlation
+ */
+ String clientInstanceId();
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java
new file mode 100644
index 000000000000..a55561b8ab99
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.v5.internal;
+
+/**
+ * Implemented by an authentication driver that wants the framework's real client services late-bound
+ * into it after the {@code PulsarClient} is constructed (PIP-478).
+ *
+ * <p>The client calls {@link #bindClientAuthenticationServices} exactly once, on
+ * {@code conf.getAuthentication()}, <em>before</em> {@code Authentication.start()} — so the bound
+ * services (real scheduler, bounded blocking executor, HTTP client factory, client instance id) are
+ * visible to the plugin's {@code initializeAsync(...)} and to every capability call thereafter. A driver
+ * that is never bound (a plain v4 plugin, or one used outside a client) simply keeps its unbound
+ * behaviour: credential work runs inline on the caller thread rather than being off-loaded.
+ *
+ * <p>Both the built-in v4 auth shims (which drive a v5-native body) and the v5 → v4
+ * bridge ({@code V5ToV4AuthenticationAdapter}, exposing a genuinely v5-native plugin) implement this so
+ * that the same late-binding path serves the v4-client and v5-client cases uniformly.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not implement
+ * this interface; it is observed and driven by the framework.
+ */
+public interface ClientAuthenticationServicesAware {
+
+ /**
+ * Bind the framework's client services into this driver. Called once, before the plugin is started.
+ *
+ * @param services the framework-owned client services
+ */
+ void bindClientAuthenticationServices(ClientAuthenticationServices services);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
index 9d3e41e2d125..6277f4745738 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
@@ -99,7 +99,7 @@ public interface PulsarClientProvider {
Authentication authenticationToken(Supplier<String> tokenSupplier);
- Authentication authenticationTls(String certFilePath, String keyFilePath);
+ Authentication authenticationTls();
Authentication createAuthentication(String className, String params) throws PulsarClientException;
diff --git a/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java b/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
index 1218c8ca27c8..de8ad86bd254 100644
--- a/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
+++ b/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
@@ -40,8 +40,8 @@ import org.apache.pulsar.client.api.v5.config.MemorySize;
import org.apache.pulsar.client.api.v5.config.ProcessingTimeoutPolicy;
import org.apache.pulsar.client.api.v5.config.ProducerEncryptionPolicy;
import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.tls.TlsPolicy;
/**
* Usage examples for the Pulsar v5 client API.
diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
index 64b8d60e4740..2966ca993bc4 100644
--- a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
@@ -739,18 +739,25 @@ public interface ClientBuilder extends Serializable, Cloneable {
ClientBuilder socks5ProxyScope(Socks5ProxyScope socks5ProxyScope);
/**
- * Set the SSL Factory Plugin for custom implementation to create SSL Context and SSLEngine.
- * @param sslFactoryPlugin ssl factory class name
+ * Set the class name of a custom {@code PulsarTlsFactory} (PIP-478) used to build the client's TLS
+ * engines. An empty value or the literal {@code "default"} selects the built-in file-based factory
+ * composed from the {@code tls*} settings; any other value is instantiated reflectively via its public
+ * no-arg constructor. This is the by-name successor of the removed PIP-337 {@code sslFactoryPlugin}.
+ *
+ * @param tlsFactoryClassName the {@code PulsarTlsFactory} class name (blank/{@code default} for built-in)
* @return the client builder instance
*/
- ClientBuilder sslFactoryPlugin(String sslFactoryPlugin);
+ ClientBuilder tlsFactoryClassName(String tlsFactoryClassName);
/**
- * Set the SSL Factory Plugin params for the ssl factory plugin to use.
- * @param sslFactoryPluginParams Params in String format that will be inputted to the SSL Factory Plugin
+ * Set the configuration parameters passed to a custom {@link #tlsFactoryClassName(String)} as its init
+ * params (PIP-478). Accepts a JSON object or a comma-separated {@code key=value} list; ignored by the
+ * built-in file-based factory.
+ *
+ * @param tlsFactoryConfig the factory configuration (JSON object or {@code key=value} list)
* @return the client builder instance
*/
- ClientBuilder sslFactoryPluginParams(String sslFactoryPluginParams);
+ ClientBuilder tlsFactoryConfig(String tlsFactoryConfig);
/**
* Set Cert Refresh interval in seconds.
diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java
new file mode 100644
index 000000000000..f903546fe64a
--- /dev/null
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.api.internal;
+
+import java.util.concurrent.CompletableFuture;
+import org.apache.pulsar.common.api.AuthData;
+
+/**
+ * Stable-internal marker that lets {@code ClientCnx} drive authentication asynchronously (PIP-478).
+ *
+ * <p>This is the only carve-out from the otherwise-untouched v4 client. When the configured
+ * {@link org.apache.pulsar.client.api.Authentication} instance also implements this interface,
+ * {@code ClientCnx} routes connect / refresh / challenge handling through these non-blocking methods
+ * and pipes the result back onto the Netty event loop; plain v4 instances keep the existing
+ * synchronous path verbatim. It is generic across all challenge types (initial connect, the broker's
+ * {@code REFRESH_AUTH_DATA} sentinel, SASL multi-round, and custom challenge/response).
+ *
+ * <p><b>Exchange-scoped.</b> Authentication is driven per <em>exchange</em> — one connection attempt.
+ * {@code ClientCnx} calls {@link #newAuthenticationExchange(String)} once per connect and drives that
+ * connection's rounds through the single returned {@link AuthenticationExchange} — the initial credential
+ * via {@link AuthenticationExchange#getAuthDataAsync()} and every ordinary challenge round (SASL
+ * multi-round, custom challenge/response) via {@link AuthenticationExchange#authenticateAsync(AuthData)}.
+ * The exchange is where per-connection conversation state lives (for the v5 bridge, one call-context and
+ * its state slot), so binding it to the exchange object — rather than passing the host on each call —
+ * makes the state slot's lifetime exactly one exchange. The broker's {@code REFRESH_AUTH_DATA} sentinel
+ * does <em>not</em> ride the current exchange: {@code ClientCnx} terminates it and starts a <em>fresh</em>
+ * exchange whose {@link AuthenticationExchange#getAuthDataAsync()} re-produces the current credential, so
+ * conversation state does NOT survive a REFRESH. A fresh exchange is likewise created for every connection
+ * attempt, so concurrent handshakes to different brokers never collide.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not
+ * implement this interface; it is observed by the framework.
+ */
+public interface AsyncAuthenticationDriver {
+
+ /**
+ * Begin an authentication exchange for a single connection attempt to the given broker.
+ *
+ * <p>The returned {@link AuthenticationExchange} owns all state for this one connection setup and is
+ * driven by {@code ClientCnx} for every round of the handshake. Create a new exchange per connection
+ * attempt; do not share one across connections.
+ *
+ * @param brokerHostName the broker host being connected to (for per-host credentials)
+ * @return a new, single-use exchange scoped to this connection attempt
+ */
+ AuthenticationExchange newAuthenticationExchange(String brokerHostName);
+
+ /**
+ * A single authentication exchange: the credential rounds of one {@code ClientCnx} connection setup.
+ *
+ * <p>An exchange is <em>not</em> thread-safe and is not reused across connection attempts. Its rounds
+ * are serialized by the caller ({@code ClientCnx} issues the next round only after the previous
+ * future completes), so an implementation needs no internal synchronization for its per-exchange
+ * conversation state. Every future-returning method reports failures by completing the returned
+ * future exceptionally; it never throws on the calling thread.
+ */
+ interface AuthenticationExchange {
+
+ /**
+ * Asynchronously produce the initial authentication data for {@code CommandConnect}.
+ *
+ * @return a future of the auth data; completes exceptionally on failure
+ */
+ CompletableFuture<AuthData> getAuthDataAsync();
+
+ /**
+ * Asynchronously compute the response to a broker {@code CommandAuthChallenge} — an ordinary
+ * challenge round (SASL multi-round or custom challenge/response), answered from this exchange's
+ * conversation state.
+ *
+ * <p>The broker's {@code REFRESH_AUTH_DATA} sentinel is <em>not</em> delivered here: {@code ClientCnx}
+ * services a refresh by starting a fresh exchange and calling {@link #getAuthDataAsync()} on it, so
+ * in the {@code ClientCnx} flow this method only ever sees a genuine challenge payload.
+ *
+ * @param challenge the challenge payload from the broker (not the refresh sentinel)
+ * @return a future of the response auth data; completes exceptionally on failure
+ */
+ CompletableFuture<AuthData> authenticateAsync(AuthData challenge);
+ }
+}
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
similarity index 76%
copy from pulsar-client-api-v5/build.gradle.kts
copy to pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
index 6b2323163684..3828c2ff7808 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
@@ -17,11 +17,8 @@
* under the License.
*/
-plugins {
- id("pulsar.public-java-library-conventions")
-}
-
-dependencies {
- compileOnly(libs.protobuf.java)
- compileOnly(libs.opentelemetry.api)
-}
+/**
+ * Stable-internal client API types observed by the framework but not intended for application
+ * implementation. See {@link org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver}.
+ */
+package org.apache.pulsar.client.api.internal;
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
index 9f3aa68ca8c3..cb88366449db 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
@@ -365,13 +365,20 @@ public class CmdClusters extends CmdBase {
description = "path for the TLS certificate file", required = false)
protected String brokerClientCertificateFilePath;
- @Option(names = "--tls-factory-plugin",
- description = "TLS Factory Plugin to be used to generate SSL Context and SSL Engine")
- protected String brokerClientSslFactoryPlugin;
-
- @Option(names = "--tls-factory-plugin-params",
- description = "Parameters used by the TLS Factory Plugin")
- protected String brokerClientSslFactoryPluginParams;
+ @Option(names = "--tls-factory-class-name",
+ description = "PulsarTlsFactory class name used for outbound connections to this cluster — the "
+ + "two legs configured from this cluster entry, the binary-protocol replication client "
+ + "and the cross-cluster admin client. It does not reach the peer-cluster lookup "
+ + "client, which stays broker-level (as in 4.x). "
+ + "Leave unset to inherit the broker's brokerClientTlsFactoryClassName.")
+ protected String brokerClientTlsFactoryClassName;
+
+ @Option(names = "--tls-factory-config",
+ description = "Configuration passed to --tls-factory-class-name as its init params, either a "
+ + "JSON object or a key=value list. Follows --tls-factory-class-name rather than "
+ + "inheriting on its own: used verbatim when this cluster names a factory, ignored "
+ + "otherwise.")
+ protected String brokerClientTlsFactoryConfig;
@Option(names = "--listener-name",
description = "listenerName when client would like to connect to cluster", required = false)
@@ -449,11 +456,13 @@ public class CmdClusters extends CmdBase {
if (brokerClientCertificateFilePath != null) {
builder.brokerClientCertificateFilePath(brokerClientCertificateFilePath);
}
- if (StringUtils.isNotBlank(brokerClientSslFactoryPlugin)) {
- builder.brokerClientSslFactoryPlugin(brokerClientSslFactoryPlugin);
+
+ if (brokerClientTlsFactoryClassName != null) {
+ builder.brokerClientTlsFactoryClassName(brokerClientTlsFactoryClassName);
}
- if (StringUtils.isNotBlank(brokerClientSslFactoryPluginParams)) {
- builder.brokerClientSslFactoryPluginParams(brokerClientSslFactoryPluginParams);
+
+ if (brokerClientTlsFactoryConfig != null) {
+ builder.brokerClientTlsFactoryConfig(brokerClientTlsFactoryConfig);
}
if (listenerName != null) {
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
index 481f8266ed09..7bbb54e4a596 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
@@ -34,9 +34,9 @@ import org.apache.pulsar.client.api.v5.PulsarClient;
import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
import org.apache.pulsar.internal.CommandHook;
import org.apache.pulsar.internal.CommanderFactory;
+import org.apache.pulsar.tls.TlsPolicy;
import picocli.CommandLine;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
@@ -207,38 +207,63 @@ public class PulsarClientTool implements CommandHook {
* Translate the client.conf TLS settings onto the typed V5 {@link TlsPolicy}. V5 has no
* untyped {@code loadConf}, so the conf-file keys that have no dedicated CLI flag
* ({@code tlsAllowInsecureConnection}, {@code tlsEnableHostnameVerification}, the mTLS
- * cert/key paths) are read from the properties here.
+ * cert/key paths, the {@code useKeyStoreTls} keystore keys) are read from the properties here.
*
* <p>TLS is enabled only when the service URL uses {@code pulsar+ssl://} or the conf sets
* {@code useTls=true}; otherwise we leave the policy untouched so a plaintext broker is not
* accidentally contacted over TLS (calling {@code tlsPolicy()} always flips {@code useTls}
- * on). Keystore TLS has no V5 equivalent and is reported as unsupported.
+ * on). Per PIP-478 hostname verification defaults ON ({@link TlsPolicy}'s secure default);
+ * an explicit {@code tlsEnableHostnameVerification=false} in the conf still opts out. When
+ * {@code useKeyStoreTls=true}, the v4 keystore keys ({@code tlsTrustStorePath},
+ * {@code tlsKeyStorePath}, ...) map onto a {@link TlsPolicy.Format#KEYSTORE} policy instead
+ * of the PEM paths. The {@code jsseProvider} conf key is honored in both formats so a
+ * FIPS/provider-specific endpoint (e.g. {@code BCJSSE}) is not silently served by the
+ * platform provider.
*/
- private void applyTlsPolicy(PulsarClientBuilder clientBuilder, String serviceUrl, Properties properties) {
+ @VisibleForTesting
+ void applyTlsPolicy(PulsarClientBuilder clientBuilder, String serviceUrl, Properties properties) {
boolean tlsByUrl = serviceUrl != null && serviceUrl.startsWith("pulsar+ssl://");
boolean tlsByConf = Boolean.parseBoolean(properties.getProperty("useTls", "false"));
if (!tlsByUrl && !tlsByConf) {
return;
}
- if (Boolean.parseBoolean(properties.getProperty("useKeyStoreTls", "false"))) {
- commander.getErr().println("Warning: keystore TLS (useKeyStoreTls) is not supported by the "
- + "V5-based pulsar-client; PEM trust/cert/key settings are used instead.");
- }
TlsPolicy.Builder tls = TlsPolicy.builder()
.allowInsecureConnection(
- Boolean.parseBoolean(properties.getProperty("tlsAllowInsecureConnection", "false")))
- .enableHostnameVerification(
- Boolean.parseBoolean(properties.getProperty("tlsEnableHostnameVerification", "false")));
- if (isNotBlank(rootParams.tlsTrustCertsFilePath)) {
- tls.trustCertsFilePath(rootParams.tlsTrustCertsFilePath);
+ Boolean.parseBoolean(properties.getProperty("tlsAllowInsecureConnection", "false")));
+ // Only an explicit conf value may override the builder's secure default (verification ON);
+ // an absent key must not silently disable it.
+ String hostnameVerification = properties.getProperty("tlsEnableHostnameVerification");
+ if (hostnameVerification != null) {
+ tls.enableHostnameVerification(Boolean.parseBoolean(hostnameVerification));
}
- String certFile = properties.getProperty("tlsCertificateFilePath");
- if (isNotBlank(certFile)) {
- tls.certificateFilePath(certFile);
+ if (Boolean.parseBoolean(properties.getProperty("useKeyStoreTls", "false"))) {
+ // The store types default to JKS, matching the v4 client.conf semantics; TlsPolicy's
+ // null means KeyStore.getDefaultType() (PKCS12 on current JDKs), which would break
+ // existing confs that rely on the v4 JKS default.
+ tls.format(TlsPolicy.Format.KEYSTORE)
+ .trustStorePath(properties.getProperty("tlsTrustStorePath"))
+ .trustStorePassword(properties.getProperty("tlsTrustStorePassword"))
+ .trustStoreType(properties.getProperty("tlsTrustStoreType", "JKS"))
+ .keyStorePath(properties.getProperty("tlsKeyStorePath"))
+ .keyStorePassword(properties.getProperty("tlsKeyStorePassword"))
+ .keyStoreType(properties.getProperty("tlsKeyStoreType", "JKS"));
+ } else {
+ if (isNotBlank(rootParams.tlsTrustCertsFilePath)) {
+ tls.trustCertsFilePath(rootParams.tlsTrustCertsFilePath);
+ }
+ String certFile = properties.getProperty("tlsCertificateFilePath");
+ if (isNotBlank(certFile)) {
+ tls.certificateFilePath(certFile);
+ }
+ String keyFile = properties.getProperty("tlsKeyFilePath");
+ if (isNotBlank(keyFile)) {
+ tls.keyFilePath(keyFile);
+ }
}
- String keyFile = properties.getProperty("tlsKeyFilePath");
- if (isNotBlank(keyFile)) {
- tls.keyFilePath(keyFile);
+ // Format-independent: the JSSE (SSLContext) provider applies to both PEM and KEYSTORE.
+ String jsseProvider = properties.getProperty("jsseProvider");
+ if (isNotBlank(jsseProvider)) {
+ tls.jsseProvider(jsseProvider);
}
clientBuilder.tlsPolicy(tls.build());
}
diff --git a/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java b/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
index bdac206cc7f1..ed40aff3dbfa 100644
--- a/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
+++ b/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
@@ -96,6 +96,27 @@ public class TestCmdClusters {
.build();
}
+ @Test
+ public void testTlsFactoryOptions() throws Exception {
+ // PIP-478: a cluster entry may select its own PulsarTlsFactory for outbound connections to that
+ // remote cluster — the two legs configured from the cluster entry, the binary replication client
+ // and the cross-cluster admin client (not the broker-level peer-cluster lookup client).
+ ClusterData expected = ClusterData.builder()
+ .brokerClientTlsFactoryClassName("com.example.MyTlsFactory")
+ .brokerClientTlsFactoryConfig("k1=v1,k2=v2")
+ .build();
+
+ cmdClusters.run(new String[]{"create", "test_cluster",
+ "--tls-factory-class-name", "com.example.MyTlsFactory",
+ "--tls-factory-config", "k1=v1,k2=v2"});
+ verify(clusters).createCluster(eq("test_cluster"), eq(expected));
+
+ cmdClusters.run(new String[]{"update", "test_cluster",
+ "--tls-factory-class-name", "com.example.MyTlsFactory",
+ "--tls-factory-config", "k1=v1,k2=v2"});
+ verify(clusters).updateCluster(eq("test_cluster"), eq(expected));
+ }
+
@Test
public void testListCmd() throws Exception {
List<String> clusterList = Lists.newArrayList("us-west", "us-east", "us-cent");
diff --git a/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java b/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java
new file mode 100644
index 000000000000..9bb7f2d929a0
--- /dev/null
+++ b/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.cli;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import java.util.Properties;
+import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the client.conf → V5 {@link TlsPolicy} translation in
+ * {@link PulsarClientTool#applyTlsPolicy}.
+ */
+public class PulsarClientToolTest {
+
+ private static TlsPolicy capturePolicy(PulsarClientTool tool, String serviceUrl, Properties properties) {
+ PulsarClientBuilder builder = mock(PulsarClientBuilder.class);
+ tool.applyTlsPolicy(builder, serviceUrl, properties);
+ ArgumentCaptor<TlsPolicy> captor = ArgumentCaptor.forClass(TlsPolicy.class);
+ verify(builder).tlsPolicy(captor.capture());
+ return captor.getValue();
+ }
+
+ private static TlsPolicy capturePolicy(String serviceUrl, Properties properties) {
+ return capturePolicy(new PulsarClientTool(properties), serviceUrl, properties);
+ }
+
+ @Test
+ public void testHostnameVerificationDefaultsOnWhenConfKeyAbsent() {
+ // PIP-478 secure default: an absent tlsEnableHostnameVerification key must not disable
+ // the TlsPolicy builder's verification-ON default.
+ TlsPolicy policy = capturePolicy("pulsar+ssl://localhost:6651", new Properties());
+ assertThat(policy.enableHostnameVerification()).isTrue();
+ assertThat(policy.allowInsecureConnection()).isFalse();
+ assertThat(policy.format()).isEqualTo(TlsPolicy.Format.PEM);
+ }
+
+ @DataProvider(name = "hostnameVerificationValues")
+ public Object[][] hostnameVerificationValues() {
+ return new Object[][] {{"false", false}, {"true", true}};
+ }
+
+ @Test(dataProvider = "hostnameVerificationValues")
+ public void testExplicitHostnameVerificationValueWins(String confValue, boolean expected) {
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("tlsEnableHostnameVerification", confValue);
+ TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+ assertThat(policy.enableHostnameVerification()).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNoTlsPolicyForPlaintextUrl() {
+ PulsarClientBuilder builder = mock(PulsarClientBuilder.class);
+ Properties properties = new Properties();
+ new PulsarClientTool(properties).applyTlsPolicy(builder, "pulsar://localhost:6650", properties);
+ verify(builder, never()).tlsPolicy(any());
+ }
+
+ @Test
+ public void testPemBranchUsesConfCertAndKeyPaths() {
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("tlsCertificateFilePath", "/path/cert.pem");
+ properties.setProperty("tlsKeyFilePath", "/path/key.pem");
+ PulsarClientTool tool = new PulsarClientTool(properties);
+ tool.rootParams.tlsTrustCertsFilePath = "/path/ca.pem";
+ TlsPolicy policy = capturePolicy(tool, "pulsar://localhost:6650", properties);
+ assertThat(policy.format()).isEqualTo(TlsPolicy.Format.PEM);
+ assertThat(policy.trustCertsFilePath()).isEqualTo("/path/ca.pem");
+ assertThat(policy.certificateFilePath()).isEqualTo("/path/cert.pem");
+ assertThat(policy.keyFilePath()).isEqualTo("/path/key.pem");
+ }
+
+ @Test
+ public void testKeyStoreTlsTranslatesToKeystorePolicy() {
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("useKeyStoreTls", "true");
+ properties.setProperty("tlsTrustStorePath", "/path/truststore.p12");
+ properties.setProperty("tlsTrustStorePassword", "trust-pw");
+ properties.setProperty("tlsTrustStoreType", "PKCS12");
+ properties.setProperty("tlsKeyStorePath", "/path/keystore.p12");
+ properties.setProperty("tlsKeyStorePassword", "key-pw");
+ properties.setProperty("tlsKeyStoreType", "PKCS12");
+ properties.setProperty("tlsAllowInsecureConnection", "true");
+ properties.setProperty("tlsEnableHostnameVerification", "false");
+ TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+ assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+ assertThat(policy.trustStorePath()).isEqualTo("/path/truststore.p12");
+ assertThat(policy.trustStorePassword()).isEqualTo("trust-pw");
+ assertThat(policy.trustStoreType()).isEqualTo("PKCS12");
+ assertThat(policy.keyStorePath()).isEqualTo("/path/keystore.p12");
+ assertThat(policy.keyStorePassword()).isEqualTo("key-pw");
+ assertThat(policy.keyStoreType()).isEqualTo("PKCS12");
+ assertThat(policy.allowInsecureConnection()).isTrue();
+ assertThat(policy.enableHostnameVerification()).isFalse();
+ }
+
+ @Test
+ public void testKeyStoreTypesDefaultToJksLikeV4() {
+ // v4 client.conf defaulted both store types to JKS when the keys were absent; the
+ // translation must preserve that (TlsPolicy's null means the JDK default type instead).
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("useKeyStoreTls", "true");
+ properties.setProperty("tlsTrustStorePath", "/path/truststore.jks");
+ properties.setProperty("tlsTrustStorePassword", "trust-pw");
+ TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+ assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+ assertThat(policy.trustStoreType()).isEqualTo("JKS");
+ assertThat(policy.keyStoreType()).isEqualTo("JKS");
+ assertThat(policy.enableHostnameVerification()).isTrue();
+ }
+
+ @Test
+ public void testJsseProviderConfKeyIsHonored() {
+ // A FIPS/provider-specific endpoint relies on the jsseProvider conf key reaching the
+ // typed policy; without it the CLI would silently use the platform provider.
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("jsseProvider", "BCJSSE");
+ TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+ assertThat(policy.jsseProvider()).isEqualTo("BCJSSE");
+ }
+
+ @Test
+ public void testJsseProviderConfKeyIsHonoredForKeystoreFormat() {
+ // jsseProvider is format-independent: it must reach the policy in the KEYSTORE branch too.
+ Properties properties = new Properties();
+ properties.setProperty("useTls", "true");
+ properties.setProperty("useKeyStoreTls", "true");
+ properties.setProperty("tlsTrustStorePath", "/path/truststore.p12");
+ properties.setProperty("jsseProvider", "BCJSSE");
+ TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+ assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+ assertThat(policy.jsseProvider()).isEqualTo("BCJSSE");
+ }
+}
diff --git a/pulsar-client-v5/build.gradle.kts b/pulsar-client-v5/build.gradle.kts
index 8e564ca4593f..3d27cf8e2da3 100644
--- a/pulsar-client-v5/build.gradle.kts
+++ b/pulsar-client-v5/build.gradle.kts
@@ -23,6 +23,8 @@ plugins {
dependencies {
api(project(":pulsar-client-api-v5"))
+ implementation(project(":pulsar-tls-factory-api"))
+ implementation(project(":pulsar-http-client-api"))
implementation(project(":pulsar-client-original"))
implementation(project(":pulsar-common"))
implementation(libs.slf4j.api)
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java
deleted file mode 100644
index bb51d2f9107e..000000000000
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.client.impl.v5;
-
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Supplier;
-import org.apache.pulsar.client.api.AuthenticationDataProvider;
-import org.apache.pulsar.client.api.v5.PulsarClientException;
-import org.apache.pulsar.client.api.v5.auth.Authentication;
-import org.apache.pulsar.client.api.v5.auth.AuthenticationData;
-import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.client.impl.auth.AuthenticationToken;
-
-/**
- * Adapts between v5 Authentication and v4 Authentication interfaces.
- */
-final class AuthenticationAdapter {
-
- private AuthenticationAdapter() {
- }
-
- /**
- * Create a v5 Authentication wrapping a v4 token auth.
- */
- static Authentication token(String token) {
- return new V5AuthWrapper(new AuthenticationToken(token));
- }
-
- /**
- * Create a v5 Authentication wrapping a v4 token supplier auth.
- */
- static Authentication token(Supplier<String> tokenSupplier) {
- return new V5AuthWrapper(new AuthenticationToken(tokenSupplier));
- }
-
- /**
- * Create a v5 Authentication wrapping a v4 TLS auth.
- */
- static Authentication tls(String certFilePath, String keyFilePath) {
- return new V5AuthWrapper(new AuthenticationTls(certFilePath, keyFilePath));
- }
-
- /**
- * Create a v5 Authentication by loading a v4 auth plugin by class name.
- */
- static Authentication create(String className, String params) throws PulsarClientException {
- try {
- var v4Auth = org.apache.pulsar.client.api.AuthenticationFactory.create(className, params);
- return new V5AuthWrapper(v4Auth);
- } catch (org.apache.pulsar.client.api.PulsarClientException e) {
- throw new PulsarClientException(e.getMessage(), e);
- }
- }
-
- /**
- * Create a v5 Authentication by loading a v4 auth plugin by class name and params map.
- */
- static Authentication create(String className, Map<String, String> params)
- throws PulsarClientException {
- try {
- var v4Auth = org.apache.pulsar.client.api.AuthenticationFactory.create(className, params);
- return new V5AuthWrapper(v4Auth);
- } catch (org.apache.pulsar.client.api.PulsarClientException e) {
- throw new PulsarClientException(e.getMessage(), e);
- }
- }
-
- /**
- * Unwrap a v5 Authentication to get the v4 Authentication.
- */
- static org.apache.pulsar.client.api.Authentication toV4(Authentication v5Auth) {
- if (v5Auth instanceof V5AuthWrapper wrapper) {
- return wrapper.v4Auth;
- }
- throw new IllegalArgumentException("Unknown v5 Authentication type: " + v5Auth.getClass());
- }
-
- /**
- * V5 Authentication that wraps a v4 Authentication.
- */
- private static final class V5AuthWrapper implements Authentication {
- final org.apache.pulsar.client.api.Authentication v4Auth;
-
- V5AuthWrapper(org.apache.pulsar.client.api.Authentication v4Auth) {
- this.v4Auth = v4Auth;
- }
-
- @Override
- public String authMethodName() {
- return v4Auth.getAuthMethodName();
- }
-
- @Override
- public AuthenticationData authData() throws PulsarClientException {
- try {
- AuthenticationDataProvider v4Data = v4Auth.getAuthData();
- return v4Data == null ? null : new V5AuthDataWrapper(v4Data);
- } catch (org.apache.pulsar.client.api.PulsarClientException e) {
- throw new PulsarClientException(e.getMessage(), e);
- }
- }
-
- @Override
- public AuthenticationData authData(String brokerHostName) throws PulsarClientException {
- try {
- AuthenticationDataProvider v4Data = v4Auth.getAuthData(brokerHostName);
- return v4Data == null ? null : new V5AuthDataWrapper(v4Data);
- } catch (org.apache.pulsar.client.api.PulsarClientException e) {
- throw new PulsarClientException(e.getMessage(), e);
- }
- }
-
- @Override
- public void initialize() throws PulsarClientException {
- try {
- v4Auth.start();
- } catch (org.apache.pulsar.client.api.PulsarClientException e) {
- throw new PulsarClientException(e.getMessage(), e);
- }
- }
-
- @Override
- public void close() {
- try {
- v4Auth.close();
- } catch (Exception e) {
- // ignore
- }
- }
- }
-
- /**
- * Bridges a v4 {@link AuthenticationDataProvider} into the V5 {@link AuthenticationData}
- * surface. The two interfaces are intentionally aligned so this is a 1:1 delegation.
- */
- private static final class V5AuthDataWrapper implements AuthenticationData {
- private final AuthenticationDataProvider v4Data;
-
- V5AuthDataWrapper(AuthenticationDataProvider v4Data) {
- this.v4Data = v4Data;
- }
-
- @Override
- public boolean hasDataForHttp() {
- return v4Data.hasDataForHttp();
- }
-
- @Override
- public Set<Map.Entry<String, String>> getHttpHeaders() {
- try {
- Set<Map.Entry<String, String>> headers = v4Data.getHttpHeaders();
- return headers != null ? headers : Set.of();
- } catch (Exception e) {
- throw new RuntimeException("Failed to obtain HTTP auth headers", e);
- }
- }
-
- @Override
- public boolean hasDataForTls() {
- return v4Data.hasDataForTls();
- }
-
- @Override
- public Certificate[] getTlsCertificates() {
- return v4Data.getTlsCertificates();
- }
-
- @Override
- public PrivateKey getTlsPrivateKey() {
- return v4Data.getTlsPrivateKey();
- }
-
- @Override
- public boolean hasDataFromCommand() {
- return v4Data.hasDataFromCommand();
- }
-
- @Override
- public String getCommandData() {
- return v4Data.getCommandData();
- }
- }
-}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
index 05dba76c00d1..9117ea2b11c4 100644
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
@@ -20,16 +20,30 @@ package org.apache.pulsar.client.impl.v5;
import io.opentelemetry.api.OpenTelemetry;
import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.KeyStoreParams;
import org.apache.pulsar.client.api.v5.PulsarClient;
import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
import org.apache.pulsar.client.api.v5.PulsarClientException;
import org.apache.pulsar.client.api.v5.auth.Authentication;
import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
import org.apache.pulsar.client.api.v5.config.TransactionPolicy;
import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.client.impl.auth.AuthenticationDisabled;
+import org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls;
+import org.apache.pulsar.client.impl.auth.AuthenticationTls;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.auth.v5.V5AuthenticationLoader;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* V5 implementation of PulsarClientBuilder.
@@ -37,9 +51,15 @@ import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
*/
final class PulsarClientBuilderV5 implements PulsarClientBuilder {
+ private static final Logger LOG = LoggerFactory.getLogger(PulsarClientBuilderV5.class);
+
private final ClientConfigurationData conf = new ClientConfigurationData();
private String description;
private Duration transactionTimeout;
+ // The configured v5 authentication, resolved into the v4 conf.authentication at build() (so a bridged
+ // v4 plugin can be probed on the application thread — off the event loop — to fold its TLS material and
+ // to decide whether it must stay wrapped for credential off-loading). Null when no auth is configured.
+ private Authentication v5Authentication;
PulsarClientBuilderV5() {
conf.setStatsIntervalSeconds(0);
@@ -52,6 +72,7 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
@Override
public PulsarClient build() throws PulsarClientException {
try {
+ applyAuthentication();
var v4Client = new PulsarClientImpl(conf);
return new PulsarClientV5(v4Client, description, transactionTimeout);
} catch (org.apache.pulsar.client.api.PulsarClientException e) {
@@ -68,28 +89,158 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
@Override
public PulsarClientBuilder authentication(Authentication authentication) {
- conf.setAuthentication(AuthenticationAdapter.toV4(authentication));
+ this.v5Authentication = authentication;
return this;
}
@Override
public PulsarClientBuilder authentication(String authPluginClassName, String authParamsString)
throws PulsarClientException {
+ // Preserve the serializable string form (authPluginClassName + authParams) AND eagerly build the
+ // plugin, mirroring the v4 ClientBuilderImpl. The build is v5-aware (PIP-478 In-Scope #2): a
+ // v5-native plugin deployed by class name is instantiated + configured, instead of being blind-cast
+ // to the v4 Authentication SPI (which threw ClassCastException). A legacy v4 class keeps the
+ // existing v4 path. The resolution to the v4 conf.authentication happens at build().
conf.setAuthPluginClassName(authPluginClassName);
conf.setAuthParams(authParamsString);
- // Instantiate the Authentication and attach it to the conf — the v4 PulsarClientImpl
- // honors the plugin class name + params strings only as metadata, it reads the actual
- // Authentication instance via conf.getAuthentication() at connect time.
try {
- conf.setAuthentication(
- org.apache.pulsar.client.api.AuthenticationFactory.create(
- authPluginClassName, authParamsString));
- } catch (org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException e) {
+ this.v5Authentication = V5AuthenticationLoader.create(authPluginClassName, authParamsString);
+ } catch (org.apache.pulsar.client.api.PulsarClientException e) {
throw new PulsarClientException(e.getMessage(), e);
}
return this;
}
+ /**
+ * Resolve the configured v5 {@link Authentication} into the v4 {@code Authentication} the underlying
+ * {@link PulsarClientImpl} consumes, folding any bridged v4 TLS material into the client TLS policy and
+ * choosing how the plugin is driven. Runs at {@link #build()} on the application thread — off the Netty
+ * event loop — where probing a bridged plugin for its material is safe.
+ *
+ * <p>A bridged v4 plugin (from {@code AuthenticationFactory.token/tls/create}) that performs <b>no
+ * credential I/O</b> — the built-in TLS-material plugins (their material is folded into the client TLS
+ * configuration; the binary handshake carries an empty payload) and disabled auth — is driven raw on the
+ * v4 client. A credential-fetching v4 plugin, and every genuinely v5-native plugin, is exposed through
+ * {@link V5ToV4AuthenticationAdapter} so its (possibly blocking) {@code getAuthData} off-loads to the
+ * blocking executor instead of running on the Netty event loop (PIP-478: v4 credential calls are
+ * ALWAYS off-loaded). The adapter implements {@code ClientAuthenticationServicesAware}, so the client
+ * late-binds real executors / HTTP client factory / client instance id into it before {@code start()}
+ * (PIP-478).
+ */
+ private void applyAuthentication() {
+ if (v5Authentication == null) {
+ return;
+ }
+ org.apache.pulsar.client.api.Authentication bridgedV4 =
+ LegacyV4AuthenticationAdapter.unwrapV4(v5Authentication).orElse(null);
+ boolean runRaw = bridgedV4 != null && resolveBridgedV4(bridgedV4);
+ conf.setAuthentication(runRaw
+ ? bridgedV4
+ : new V5ToV4AuthenticationAdapter(v5Authentication));
+ }
+
+ /**
+ * Resolve the configured authentication and return the v4 {@code Authentication} the client would drive.
+ * Package-private for tests that assert the unwrap-vs-offload decision without standing up a broker
+ * (VisibleForTesting; Guava's annotation is not on this module's classpath).
+ *
+ * @return the resolved v4 authentication (a raw v4 engine, or a wrapping {@code V5ToV4AuthenticationAdapter})
+ */
+ org.apache.pulsar.client.api.Authentication resolveAuthenticationForTest() {
+ applyAuthentication();
+ return conf.getAuthentication();
+ }
+
+ /**
+ * For a bridged v4 plugin: fold its TLS material into {@link TlsPurpose#CLIENT_DEFAULT} (when a
+ * {@code tlsPolicy} is configured) and decide whether it can be driven raw on the v4 client.
+ *
+ * @param v4 the bridged v4 plugin recovered from the wrapping adapter
+ * @return {@code true} when the raw v4 engine is correct — it performs no credential I/O (a built-in
+ * TLS-material plugin or disabled auth); {@code false} when it fetches a command/HTTP credential
+ * and must stay wrapped so the call off-loads
+ */
+ private boolean resolveBridgedV4(org.apache.pulsar.client.api.Authentication v4) {
+ boolean foldTls = conf.getTlsPolicyMap() != null;
+ if (v4 instanceof AuthenticationTls tls) {
+ if (foldTls && tls.getCertFilePath() != null && tls.getKeyFilePath() != null) {
+ mergeClientDefault(base -> pemBuilder(base)
+ .certificateFilePath(tls.getCertFilePath())
+ .keyFilePath(tls.getKeyFilePath())
+ .build());
+ }
+ return true;
+ }
+ if (v4 instanceof AuthenticationKeyStoreTls keyStoreTls) {
+ if (foldTls && keyStoreTls.getKeyStoreParams() != null) {
+ KeyStoreParams ks = keyStoreTls.getKeyStoreParams();
+ mergeClientDefault(base -> keyStoreBuilder(base)
+ .keyStorePath(ks.getKeyStorePath())
+ .keyStorePassword(ks.getKeyStorePassword())
+ .keyStoreType(ks.getKeyStoreType())
+ .build());
+ }
+ return true;
+ }
+ if (v4 instanceof AuthenticationDisabled) {
+ return true;
+ }
+ return resolveGenericV4(v4, foldTls);
+ }
+
+ /**
+ * Probe a generic (non-built-in) bridged v4 plugin once at build time (application thread, off the event
+ * loop): fold any file-based TLS material into {@link TlsPurpose#CLIENT_DEFAULT} when a {@code tlsPolicy}
+ * is configured, and report whether it can run raw. A plugin that exposes only in-memory cert/key
+ * material is logged rather than silently dropped (it cannot be represented in the file-path
+ * {@link TlsPolicy}).
+ *
+ * @param v4 the bridged v4 authentication plugin (not a built-in TLS class)
+ * @param foldTls whether a {@code tlsPolicy} is configured, so TLS material should be folded
+ * @return {@code true} when the plugin must be driven raw on the v4 client — it performs no credential
+ * I/O, or it carries TLS material that can only reach the transport through the legacy path;
+ * {@code false} when it should stay wrapped for credential off-load
+ */
+ @SuppressWarnings("deprecation")
+ private boolean resolveGenericV4(org.apache.pulsar.client.api.Authentication v4, boolean foldTls) {
+ final AuthenticationDataProvider data;
+ try {
+ data = v4.getAuthData();
+ } catch (Exception e) {
+ // A plugin that cannot produce auth data at build time (e.g. a not-yet-reachable credential
+ // endpoint) contributes no TLS material here; keep it wrapped so any later credential I/O
+ // off-loads instead of running on the event loop.
+ LOG.debug("Could not probe v4 authentication plugin {} at build time", v4.getClass().getName(), e);
+ return false;
+ }
+ if (data == null) {
+ return false;
+ }
+ boolean hasCredentialIo = data.hasDataFromCommand() || data.hasDataForHttp();
+ if (data.hasDataForTls()) {
+ if (foldTls) {
+ // tlsPolicy is configured: fold the plugin's file material into the client TLS factory path,
+ // so the transport reads TLS from the factory and the plugin may still be wrapped for
+ // credential off-load.
+ foldGenericV4TlsMaterial(data, v4.getClass().getName());
+ return !hasCredentialIo;
+ }
+ // No tlsPolicy: the legacy v4 TLS path reads the plugin's material directly from getAuthData(),
+ // so it MUST run raw or its client certificate would be dropped (the wrapping adapter's
+ // synthesized v4 provider carries only the binary credential, not TLS material). A plugin that
+ // also fetches a credential therefore forgoes off-load here — the two cannot both hold on the
+ // legacy path; configuring tlsPolicy(...) restores off-load by moving TLS to the factory path.
+ if (hasCredentialIo) {
+ LOG.warn("Bridged v4 authentication plugin {} provides both TLS material and a fetched "
+ + "credential but no tlsPolicy(...) is configured; it runs inline (no credential "
+ + "off-load) so its client certificate is still presented on the legacy TLS path. "
+ + "Configure tlsPolicy(...) to off-load the credential.", v4.getClass().getName());
+ }
+ return true;
+ }
+ return !hasCredentialIo;
+ }
+
@Override
public PulsarClientBuilder operationTimeout(Duration timeout) {
conf.setOperationTimeoutMs(timeout.toMillis());
@@ -127,21 +278,140 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
@Override
public PulsarClientBuilder tlsPolicy(TlsPolicy policy) {
- conf.setUseTls(true);
- if (policy.trustCertsFilePath() != null) {
- conf.setTlsTrustCertsFilePath(policy.trustCertsFilePath());
+ return tlsPolicy(TlsPurpose.CLIENT_DEFAULT, policy);
+ }
+
+ @Override
+ public PulsarClientBuilder tlsPolicy(TlsPurpose purpose, TlsPolicy policy) {
+ if (purpose == null || policy == null) {
+ throw new IllegalArgumentException("tlsPolicy purpose and policy must not be null");
}
- if (policy.keyFilePath() != null) {
- conf.setTlsKeyFilePath(policy.keyFilePath());
+ // useTls governs the BINARY BROKER TRANSPORT only, so a policy for a different trust domain must not
+ // turn it on. Configuring CLIENT_OAUTH2 (the identity provider) against a plaintext pulsar:// broker
+ // is a legitimate combination — it was enabling TLS toward the broker and failing the connection.
+ // The policy map itself is what triggers TLS-factory creation, so a non-transport purpose still gets
+ // its factory without touching the transport.
+ if (purpose.role() == TlsPurpose.Role.CLIENT && !TlsPurpose.CLIENT_OAUTH2.equals(purpose)) {
+ conf.setUseTls(true);
}
- if (policy.certificateFilePath() != null) {
- conf.setTlsCertificateFilePath(policy.certificateFilePath());
+ Map<TlsPurpose, TlsPolicy> map = conf.getTlsPolicyMap();
+ if (map == null) {
+ map = new LinkedHashMap<>();
+ conf.setTlsPolicyMap(map);
}
- conf.setTlsAllowInsecureConnection(policy.allowInsecureConnection());
- conf.setTlsHostnameVerificationEnable(policy.enableHostnameVerification());
+ map.put(purpose, policy);
return this;
}
+ @Override
+ public PulsarClientBuilder tlsFactory(PulsarTlsFactory factory) {
+ if (factory == null) {
+ throw new IllegalArgumentException("tlsFactory must not be null");
+ }
+ conf.setUseTls(true);
+ conf.setTlsFactory(factory);
+ return this;
+ }
+
+ /**
+ * Fold a bridged third-party v4 plugin's file-based TLS material into {@link TlsPurpose#CLIENT_DEFAULT}
+ * (PIP-478). The plugin's {@code getAuthData()} has already been probed by
+ * {@link #resolveGenericV4} on the application thread (off the event loop), and {@code data} is known to
+ * report {@code hasDataForTls()}. Only <em>file-based</em> material (PEM cert/key file paths or a
+ * keystore) can be represented in the file-path {@link TlsPolicy}; a plugin that exposes only in-memory
+ * cert/key material is logged rather than silently dropped, since it cannot be folded on this path.
+ *
+ * @param data the already-probed auth data reporting TLS material
+ * @param pluginName the bridged plugin's class name, for logging
+ */
+ @SuppressWarnings("deprecation")
+ private void foldGenericV4TlsMaterial(AuthenticationDataProvider data, String pluginName) {
+ String certPath = data.getTlsCertificateFilePath();
+ String keyPath = data.getTlsPrivateKeyFilePath();
+ KeyStoreParams ks = data.getTlsKeyStoreParams();
+ if (isNotBlank(certPath) && isNotBlank(keyPath)) {
+ mergeClientDefault(base -> pemBuilder(base).certificateFilePath(certPath).keyFilePath(keyPath).build());
+ } else if (ks != null && isNotBlank(ks.getKeyStorePath())) {
+ mergeClientDefault(base -> keyStoreBuilder(base)
+ .keyStorePath(ks.getKeyStorePath())
+ .keyStorePassword(ks.getKeyStorePassword())
+ .keyStoreType(ks.getKeyStoreType())
+ .build());
+ } else {
+ LOG.warn("Bridged v4 authentication plugin {} reports TLS material (hasDataForTls()) but exposes "
+ + "only in-memory cert/key, which cannot be represented in the file-path client TLS "
+ + "policy; its material will not be folded into the transport. Configure it via "
+ + "tlsPolicy(...) or a file-based plugin.", pluginName);
+ }
+ }
+
+ private static boolean isNotBlank(String s) {
+ return s != null && !s.isBlank();
+ }
+
+ private void mergeClientDefault(java.util.function.Function<TlsPolicy, TlsPolicy> merge) {
+ Map<TlsPurpose, TlsPolicy> map = conf.getTlsPolicyMap();
+ TlsPolicy base = map.get(TlsPurpose.CLIENT_DEFAULT);
+ map.put(TlsPurpose.CLIENT_DEFAULT, merge.apply(base));
+ }
+
+ /** Copy the trust material and flags of {@code base} (if any) into a PEM-format builder. */
+ private static TlsPolicy.Builder pemBuilder(TlsPolicy base) {
+ TlsPolicy.Builder b = copyFlags(base).format(TlsPolicy.Format.PEM);
+ if (base != null && base.format() == TlsPolicy.Format.PEM) {
+ b.trustCertsFilePath(base.trustCertsFilePath());
+ } else if (base != null && isNotBlank(base.trustStorePath())) {
+ // Cross-format fold: the tlsPolicy(...) carries a keystore truststore but the auth plugin's client
+ // identity is PEM. A PEM policy has no truststore field, so folding here would silently drop the
+ // configured trust anchors and fall back to the system trust store. Fail loud (matching
+ // TlsPolicy.build()'s fail-loud format validation) rather than silently broadening/breaking trust.
+ throw new IllegalArgumentException("Cross-format TLS material: tlsPolicy(...) configures a keystore "
+ + "truststore (trustStorePath) but the authentication plugin supplies a PEM client "
+ + "certificate/key. Folding these would silently drop the configured truststore. Configure the "
+ + "trust material and the client identity in the same format (both PEM, or both keystore).");
+ }
+ return b;
+ }
+
+ /** Copy the trust material and flags of {@code base} (if any) into a keystore-format builder. */
+ private static TlsPolicy.Builder keyStoreBuilder(TlsPolicy base) {
+ TlsPolicy.Builder b = copyFlags(base).format(TlsPolicy.Format.KEYSTORE);
+ if (base != null && base.format() == TlsPolicy.Format.KEYSTORE) {
+ // Preserve the base truststore (path, password, and TYPE): folding the auth plugin's keystore must
+ // not clobber the truststore type configured via tlsPolicy(...) — the keystore and truststore may
+ // use different types (e.g. a PKCS12 keystore with a JKS truststore).
+ b.trustStorePath(base.trustStorePath())
+ .trustStorePassword(base.trustStorePassword())
+ .trustStoreType(base.trustStoreType());
+ } else if (base != null && isNotBlank(base.trustCertsFilePath())) {
+ // Cross-format fold: the tlsPolicy(...) carries a PEM truststore (trustCertsFilePath) but the auth
+ // plugin's client identity is a keystore. A keystore policy has no PEM trust field, so folding here
+ // would silently drop the configured trust anchors and fall back to the system trust store. Fail loud
+ // (matching TlsPolicy.build()'s fail-loud format validation) rather than silently broadening/breaking
+ // trust.
+ throw new IllegalArgumentException("Cross-format TLS material: tlsPolicy(...) configures a PEM "
+ + "truststore (trustCertsFilePath) but the authentication plugin supplies a keystore client "
+ + "certificate/key. Folding these would silently drop the configured truststore. Configure the "
+ + "trust material and the client identity in the same format (both PEM, or both keystore).");
+ }
+ return b;
+ }
+
+ private static TlsPolicy.Builder copyFlags(TlsPolicy base) {
+ TlsPolicy.Builder b = TlsPolicy.builder();
+ if (base != null) {
+ b.allowInsecureConnection(base.allowInsecureConnection())
+ .enableHostnameVerification(base.enableHostnameVerification())
+ .protocols(base.protocols())
+ .ciphers(base.ciphers())
+ // Preserve the pinned JSSE (SSLContext) provider across the fold. A FIPS deployment
+ // pins it via tlsPolicy(...); dropping it here would let the transport fall back to
+ // the default JDK engine, silently defeating the pin.
+ .jsseProvider(base.jsseProvider());
+ }
+ return b;
+ }
+
@Override
public PulsarClientBuilder openTelemetry(OpenTelemetry openTelemetry) {
conf.setOpenTelemetry(openTelemetry);
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
index fe8ac87a70d3..96c8c3361c84 100644
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
@@ -29,6 +29,10 @@ import org.apache.pulsar.client.api.v5.auth.Authentication;
import org.apache.pulsar.client.api.v5.internal.PulsarClientProvider;
import org.apache.pulsar.client.api.v5.schema.GenericRecord;
import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.client.impl.auth.AuthenticationToken;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.auth.v5.TlsAuthentication;
+import org.apache.pulsar.client.impl.auth.v5.V5AuthenticationLoader;
/**
* ServiceLoader-registered implementation of PulsarClientProvider.
@@ -168,28 +172,36 @@ public final class PulsarClientProviderV5 implements PulsarClientProvider {
@Override
public Authentication authenticationToken(String token) {
- return AuthenticationAdapter.token(token);
+ return LegacyV4AuthenticationAdapter.wrap(new AuthenticationToken(token));
}
@Override
public Authentication authenticationToken(Supplier<String> tokenSupplier) {
- return AuthenticationAdapter.token(tokenSupplier);
+ return LegacyV4AuthenticationAdapter.wrap(new AuthenticationToken(tokenSupplier));
}
@Override
- public Authentication authenticationTls(String certFilePath, String keyFilePath) {
- return AuthenticationAdapter.tls(certFilePath, keyFilePath);
+ public Authentication authenticationTls() {
+ return new TlsAuthentication();
}
@Override
public Authentication createAuthentication(String className, String params)
throws PulsarClientException {
- return AuthenticationAdapter.create(className, params);
+ try {
+ return V5AuthenticationLoader.create(className, params);
+ } catch (org.apache.pulsar.client.api.PulsarClientException e) {
+ throw new PulsarClientException(e.getMessage(), e);
+ }
}
@Override
public Authentication createAuthentication(String className, Map<String, String> params)
throws PulsarClientException {
- return AuthenticationAdapter.create(className, params);
+ try {
+ return V5AuthenticationLoader.create(className, params);
+ } catch (org.apache.pulsar.client.api.PulsarClientException e) {
+ throw new PulsarClientException(e.getMessage(), e);
+ }
}
}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java
new file mode 100644
index 000000000000..d94975ea3b92
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.impl.v5.auth;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationCallContext;
+
+/**
+ * Simple {@link AuthenticationCallContext} implementation backed by a concurrent map of typed
+ * per-exchange state objects. Used by the v5↔v4 bridges to drive challenge/response exchanges.
+ */
+class SimpleAuthCallContext implements AuthenticationCallContext {
+
+ private final String brokerHost;
+ private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
+
+ SimpleAuthCallContext(String brokerHost) {
+ this.brokerHost = brokerHost;
+ }
+
+ @Override
+ public String brokerHost() {
+ return brokerHost;
+ }
+
+ @Override
+ public <T> Optional<T> getStateObject(Class<T> clazz) {
+ return Optional.ofNullable(clazz.cast(state.get(clazz)));
+ }
+
+ @Override
+ public <T> void setStateObject(Class<T> clazz, T value) {
+ if (value == null) {
+ state.remove(clazz);
+ } else {
+ state.put(clazz, value);
+ }
+ }
+}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java
new file mode 100644
index 000000000000..55456c8257d5
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.impl.v5.auth;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * Simple immutable {@link AuthenticationInitContext} implementation used by the v5/v4 bridges to
+ * initialize a v5 {@link org.apache.pulsar.client.api.v5.auth.Authentication} plugin.
+ */
+class SimpleAuthInitContext implements AuthenticationInitContext {
+
+ private final PulsarHttpClientFactory httpClientFactory;
+ private final ScheduledExecutorService scheduler;
+ private final Executor blockingExecutor;
+ private final Clock clock;
+ private final OpenTelemetry openTelemetry;
+ private final String clientInstanceId;
+
+ SimpleAuthInitContext(PulsarHttpClientFactory httpClientFactory, ScheduledExecutorService scheduler,
+ Executor blockingExecutor, Clock clock, OpenTelemetry openTelemetry, String clientInstanceId) {
+ this.httpClientFactory = httpClientFactory;
+ this.scheduler = scheduler;
+ this.blockingExecutor = blockingExecutor;
+ this.clock = clock;
+ this.openTelemetry = openTelemetry;
+ this.clientInstanceId = clientInstanceId;
+ }
+
+ @Override
+ public PulsarHttpClientFactory httpClientFactory() {
+ return httpClientFactory;
+ }
+
+ @Override
+ public ScheduledExecutorService scheduler() {
+ return scheduler;
+ }
+
+ @Override
+ public Executor blockingExecutor() {
+ return blockingExecutor;
+ }
+
+ @Override
+ public Clock clock() {
+ return clock;
+ }
+
+ @Override
+ public OpenTelemetry openTelemetry() {
+ return openTelemetry;
+ }
+
+ @Override
+ public String clientInstanceId() {
+ return clientInstanceId;
+ }
+}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java
new file mode 100644
index 000000000000..8d1a6cc8e102
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.impl.v5.auth;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import io.opentelemetry.api.OpenTelemetry;
+import java.io.IOException;
+import java.io.NotSerializableException;
+import java.io.ObjectOutputStream;
+import java.net.URI;
+import java.time.Clock;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver;
+import org.apache.pulsar.client.api.v5.auth.Authentication;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationCallContext;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext;
+import org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthCallContext;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthHeaders;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthHeadersProvider;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServicesAware;
+import org.apache.pulsar.client.impl.auth.v5.AuthMetrics;
+import org.apache.pulsar.client.impl.auth.v5.BinaryAuthenticationExchange;
+import org.apache.pulsar.common.api.AuthData;
+
+/**
+ * Exposes a new-SPI v5 {@link Authentication} plugin through the legacy v4
+ * {@link org.apache.pulsar.client.api.Authentication} interface that {@code ClientCnx} drives
+ * (PIP-478).
+ *
+ * <p>It also implements {@link AsyncAuthenticationDriver} so that the {@code ClientCnx} carve-out
+ * (PIP-478) can authenticate via the asynchronous (non event-loop-blocking) path. Both the
+ * async driver and the synchronous v4 {@link #getAuthData(String)} path are exchange-scoped: one
+ * {@link AsyncAuthenticationDriver.AuthenticationExchange} — and one {@link AuthenticationCallContext}
+ * with its state slot — backs a single connection attempt, so a plugin's challenge/response
+ * conversation state survives across all of that connection's rounds (initial data, refresh, and every
+ * {@code CommandAuthChallenge}).
+ *
+ * <p>The per-connection routing (initial connect / refresh sentinel / challenge) and the v5→v4 exception
+ * mapping are the shared {@link BinaryAuthenticationExchange} — the same implementation the built-in
+ * shims' {@code V5BinaryAuthenticationDriver} uses — so the two adapters cannot drift on the normative
+ * binary rules. Binary transport requires the {@link BinaryAuthDataProvider} capability (PIP-478 binary
+ * routing rule 1); a wrapped v5 plugin that does not expose it fails loudly — {@link #start()} throws a
+ * v4-mapped {@link PulsarClientException.UnsupportedAuthenticationException} — rather than synthesizing
+ * an empty {@code "none"} credential.
+ */
+// Implements the deprecated v4 Authentication SPI by design (configure(Map)).
+@SuppressWarnings("deprecation")
+public class V5ToV4AuthenticationAdapter
+ implements org.apache.pulsar.client.api.Authentication, AsyncAuthenticationDriver,
+ ClientAuthenticationServicesAware {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient Authentication v5;
+ // Late-bound by the client via bindClientAuthenticationServices(...) before start() (PIP-478);
+ // null until then (e.g. when the adapter is exercised outside a client), yielding an init context with
+ // no framework services.
+ private transient volatile ClientAuthenticationServices services;
+ // Built from the bound services' OpenTelemetry in start(); NOOP until then.
+ private transient volatile AuthMetrics authMetrics = AuthMetrics.NOOP;
+
+ /**
+ * Create an adapter that exposes a v5 authentication plugin through the v4 interface. The framework
+ * services the plugin's init context reports are late-bound by the client through
+ * {@link #bindClientAuthenticationServices} before {@link #start()}.
+ *
+ * @param v5 the v5 authentication plugin to expose
+ */
+ public V5ToV4AuthenticationAdapter(Authentication v5) {
+ this.v5 = v5;
+ }
+
+ @Override
+ public void bindClientAuthenticationServices(ClientAuthenticationServices services) {
+ this.services = services;
+ }
+
+ @Override
+ public String getAuthMethodName() {
+ return requireBinaryProvider().authMethodName();
+ }
+
+ @Override
+ public void configure(Map<String, String> authParams) {
+ v5.configure(authParams);
+ }
+
+ @Override
+ public void start() throws PulsarClientException {
+ ClientAuthenticationServices bound = this.services;
+ this.authMetrics = AuthMetrics.create(bound == null ? null : bound.openTelemetry());
+ AuthenticationInitContext initContext = bound == null
+ ? new SimpleAuthInitContext(null, null, null, Clock.systemUTC(), OpenTelemetry.noop(), null)
+ : new SimpleAuthInitContext(bound.httpClientFactory(), bound.scheduler(), bound.blockingExecutor(),
+ bound.clock() == null ? Clock.systemUTC() : bound.clock(),
+ bound.openTelemetry() == null ? OpenTelemetry.noop() : bound.openTelemetry(),
+ bound.clientInstanceId());
+ try {
+ v5.initializeAsync(initContext).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new PulsarClientException(e);
+ } catch (Exception e) {
+ throw BinaryAuthenticationExchange.toV4Exception(BinaryAuthenticationExchange.unwrap(e));
+ }
+ // Binary transport requires the BinaryAuthDataProvider capability (PIP-478 binary routing
+ // rule 1). Fail loudly here rather than synthesizing a "none"/empty credential.
+ if (v5.capability(BinaryAuthDataProvider.class).isEmpty()) {
+ throw new PulsarClientException.UnsupportedAuthenticationException(
+ "v5 authentication plugin " + v5.getClass().getName() + " does not expose "
+ + "BinaryAuthDataProvider; the Pulsar binary transport requires it (PIP-478)");
+ }
+ }
+
+ @Override
+ public AuthenticationDataProvider getAuthData(String brokerHostName) throws PulsarClientException {
+ AuthenticationExchange exchange = newAuthenticationExchange(brokerHostName);
+ try {
+ AuthData initial = exchange.getAuthDataAsync().get();
+ return new SynthesizedV4DataProvider(exchange, initial, v5, brokerHostName);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new PulsarClientException(e);
+ } catch (Exception e) {
+ throw BinaryAuthenticationExchange.toV4Exception(BinaryAuthenticationExchange.unwrap(e));
+ }
+ }
+
+ @Override
+ public AuthenticationExchange newAuthenticationExchange(String brokerHostName) {
+ return new BinaryAuthenticationExchange(v5, new SimpleAuthCallContext(brokerHostName), authMetrics);
+ }
+
+ /**
+ * The v5 {@link BinaryAuthDataProvider} the wrapped plugin exposes for the binary transport.
+ *
+ * @return the capability
+ * @throws IllegalStateException if the plugin does not support the binary transport; normally
+ * {@link #start()} has already failed loudly before this can be reached
+ */
+ private BinaryAuthDataProvider requireBinaryProvider() {
+ return v5.capability(BinaryAuthDataProvider.class)
+ .orElseThrow(() -> new IllegalStateException(
+ "v5 authentication plugin " + v5.getClass().getName() + " does not expose "
+ + "BinaryAuthDataProvider; it cannot authenticate a Pulsar binary-protocol "
+ + "connection (PIP-478)"));
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ v5.close();
+ } catch (IOException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+
+ /**
+ * v5 {@link Authentication} plugins are not serializable. Serialization of this adapter is therefore
+ * unsupported. Configure authentication on a remote/forked context via the
+ * {@code authPluginClassName} + {@code authParams} string form instead.
+ *
+ * @param out the object output stream
+ * @throws NotSerializableException always
+ */
+ private void writeObject(ObjectOutputStream out) throws NotSerializableException {
+ throw new NotSerializableException("V5 authentication plugins are not serializable. "
+ + "Configure authentication using authPluginClassName + authParams instead of a "
+ + "pre-built Authentication instance.");
+ }
+
+ private static boolean isInitSentinel(AuthData challenge) {
+ return challenge != null && Arrays.equals(challenge.getBytes(), AuthData.INIT_AUTH_DATA_BYTES);
+ }
+
+ /**
+ * A v4 {@link AuthenticationDataProvider} synthesized from a single
+ * {@link AsyncAuthenticationDriver.AuthenticationExchange}, so that the synchronous v4 {@code ClientCnx}
+ * code path keeps working — including multi-round challenge/response, which rides
+ * {@link #authenticate(AuthData)} through the same exchange (and thus the same
+ * {@link AuthenticationCallContext} state slot).
+ *
+ * <p>It also bridges the single-pass HTTP transport: the v4 HTTP lookup path (which drives
+ * {@link #hasDataForHttp()} / {@link #getHttpHeaders()}) is served from the wrapped plugin's v5
+ * {@link HttpAuthHeadersProvider} capability, so a bearer/role credential (for example
+ * {@code Authorization: Bearer <jwt>}) is attached to the lookup {@code GET}. Multi-round
+ * SASL-over-HTTP challenge/response is out of scope here — it rides the framework HTTP challenge
+ * driver, not this bridge — so a plugin that exposes only an
+ * {@link org.apache.pulsar.client.api.v5.auth.HttpAuthChallengeHandler} contributes no static header
+ * and {@link #hasDataForHttp()} stays {@code false}.
+ */
+ private static final class SynthesizedV4DataProvider implements AuthenticationDataProvider {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient AuthenticationExchange exchange;
+ private final transient byte[] initialData;
+ private final transient Authentication v5;
+ private final transient String brokerHostName;
+
+ SynthesizedV4DataProvider(AuthenticationExchange exchange, AuthData initial, Authentication v5,
+ String brokerHostName) {
+ this.exchange = exchange;
+ this.initialData = initial == null || initial.getBytes() == null ? null : initial.getBytes();
+ this.v5 = v5;
+ this.brokerHostName = brokerHostName;
+ }
+
+ @Override
+ public boolean hasDataFromCommand() {
+ return initialData != null;
+ }
+
+ @Override
+ public String getCommandData() {
+ return initialData == null ? null : new String(initialData, UTF_8);
+ }
+
+ @Override
+ public boolean hasDataForHttp() {
+ return v5.capability(HttpAuthHeadersProvider.class).isPresent();
+ }
+
+ @Override
+ public Set<Map.Entry<String, String>> getHttpHeaders() throws Exception {
+ Optional<HttpAuthHeadersProvider> provider = v5.capability(HttpAuthHeadersProvider.class);
+ if (provider.isEmpty()) {
+ return null;
+ }
+ // Blocking .get() is intentional: this v4 hook runs on the client's blockingAuthExecutor
+ // (HttpClient.computeAuthHeaders offloads the whole v4 branch), never the event loop.
+ HttpAuthCallContext ctx = new SynthesizedHttpAuthCallContext(brokerHostName);
+ try {
+ HttpAuthHeaders headers = provider.get().getHttpHeadersAsync(ctx).get();
+ return headers == null || headers.isEmpty() ? null : headers.asMap().entrySet();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw e;
+ } catch (ExecutionException e) {
+ Throwable cause = BinaryAuthenticationExchange.unwrap(e);
+ if (cause instanceof Exception ex) {
+ throw ex;
+ }
+ throw new PulsarClientException(cause);
+ }
+ }
+
+ /**
+ * Route a challenge (or the connect/refresh sentinel) through the bound exchange so that v5
+ * challenge handlers work through the plain synchronous v4 path, sharing the exchange's state
+ * slot across rounds. The init sentinel reuses the already-computed initial credential to avoid
+ * re-running the initial round (which, for challenge/response plugins, would re-seed the
+ * conversation).
+ *
+ * @param data the challenge payload, or the {@code INIT}/{@code REFRESH} sentinel
+ * @return the response auth data
+ * @throws javax.naming.AuthenticationException if the exchange fails
+ */
+ @Override
+ public AuthData authenticate(AuthData data) throws javax.naming.AuthenticationException {
+ if (isInitSentinel(data)) {
+ return AuthData.of(initialData == null ? new byte[0] : initialData);
+ }
+ try {
+ AuthData result = exchange.authenticateAsync(data).get();
+ return AuthData.of(result == null ? new byte[0] : result.getBytes());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw namingAuthException(e);
+ } catch (ExecutionException e) {
+ throw namingAuthException(BinaryAuthenticationExchange.unwrap(e));
+ }
+ }
+
+ private static javax.naming.AuthenticationException namingAuthException(Throwable cause) {
+ javax.naming.AuthenticationException e = new javax.naming.AuthenticationException(
+ cause == null ? null : cause.getMessage());
+ e.initCause(cause);
+ return e;
+ }
+ }
+
+ /**
+ * A single-pass {@link HttpAuthCallContext} for the v4 HTTP lookup path, which surfaces only the
+ * request host (from {@link org.apache.pulsar.client.api.Authentication#getAuthData(String)}) rather
+ * than a full request URI. The host is presented as an {@code https://<host>} URI; single-pass
+ * {@link HttpAuthHeadersProvider}s (token/Athenz/OAuth2) do not consult it. There is no prior server
+ * challenge on this path, so {@link #serverChallengeHeaders()} is always empty; the state slot exists
+ * to satisfy the contract but is not shared across requests.
+ */
+ private static final class SynthesizedHttpAuthCallContext implements HttpAuthCallContext {
+
+ private final URI requestUri;
+ private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
+
+ SynthesizedHttpAuthCallContext(String brokerHostName) {
+ this.requestUri = URI.create("https://" + (brokerHostName == null ? "" : brokerHostName));
+ }
+
+ @Override
+ public URI requestUri() {
+ return requestUri;
+ }
+
+ @Override
+ public Optional<HttpAuthHeaders> serverChallengeHeaders() {
+ return Optional.empty();
+ }
+
+ @Override
+ public <T> Optional<T> getStateObject(Class<T> clazz) {
+ return Optional.ofNullable(clazz.cast(state.get(clazz)));
+ }
+
+ @Override
+ public <T> void setStateObject(Class<T> clazz, T value) {
+ if (value == null) {
+ state.remove(clazz);
+ } else {
+ state.put(clazz, value);
+ }
+ }
+ }
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
similarity index 59%
copy from pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
copy to pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
index afc47322a1c0..16d2a0e3b7db 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
@@ -18,12 +18,12 @@
*/
/**
- * Configuration value types and policy records.
+ * v5 client authentication bridges and the built-in mTLS plugin (PIP-478).
*
- * <p>Immutable records used to group related configuration parameters in builder APIs,
- * such as {@link org.apache.pulsar.client.api.v5.config.BatchingPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.TlsPolicy}, and
- * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}.
+ * <p>Contains the built-in {@link org.apache.pulsar.client.impl.auth.v5.TlsAuthentication} plugin and
+ * the compatibility adapters between the v4 and v5 authentication SPIs:
+ * {@link org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter} (v4 plugin exposed as v5)
+ * and {@link org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter} (v5 plugin exposed
+ * through the v4 interface that {@code ClientCnx} drives).
*/
-package org.apache.pulsar.client.api.v5.config;
+package org.apache.pulsar.client.impl.v5.auth;
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java
deleted file mode 100644
index 283cff17b157..000000000000
--- a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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.
- */
-package org.apache.pulsar.client.impl.v5;
-
-import static org.testng.Assert.assertEquals;
-import static org.testng.Assert.assertNotNull;
-import static org.testng.Assert.assertTrue;
-import org.apache.pulsar.client.api.v5.auth.Authentication;
-import org.apache.pulsar.client.api.v5.auth.AuthenticationData;
-import org.testng.annotations.Test;
-
-/**
- * Verifies that {@code AuthenticationAdapter}'s V5 wrapper bridges through to the
- * v4 {@code AuthenticationDataProvider} on every dimension exposed by the V5
- * {@link AuthenticationData} surface — previously {@code authData()} was a TODO
- * stub returning {@code null}, which silently broke any V5 caller that asked the
- * wrapped auth for credentials.
- */
-public class AuthenticationAdapterTest {
-
- @Test
- public void testTokenAuthExposesCommandData() throws Exception {
- Authentication v5Auth = AuthenticationAdapter.token("my-jwt");
-
- AuthenticationData data = v5Auth.authData();
- assertNotNull(data, "authData() must not return null after the wiring fix");
- assertTrue(data.hasDataFromCommand(),
- "token auth should expose its credential via the binary-protocol command path");
- assertEquals(data.getCommandData(), "my-jwt");
- }
-
- @Test
- public void testAuthMethodNamePassesThrough() throws Exception {
- Authentication v5Auth = AuthenticationAdapter.token("any-token");
- assertEquals(v5Auth.authMethodName(), "token");
- }
-
- @Test
- public void testAuthDataPerBrokerHostDelegates() throws Exception {
- Authentication v5Auth = AuthenticationAdapter.token("per-host-token");
-
- AuthenticationData data = v5Auth.authData("broker-1.example.com");
- assertNotNull(data, "authData(brokerHost) must not return null");
- assertEquals(data.getCommandData(), "per-host-token");
- }
-}
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java
new file mode 100644
index 000000000000..a8665c48d740
--- /dev/null
+++ b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.pulsar.client.impl.v5;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver.AuthenticationExchange;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.DefaultClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter;
+import org.apache.pulsar.common.api.AuthData;
+import org.testng.annotations.Test;
+
+/**
+ * Verifies PIP-478: a bridged v4 credential plugin deployed on the v5 client keeps its async offload
+ * adapter (rather than being unwrapped onto the raw v4 engine), so its blocking {@code getAuthData} runs on
+ * the client's blocking executor — never inline on the Netty event loop. A TLS-only plugin, which performs
+ * no credential I/O, is driven raw.
+ */
+public class CredentialOffloadThroughV5BuilderTest {
+
+ private static final String BLOCKING_THREAD = "v5-builder-offload-test-thread";
+
+ @Test
+ public void credentialV4PluginStaysWrappedAndOffloadsGetAuthData() throws Exception {
+ BlockingCredentialV4 v4 = new BlockingCredentialV4();
+ PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+ builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+
+ // Decision: the credential plugin is NOT unwrapped to the raw v4 engine; it stays wrapped so that
+ // its (blocking) getAuthData off-loads instead of running on the Netty event loop.
+ org.apache.pulsar.client.api.Authentication resolved = builder.resolveAuthenticationForTest();
+ assertThat(resolved).isInstanceOf(V5ToV4AuthenticationAdapter.class);
+
+ ExecutorService blocking = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, BLOCKING_THREAD);
+ t.setDaemon(true);
+ return t;
+ });
+ ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ try {
+ V5ToV4AuthenticationAdapter adapter = (V5ToV4AuthenticationAdapter) resolved;
+ adapter.bindClientAuthenticationServices(services(blocking, scheduler));
+ adapter.start();
+
+ String callerThread = Thread.currentThread().getName();
+ AuthenticationExchange exchange = adapter.newAuthenticationExchange("broker.example.com");
+ CompletableFuture<AuthData> future = exchange.getAuthDataAsync();
+
+ // The fetch is off-loaded: it started on the blocking executor while the caller proceeds.
+ assertThat(v4.entered.await(5, SECONDS)).isTrue();
+ AuthData data = future.get(5, SECONDS);
+ assertThat(new String(data.getBytes(), UTF_8)).isEqualTo("the-credential");
+ assertThat(v4.fetchThread.get())
+ .isEqualTo(BLOCKING_THREAD)
+ .isNotEqualTo(callerThread);
+ } finally {
+ blocking.shutdownNow();
+ scheduler.shutdownNow();
+ }
+ }
+
+ @Test
+ public void tlsOnlyV4PluginRunsRawWithoutOffloadWrapper() {
+ // A plugin that reports TLS material but no command/HTTP credential performs no I/O to off-load, so
+ // it is driven raw on the v4 client (its TLS material is presented at the transport layer).
+ TlsOnlyV4 v4 = new TlsOnlyV4();
+ PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+ builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+ assertThat(builder.resolveAuthenticationForTest()).isSameAs(v4);
+ }
+
+ @Test
+ public void combinedTlsAndCredentialV4WithoutTlsPolicyRunsRawToPreserveTls() {
+ // A v4 plugin that presents BOTH a client certificate (mTLS) and a fetched credential must run raw
+ // when no tlsPolicy is configured: the legacy TLS path reads its certificate from getAuthData(), so
+ // wrapping it for credential off-load would drop the certificate. Off-load is regained by
+ // configuring tlsPolicy(...) (which moves TLS material to the factory path).
+ CombinedTlsCredentialV4 v4 = new CombinedTlsCredentialV4();
+ PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+ builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+ assertThat(builder.resolveAuthenticationForTest()).isSameAs(v4);
+ }
+
+ private static ClientAuthenticationServices services(ExecutorService blocking,
+ ScheduledExecutorService scheduler) {
+ // The offload path does not use HTTP, so a throwing factory suffices.
+ return new DefaultClientAuthenticationServices(config -> {
+ throw new UnsupportedOperationException("HTTP client not used in this test");
+ }, scheduler, blocking, Clock.systemUTC(), OpenTelemetry.noop(), "test-client");
+ }
+
+ /** A v4 credential plugin whose {@code getCommandData} blocks (models blocking credential I/O). */
+ @SuppressWarnings("deprecation")
+ private static final class BlockingCredentialV4 implements org.apache.pulsar.client.api.Authentication {
+
+ private final CountDownLatch entered = new CountDownLatch(1);
+ private final AtomicReference<String> fetchThread = new AtomicReference<>();
+
+ @Override
+ public String getAuthMethodName() {
+ return "blocking-cred";
+ }
+
+ @Override
+ public AuthenticationDataProvider getAuthData() {
+ return new AuthenticationDataProvider() {
+ @Override
+ public boolean hasDataFromCommand() {
+ return true;
+ }
+
+ @Override
+ public String getCommandData() {
+ fetchThread.set(Thread.currentThread().getName());
+ entered.countDown();
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return "the-credential";
+ }
+ };
+ }
+
+ @Override
+ public void configure(Map<String, String> authParams) {
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ /** A v4 plugin that reports only TLS material (no command/HTTP credential) under a non-"tls" method name. */
+ @SuppressWarnings("deprecation")
+ private static final class TlsOnlyV4 implements org.apache.pulsar.client.api.Authentication {
+
+ @Override
+ public String getAuthMethodName() {
+ return "custom-tls";
+ }
+
+ @Override
+ public AuthenticationDataProvider getAuthData() {
+ return new AuthenticationDataProvider() {
+ @Override
+ public boolean hasDataForTls() {
+ return true;
+ }
+ };
+ }
+
+ @Override
+ public void configure(Map<String, String> authParams) {
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ /** A v4 plugin that reports BOTH a client certificate (mTLS) and a command credential. */
+ @SuppressWarnings("deprecation")
+ private static final class CombinedTlsCredentialV4 implements org.apache.pulsar.client.api.Authentication {
+
+ @Override
+ public String getAuthMethodName() {
+ return "combined";
+ }
+
+ @Override
+ public AuthenticationDataProvider getAuthData() {
+ return new AuthenticationDataProvider() {
+ @Override
+ public boolean hasDataForTls() {
+ return true;
+ }
+
+ @Override
+ public boolean hasDataFromCommand() {
+ return true;
+ }
+
+ @Override
+ public String getCommandData() {
+ return "the-credential";
+ }
+ };
+ }
+
+ @Override
+ public void configure(Map<String, String> authParams) {
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
index c081f6df6a05..f3bea25c252e 100644
--- a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
+++ b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
@@ -24,12 +24,17 @@ import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
+import java.util.Map;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
import org.apache.pulsar.client.api.v5.PulsarClient;
import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
import org.apache.pulsar.client.api.v5.PulsarClientException;
+import org.apache.pulsar.client.api.v5.auth.Authentication;
import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
import org.testng.annotations.Test;
/**
@@ -101,10 +106,103 @@ public class PulsarClientBuilderV5Test {
"error must name the offending field: " + e.getMessage());
}
+ /**
+ * PIP-478 stage 3b: on the v5-builder TLS path a bad {@link TlsPolicy} (here a non-existent trust
+ * cert file) fails the client build fast — at {@code build()} time, before any connection is
+ * attempted — with an actionable error, rather than surfacing later as an opaque handshake failure.
+ */
+ @Test
+ public void testTlsPolicyBadPathFailsClientBuild() {
+ String badPath = "/nonexistent/pip478/ca-does-not-exist.pem";
+ PulsarClientException e = null;
+ try {
+ PulsarClient.builder()
+ .serviceUrl("pulsar+ssl://localhost:6651")
+ .tlsPolicy(TlsPolicy.pem(badPath, null, null))
+ .build();
+ fail("expected the client build to fail fast on the bad TLS policy");
+ } catch (PulsarClientException ex) {
+ e = ex;
+ }
+ assertNotNull(e, "a bad TLS policy must fail the client build");
+ assertTrue(allMessages(e).toLowerCase().contains("tls"),
+ "the failure must be actionable and mention TLS: " + allMessages(e));
+ }
+
+ /**
+ * PIP-478 stage 3c: a bridged third-party v4 plugin (not a built-in TLS class) that reports
+ * {@code hasDataForTls()} with file-based cert/key must have that material folded into
+ * {@link TlsPurpose#CLIENT_DEFAULT} at build time. Proven here by pointing the plugin at a non-existent
+ * cert file: the fold makes the client build fail fast on the missing file; without the fold the
+ * system-default CLIENT_DEFAULT policy would build cleanly.
+ */
+ @Test
+ public void genericV4TlsFilePluginMaterialIsFoldedIntoClientDefault() {
+ AuthenticationDataProvider data = new AuthenticationDataProvider() {
+ @Override
+ public boolean hasDataForTls() {
+ return true;
+ }
+
+ @Override
+ public String getTlsCertificateFilePath() {
+ return "/nonexistent/pip478/generic-cert.pem";
+ }
+
+ @Override
+ public String getTlsPrivateKeyFilePath() {
+ return "/nonexistent/pip478/generic-key.pem";
+ }
+ };
+ Authentication v5 = LegacyV4AuthenticationAdapter.wrap(new GenericTlsV4Auth("custom-tls", data));
+
+ PulsarClientException e = null;
+ try {
+ PulsarClient.builder()
+ .serviceUrl("pulsar+ssl://localhost:6651")
+ .authentication(v5)
+ .tlsPolicy(TlsPolicy.pem(null, null, null))
+ .build();
+ fail("expected the client build to fail fast on the folded generic TLS material");
+ } catch (PulsarClientException ex) {
+ e = ex;
+ }
+ assertNotNull(e, "the folded generic cert path must fail the client build");
+ assertTrue(allMessages(e).toLowerCase().contains("tls"),
+ "the failure must be actionable and mention TLS: " + allMessages(e));
+ }
+
+ /**
+ * PIP-478 stage 3c: a generic v4 plugin exposing only in-memory TLS material cannot be represented in a
+ * file-path {@link TlsPolicy}; it is logged rather than folded, and the client build still succeeds
+ * (the material simply is not applied on this path).
+ */
+ @Test
+ public void genericV4InMemoryOnlyTlsMaterialDoesNotFailBuild() throws Exception {
+ AuthenticationDataProvider inMemoryOnly = new AuthenticationDataProvider() {
+ @Override
+ public boolean hasDataForTls() {
+ return true;
+ }
+ // No file paths and no keystore params — only (notional) in-memory material.
+ };
+ Authentication v5 = LegacyV4AuthenticationAdapter.wrap(new GenericTlsV4Auth("custom-tls", inMemoryOnly));
+
+ try (PulsarClient client = PulsarClient.builder()
+ .serviceUrl("pulsar+ssl://localhost:6651")
+ .authentication(v5)
+ .tlsPolicy(TlsPolicy.pem(null, null, null))
+ .build()) {
+ assertNotNull(client, "an in-memory-only generic TLS plugin must not fail the client build");
+ }
+ }
+
@Test
public void testTlsPolicyFieldsPropagate() {
- // Build a fully-populated TlsPolicy and confirm every field lands on the underlying
- // v4 ClientConfigurationData. Used to be a stub that only set useTls=true.
+ // Build a fully-populated TlsPolicy and confirm the policy — every field intact — lands on the
+ // underlying v4 ClientConfigurationData. Used to be a stub that only set useTls=true; under
+ // PIP-478 the policy rides conf.tlsPolicyMap under TlsPurpose.CLIENT_DEFAULT (consumed by the
+ // client TLS factory) instead of the legacy per-field TLS settings.
PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
TlsPolicy policy = TlsPolicy.builder()
.trustCertsFilePath("/path/to/ca.pem")
@@ -118,33 +216,38 @@ public class PulsarClientBuilderV5Test {
ClientConfigurationData conf = builder.getConfForTesting();
assertTrue(conf.isUseTls());
- assertEquals(conf.getTlsTrustCertsFilePath(), "/path/to/ca.pem");
- assertEquals(conf.getTlsKeyFilePath(), "/path/to/client.key");
- assertEquals(conf.getTlsCertificateFilePath(), "/path/to/client.cert");
- assertTrue(conf.isTlsAllowInsecureConnection());
- assertFalse(conf.isTlsHostnameVerificationEnable());
+ TlsPolicy applied = conf.getTlsPolicyMap().get(TlsPurpose.CLIENT_DEFAULT);
+ assertNotNull(applied, "the policy must land on the conf under CLIENT_DEFAULT");
+ assertEquals(applied.trustCertsFilePath(), "/path/to/ca.pem");
+ assertEquals(applied.keyFilePath(), "/path/to/client.key");
+ assertEquals(applied.certificateFilePath(), "/path/to/client.cert");
+ assertTrue(applied.allowInsecureConnection());
+ assertFalse(applied.enableHostnameVerification());
}
@Test
public void testTlsPolicyInsecureShortcut() {
- // TlsPolicy.ofInsecure() is the dev convenience that disables verification.
+ // TlsPolicy.insecure() is the dev convenience that disables verification.
PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
- builder.tlsPolicy(TlsPolicy.ofInsecure());
+ builder.tlsPolicy(TlsPolicy.insecure());
ClientConfigurationData conf = builder.getConfForTesting();
assertTrue(conf.isUseTls());
- assertTrue(conf.isTlsAllowInsecureConnection());
- assertFalse(conf.isTlsHostnameVerificationEnable());
+ TlsPolicy applied = conf.getTlsPolicyMap().get(TlsPurpose.CLIENT_DEFAULT);
+ assertNotNull(applied, "the insecure policy must land on the conf under CLIENT_DEFAULT");
+ assertTrue(applied.allowInsecureConnection());
+ assertFalse(applied.enableHostnameVerification());
}
@Test
public void testAuthenticationPluginAndParamsInstantiatesAuthentication() throws Exception {
// Regression for a bug where authentication(plugin, params) only set the strings and
- // never called conf.setAuthentication(...) with the instantiated Authentication object.
- // PulsarClientImpl reads the Authentication instance via conf.getAuthentication() at
- // connect time — without it, the client connects with no credentials and the broker
- // rejects the handshake. Use the v4 AuthenticationDisabled stub which always exists on
- // the classpath; we only care that *some* instance lands on the conf.
+ // never instantiated the plugin. PulsarClientImpl reads the actual Authentication instance
+ // via conf.getAuthentication() at connect time — without it, the client connects with no
+ // credentials and the broker rejects the handshake. Under PIP-478 the plugin is built eagerly
+ // at authentication(...) and resolved into conf.authentication at build();
+ // resolveAuthenticationForTest() runs that resolution. Use the v4 AuthenticationDisabled stub
+ // which always exists on the classpath; we only care that *some* instance lands on the conf.
PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
builder.authentication(
... 15414 lines suppressed ...