[feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) - #26282
[feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration)#26282lhotari wants to merge 7 commits into
Conversation
… + server-side TLS factory integration (core migration) This is the core of PIP-478: migrate the client and server to the asynchronous v5 authentication interface and the pluggable server-side TLS factory. The legacy PIP-337 PulsarSslFactory and SecurityUtility code paths are replaced throughout client, admin, broker, proxy, websocket and functions-worker, with secure-by-default, SAN-only hostname matching. Rebased onto the reworked default TLS factory (apache#26271) and current master. Assisted-by: Claude Code (Opus 5)
…tory API The default TLS factory was reworked during the review of apache#26271, so two of the APIs this migration calls have moved: - JettyTlsFactory's two public builders now require an Executor for the rotation reload, so that it never runs inline on the factory's delivery thread. Each of the five call sites passes the executor that component already owns for TLS work: the broker's shared executor (WebService), the websocket / functions-worker scheduled executor, the proxy web server's dedicated TLS refresh executor, and the admin handler's SSL refresher. - TlsContextAcquisition's HTTP rotation connection-TTL constant and system property were dropped from apache#26271 as unused; their consumers (HttpClient and the v5 FrameworkHttpClientFactory) arrive here, so they are restored with this change. Assisted-by: Claude Code (Opus 5)
Under PIP-337 the web-service provider keys only reached Jetty's
SslContextFactory.setProvider(...), which is inert on a factory that overrides
getSslContext() with a pre-built context — so the shipped Conscrypt default never actually
selected a provider. PIP-478 routes those keys onto TlsPolicy.jsseProvider, where a
configured name is pinned and an unresolvable one fails startup. That makes the default
real for the first time.
conscrypt-openjdk-uber ships native libraries for x86_64 only (linux, macOS, Windows), so
with the default in force the broker, proxy and websocket web listeners fail to start on
aarch64 (Apple silicon, ARM servers) and s390x:
No java.security.Provider named 'Conscrypt' could be resolved ...
at JcaProviders.resolveNamedProvider
at TlsContexts.buildJdkContext
at WebService.<init>
Reproduced locally on macOS/aarch64 by AdminProxyHandlerKeystoreTLSTest and
ProxyAuthenticatedProducerConsumerTest; linux-x86_64 CI cannot catch it.
Drop the Conscrypt default from ServiceConfiguration.webServiceTlsProvider,
ProxyConfiguration.webServiceTlsProvider and WebSocketProxyConfiguration.tlsProvider, and
from broker.conf, standalone.conf, proxy.conf, websocket.conf and functions_worker.yml.
Unset means the JVM default, which is what deployments have effectively been running all
along; an operator who wants Conscrypt still configures it explicitly and still gets the
loud failure when it cannot be resolved.
DefaultBrokerTlsPolicyTest#theDefaultConfigurationPinsNoJsseProviderOnAnyPurpose locks this
down and is mutation-verified: restoring the Conscrypt default fails it.
Assisted-by: Claude Code (Opus 5)
…ts are removed The Configuration "Defaults" note still described webServiceTlsProvider and the WebSocket proxy's tlsProvider as shipping Conscrypt. Making those keys authoritative on the JSSE axis is what forced the defaults to be dropped, so state that, with the reason (x86_64-only natives) and why it is operationally a no-op (the defaults were inert under PIP-337). Add the matching entry to the behaviour-change list. Assisted-by: Claude Code (Opus 5)
TLS hostname verification is on by default in 5.0 and CN-based matching is removed, so a test server certificate that names its host only in the CN is now rejected with "No subject alternative DNS name matching localhost found". Two fixtures still had that shape: - tests/certificate-authority/ec/server.cert.pem (and the matching JKS) carried DNS:pulsar, DNS:pulsar.default, IP:127.0.0.1, IP:192.168.1.2 — no localhost — which failed TlsWithECCertificateFileTest in CI (Broker Group 2). - tests/certificate-authority/jks/broker.keystore.jks carried CN=localhost and no SAN at all. Regenerate both with a localhost SAN, and record in generate_keystore.sh why the broker keystore needs one while the client and proxy keystores deliberately do not: only the broker cert is presented as a TLS server certificate, and only server certificates are hostname-verified. These fixtures were regenerated in the branch behind apache#26271 but left out of that PR, since nothing there wires the factory in and the old certificates still passed. This is the change that makes hostname verification live, so they belong here. Assisted-by: Claude Code (Opus 5)
…ame verification is on
conf/client.conf now ships tlsEnableHostnameVerification=true (hostname verification is on by
default since 5.0), and PerformanceBaseArguments resolves that key through picocli's
descriptionKey. Every pulsar-perf invocation in a distribution therefore sees
tlsHostnameVerificationEnable=TRUE, which the V5 client-builder helper read as "the user wants
TLS" and answered by wiring a TlsPolicy — and PulsarClientBuilderV5#tlsPolicy unconditionally
flips useTls=true.
The result is a TLS handshake against a plaintext pulsar:// endpoint, which the broker closes:
WARN PulsarDecoder - TLS handshake failed ... SslHandshakeCompletionEvent(
StacklessClosedChannelException)
Suppressed: StacklessSSLHandshakeException: Connection closed while SSL/TLS handshake was
in progress
This is what failed CI - Integration - Cli (PerfToolTest.testConsume); it reproduced on both
runs of this content.
Hostname verification can no longer signal intent now that it is the default, so drop it from
the test. The remaining signals — a pulsar+ssl:// URL, an explicit trust-cert path, or
tlsAllowInsecureConnection=TRUE — all still mean what they did. The flag continues to configure
the policy once TLS is on for one of those reasons.
The decision moves into a package-private wantsTls() so it can be asserted directly;
PerfClientUtilsTest#hostnameVerificationAloneDoesNotEnableTls is mutation-verified (restoring
the hostname-verification signal fails it).
Assisted-by: Claude Code (Opus 5)
A ClusterData entry configures the broker's outbound connections to one remote cluster, and it already carries that cluster's own TLS material (brokerClientTls*). It had no way to name the PulsarTlsFactory that consumes it: factory selection was broker-level only, so a deployment could not drive connections to one remote cluster through a different factory — an HSM- or KMS-backed one, say — while the rest of the cluster used the default. Add brokerClientTlsFactoryClassName and brokerClientTlsFactoryConfig to ClusterData, and apply them to both broker-client legs a cluster entry drives: the binary-protocol replication client (configTlsSettings) and the cross-cluster admin HTTPS client (configAdminTlsSettings). They are the successors of the deprecated brokerClientSslFactoryPlugin / ...Params, whose javadoc now points at them. A blank per-cluster value inherits the broker-level brokerClientTlsFactoryClassName / brokerClientTlsFactoryConfig rather than falling back to the default factory. That differs from the TLS material in a cluster entry, which is taken wholesale, and the asymmetry is deliberate: the factory selects the mechanism that loads material rather than the material itself, so a deployment with a custom broker-client factory must not silently revert to the file-based default just because a cluster entry did not repeat the setting. It is the same reasoning that already keeps brokerClientSslProvider / brokerClientJsseProvider broker-level in these helpers — a silent downgrade of the TLS mechanism is a security regression. pulsar-admin clusters create/update gain --tls-factory-class-name and --tls-factory-config, replacing the removed PIP-337 --tls-factory-plugin flags. Both tests are mutation-verified: TestCmdClusters#testTlsFactoryOptions fails when the builder wiring is dropped, and ReplicatorTlsFactoryTest#perClusterTlsFactorySelects... covers cluster-only, broker-only and both-set, the last of which fails when precedence is inverted. Assisted-by: Claude Code (Opus 5)
david-streamlio
left a comment
There was a problem hiding this comment.
Reviewed the full diff against bab5c77631 (215 files) in a worktree at d4ce308dc9; ./gradlew quickCheck passes. This is an impressively disciplined migration — the offload discipline, the ctor-throw cleanup on the TLS initializers, the use-after-free pinning around newHandler/newEngine, and the generation/liveness guards in the ClientCnx state machine all read as carefully thought through. The notes below are what I found; the first three look like genuine functional regressions, the rest are consistency/robustness/doc items.
Per-cluster TLS factory selection does not reach the cross-cluster admin client
The PR describes ClusterData.brokerClientTlsFactoryClassName as selecting the factory "for both broker-client legs the entry drives — the binary replication client and the cross-cluster admin (HTTPS) client." The admin leg does not honour it.
In BrokerService.getClusterPulsarAdmin, configAdminTlsSettings(...) writes the resolved (cluster-preferred) value onto the builder's conf via adminBuilder.tlsFactoryClassName(...) / .tlsFactoryConfig(...). Then BrokerService.java:1918 calls pulsar.applyBrokerClientTlsFactoryToAdmin(builder), which at PulsarService.java:1915 is gated on — and parameterised by — the broker-level getConfiguration().getBrokerClientTlsFactoryClassName():
- broker-level set, cluster overrides it →
conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, /* broker-level */ factoryClassName))andconf.setTlsFactoryParams(parseFactoryConfig(/* broker-level */ ...)).resolveClientTlsFactoryshort-circuits on a non-nullconf.getTlsFactory(), so the per-cluster name and config thatconfigAdminTlsSettingsjust wrote are never read. The cluster override is silently dropped. - broker-level blank, cluster names a factory → the method returns early, and
AsyncHttpConnectorlater resolves by name throughresolveClientTlsFactory(conf, ..., /* brokerClientPurpose */ false). So the custom factory is not wrapped inBrokerClientPurposeFactory. By the reasoning inClientTlsFactorySupport#wrapBrokerClientPurpose, a compliant custom factory serving onlyBROKER_CLIENTthen returnsempty()for the transport'sCLIENT_DEFAULTrequest — a hard connect failure.
ReplicatorTlsFactoryTest#perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnset is a good test, but it only exercises getReplicationClient; nothing covers getClusterPulsarAdmin, which is why this is invisible in CI.
Admin-only OAuth2 loses its IdP TLS material
PulsarAdminImpl.java:466 builds the FrameworkHttpClientFactory with conf::getTlsFactory as the TLS supplier. Nothing ever sets a factory on that conf on the admin path: AsyncHttpConnector.resolveNewTlsFactory keeps its factory in its own field rather than on the conf, and it runs after auth.start() anyway. So hasTlsFactory() is false, FrameworkHttpClientFactory.configureTls takes the legacy branch, and because the purpose is CLIENT_OAUTH2 rather than CLIENT_DEFAULT it applies nothing — the IdP connection falls back to the platform default trust store.
Binding a factory at all is also what disables the fallback that would have handled this: FlowBase.resolveHttpClientFactory() only reaches StandaloneOAuth2HttpClientFactory (the one place idpTlsPolicy() is consumed) when httpClientFactory == null.
Net effect: a PulsarAdmin using OAuth2 with trustCertsFilePath / tlsCertFile / tlsKeyFile in its authParams silently stops honouring them. v4 honoured them via FlowBase.defaultHttpClient, and PulsarClientImpl explicitly preserves the behaviour through hasOAuth2IdpTlsMaterial() / foldOAuth2IdpPolicy — the admin path has no equivalent. AdminOnlyOAuth2AuthTest uses a plaintext mock IdP, and none of OAuth2IdpTlsFoldTest / OAuth2IdpTlsFrameworkClientTest / OAuth2IdpTlsPlaintextBrokerTest involves PulsarAdmin.
tlsCertFile / tlsKeyFile pairing is no longer validated
The removed FlowBase.defaultHttpClient threw on a half-configured pair:
if (hasCertFile != hasKeyFile) {
throw new IllegalArgumentException("Invalid TLS client certificate configuration: "
+ CONFIG_PARAM_CERT_FILE + " and " + CONFIG_PARAM_TLS_KEY_FILE + " must be provided together");
}FlowBase.idpTlsPolicy() (line 154) replaces it with if (isNotBlank(certFile) && isNotBlank(keyFile)), and hasOwnTlsMaterial() returns true when either is set. A config with only one of the two now yields a policy with no client identity and no error — the mTLS handshake to the IdP silently degrades instead of failing at startup. Nothing else in the tree validates the pair (TlsClientAuthFlow.fromParameters doesn't either, and TlsClientAuthFlowTest lost its factory-mocking wrapper).
tlsCertRefreshCheckDurationSec=0 now means two different things
DefaultBrokerTlsFactory.refreshIntervalSeconds maps <= 0 to 0, with a deliberate comment: "an operator who set 0 still gets no poll." The other three components do the opposite:
ProxyTlsFactories.java:144→DEFAULT_REFRESH_INTERVAL_SECONDS(60)ProxyServer.buildDefaultWebTlsFactory(websocket) → sameWorkerServer.java:392→ same
Under PIP-337 all three gated the refresh task on > 0, so 0 disabled polling everywhere. This is both a behaviour change and an inconsistency with the broker's documented handling of the same key.
Proxy startup race: listeners bind before the broker-client TLS is built
ProxyService.start() binds listenChannel and listenChannelTls and only then, at line 344, builds brokerClientTlsFactory / brokerClientSslContext / lookupClientTlsFactory. A connection accepted in that window hits either TlsContextAcquisition.withPinnedContext(service::getBrokerClientSslContext, ...) in DirectProxyHandler with a null context, or a null conf.getTlsFactory() from ProxyConnection.createClientConfiguration — the javadoc on getLookupClientTlsFactory() already acknowledges it is "transiently null while it is being built at startup". Under PIP-337 DirectProxyHandler built its factory lazily per remote host, so no such window existed. Moving the isTlsEnabledWithBroker() block above the binds closes it.
Per-cluster factory class and its config fall back independently
BrokerService.java:1756 resolveBrokerClientTlsFactory is applied separately to ...ClassName and ...Config. A ClusterData that sets only the class name therefore inherits the broker-level brokerClientTlsFactoryConfig — factory A's init params handed to factory B. Given the reasoning in the method's own javadoc (don't silently change the TLS mechanism), the pair should probably resolve atomically: if the cluster supplies a class name, its config wins even when blank.
The documented WARN is never emitted
ClusterData#getBrokerClientSslFactoryPlugin, the ClusterDataImpl @Schema descriptions, the PR body, and pip-478.md §1192 all say a stale value read from the store is "ignored with a WARN". Grepping every non-test main source for sslFactoryPlugin turns up only field declarations and comments — nothing logs. Since this is explicitly called out as the one place a stale PIP-337 value cannot fail loud, the WARN is the whole remediation signal.
pip-478.md contradicts itself and this PR
§1238 and §1241 still state that the ClusterData factory fields are removed from the metadata model along with their accessors and builder methods, that the pulsar-admin clusters options are removed, and that "per-cluster factory selection no longer exists — set the factory broker-level." §1192 and this PR do the opposite: deprecate-and-retain the old fields and add per-cluster selection plus --tls-factory-class-name / --tls-factory-config. The PIP is the normative spec and this PR already touches it, so those two sections should be updated in the same change.
Smaller items
ProxyServer.createTlsFactoryWebServer(line 239) leaks on partial init failure.WebService.createTlsFactoryWebServer,ServiceChannelInitializer.initializeTlsFactoryand the broker'sPulsarChannelInitializer.initializeTlsFactoryall wrap the post-createFactorysteps in a try/catch that disposes the subscription and closes the factory.ProxyServerdoesn't, and its caller rethrows asPulsarServerExceptionwithout ever reachingclose(), so the cert watchers survive a failed startup.- The v4 auth shims rebuild the driver per connection attempt.
AuthenticationToken,AuthenticationBasicandAuthenticationOAuth2each donew V5BinaryAuthenticationDriver(new XxxV5(...), authServices).newAuthenticationExchange(host)insidenewAuthenticationExchange. That constructs a freshAuthMetrics— two OpenTelemetry instrument builds — and re-runsensureInitialized()'sinitializeAsync().join()on every connection attempt, including reconnect storms.HttpAuthenticationDriver's own javadoc states the intended shape ("One instance is created per plugin and reused across requests"); caching the driver in a field would match it. ensureInitialized()joins on the calling thread in bothV5BinaryAuthenticationDriverandHttpAuthenticationDriver. The comments justify it by "the built-in bodies complete immediately", which holds for the built-ins, but nothing in theAuthenticationSPI javadoc makes that a contract — a third-party v5 plugin doing I/O ininitializeAsyncwould block whatever thread first opens an exchange, which is the hazard the PIP exists to remove. Worth either stating it as a hard contract oninitializeAsyncor hoisting init off the call path.LegacyV4AuthenticationAdapter.wraproutes by auth-method name. Only the literal"sasl"getsLegacyV4ChallengeResponseAdapter; every other name falls toLegacyV4CredentialAdapter, which does not exposeBinaryAuthChallengeHandler. A third-party v4 multi-round plugin under a different method name will fail the firstCommandAuthChallengewith "does not expose BinaryAuthChallengeHandler". Separately,LegacyV4TlsAdapterforwardsconfigure(...)but neverv4.start()orv4.close(), so a custom plugin reporting method name"tls"is neither started nor closed.LegacyV4CredentialAdapter.getHttpHeadersAsynccalls the deprecated no-argv4.getAuthData()even though theHttpAuthCallContextcarriesrequestUri(), dropping per-host credential selection.- Per-host client TLS material. The old client keyed a
PulsarSslFactoryper SNI host and fed itgetAuthData(host);DirectProxyHandlerdid the same per remote host. Both now share one factory/context with only aTlsEndpointhint. Is dropping per-host client identity intentional? If so it's probably worth a line in the PIP's upgrade notes. newConnectCommand()→buildConnectCommand(AuthData)silently breaks out-of-treeClientCnxsubclasses (all in-tree callers are updated). A deliberate 5.0 break, but it isn't in the PR's public-API inventory.PulsarClientProvider.authenticationTls(String, String)→authenticationTls()is a v5 public-API signature change not called out under Modifications.jsseProvideris missing fromconf/functions_worker.ymleven thoughWorkerConfiggained the field and broker/proxy/standalone/client all document it.- Deleted tests for still-present classes.
KeyStoreTlsTest,JettySslContextFactoryTestandJettySslContextFactoryWithKeyStoreTestare removed here, but the PIP-337 classes they cover (KeyStoreSSLContext, andJettySslContextFactory— that one is deleted, so its tests going is fine) remain in the tree until a later PR. Was there a reason not to keepKeyStoreTlsTestuntilKeyStoreSSLContextitself goes?
…ually take effect Review of apache#26282 found that ClusterData.brokerClientTlsFactoryClassName never reached the factory that gets built, in either direction: - With a broker-level factory configured, PulsarService.maybeApplyBrokerClientTlsFactory and applyBrokerClientTlsFactoryToAdmin both read ServiceConfiguration directly, built the broker-level factory, and left conf.tlsFactory non-null — so the cluster's selection, which BrokerService had already resolved onto the configuration, was silently discarded. - With the broker-level key blank, both methods returned early. The cluster's class name then rode the config into resolveClientTlsFactory with brokerClientPurpose=false, so the custom factory was never wrapped in BrokerClientPurposeFactory. A compliant BROKER_CLIENT-only factory answers the transport's CLIENT_DEFAULT request with empty, failing the connection. Both apply methods now read the resolved selection off the configuration the caller populated, falling back to the broker-level key, so the cluster value both wins and is purpose-wrapped. The class name and its configuration also resolve atomically now: a cluster that overrides only the class name previously inherited the broker-level brokerClientTlsFactoryConfig, handing factory A's init parameters to factory B. Also emit the WARN that ClusterData's deprecation javadoc, the @Schema descriptions and pip-478.md all promise for a stale PIP-337 brokerClientSslFactoryPlugin read from the store — nothing logged it, and it is the whole remediation signal for the one stale value that cannot fail loud. It fires once per cluster and names it. ReplicatorTlsFactoryTest now asserts a factory is actually built, not just that the name reaches the config, and covers getClusterPulsarAdmin — the leg nothing exercised, which is why this was invisible in CI. Mutation-verified: restoring either method's broker-level read fails them. Assisted-by: Claude Code (Opus 5)
…ually take effect Review of apache#26282 found that ClusterData.brokerClientTlsFactoryClassName never reached the factory that gets built, in either direction: - With a broker-level factory configured, PulsarService.maybeApplyBrokerClientTlsFactory and applyBrokerClientTlsFactoryToAdmin both read ServiceConfiguration directly, built the broker-level factory, and left conf.tlsFactory non-null — so the cluster's selection, which BrokerService had already resolved onto the configuration, was silently discarded. - With the broker-level key blank, both methods returned early. The cluster's class name then rode the config into resolveClientTlsFactory with brokerClientPurpose=false, so the custom factory was never wrapped in BrokerClientPurposeFactory. A compliant BROKER_CLIENT-only factory answers the transport's CLIENT_DEFAULT request with empty, failing the connection. Both apply methods now read the resolved selection off the configuration the caller populated, falling back to the broker-level key, so the cluster value both wins and is purpose-wrapped. The class name and its configuration also resolve atomically now: a cluster that overrides only the class name previously inherited the broker-level brokerClientTlsFactoryConfig, handing factory A's init parameters to factory B. Also emit the WARN that ClusterData's deprecation javadoc, the @Schema descriptions and pip-478.md all promise for a stale PIP-337 brokerClientSslFactoryPlugin read from the store — nothing logged it, and it is the whole remediation signal for the one stale value that cannot fail loud. It fires once per cluster and names it. ReplicatorTlsFactoryTest now asserts a factory is actually built, not just that the name reaches the config, and covers getClusterPulsarAdmin — the leg nothing exercised, which is why this was invisible in CI. Mutation-verified: restoring either method's broker-level read fails them. Two documentation fixes ride along, since they describe this same disposition: - pip-478.md contradicted itself and this PR on whether the PIP-337 ClusterData fields are removed. Five passages said removed — the breaking-change bullet, the "one silently-dropped case" bullet, the geo-replication note (which explained compatibility through Jackson lenient-dropping an unknown property, impossible for a retained field), the removal list's "nothing PIP-337 is retained (no @deprecated fields)", and the implementation-approach paragraph. All now describe the retained-and-ignored disposition, the successor fields, and the WARN that replaces lenient-dropping as the remediation signal. - conf/functions_worker.yml gains the jsseProvider documentation it was missing; WorkerConfig has the field and broker.conf, proxy.conf, standalone.conf and client.conf all document it. Assisted-by: Claude Code (Opus 5)
PIP: #25890 (
pip/pip-478.md)Motivation
This is the third implementation PR for PIP-478, following #26222 (the
pulsar-tls-factory-api/pulsar-http-client-apiSPI modules) and #26271 (the defaultFileBasedTlsFactoryimplementation).The two PRs so far were additive: the new SPI and its default implementation landed with no consumers, and PIP-337's
PulsarSslFactory/SecurityUtilitystack remained the TLS path actually in use. This PR is the migration itself — it wires the asynchronous v5 authentication interface and thePulsarTlsFactorySPI into the client, admin, broker, proxy, WebSocket proxy and functions worker, so the new SPI becomes the path that runs.That is also what turns the engine and provider selection added in #26271 into something an operator can configure end to end, and what makes PIP-478's secure-by-default posture (hostname verification on, SAN-only matching) real.
Modifications
Asynchronous v5 client authentication.
pulsar-client-api-v5gains theorg.apache.pulsar.client.api.v5.authSPI:Authentication,AuthenticationData,AuthChallenge/ChallengeResponse, the binary and HTTP challenge handlers, and the init/call contexts. Every operation that can perform I/O returns aCompletableFuture, so an authentication plugin never blocks an event-loop thread — the core motivation of the PIP.pulsar-clientimplements it inorg.apache.pulsar.client.impl.auth.v5:BinaryAuthenticationExchange(the binary-protocol challenge/response engine),HttpAuthenticationDriver+FrameworkHttpClient(the HTTP side, on thePulsarHttpClientSPI), the built-in token/basic plugins, andAuthMetrics.LegacyV4AuthenticationAdapterdrives an existing v4 plugin from the v5 engine, andV5ToV4AuthenticationAdapterlets a v5 plugin serve the v4 client. Existing third-party v4 plugins keep working unchanged.Server-side and client-side TLS factory integration.
PulsarService,BrokerService,PulsarChannelInitializerandWebServiceacquire TLS throughDefaultBrokerTlsFactory/JettyTlsFactoryinstead ofPulsarSslFactory.ProxyService,DirectProxyHandlerandAdminProxyHandler(JettySslContextFactory.Client), plusProxyTlsFactoriesfor the proxy's own purposes.ClientCnx,ConnectionPool,PulsarClientImpl,HttpClientand the admin HTTP connector.Configuration surface.
sslFactoryPlugin/sslFactoryPluginParams/brokerClientSslFactoryPlugin/brokerClientSslFactoryPluginParamsconfig keys are removed, together with theClientBuilder/PulsarAdminBuildersslFactoryPlugin(...)/sslFactoryPluginParams(...)methods and thepulsar-admin clusters --tls-factory-plugin/--tls-factory-plugin-paramsCLI options. A stale non-default value in a config file is rejected at startup with a migration pointer rather than silently ignored. TheClusterData.brokerClientSslFactoryPlugin/...Paramsaccessors are the one exception: they stay on the metadata schema for wire compatibility, now@Deprecated, and a value read from the store is ignored with a WARN rather than making a cluster unloadable.tlsFactoryClassName/tlsFactoryConfig(andbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfig) select aPulsarTlsFactoryby name, on the server configs and on the v4 client and admin builders.jsseProviderkey (client, broker, proxy, and thebrokerClient*variants) names the JSSE (SSLContext)java.security.Provider.tlsProviderstays overloaded for v4 parity: a value that is not a Netty engine literal is routed tojsseProvider, which wins when both are set.ClusterDatagainsbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfig, so a deployment can drive connections to one remote cluster through its own factory. A cluster entry already carries that cluster's TLS material; these select the factory that consumes it, for both broker-client legs the entry drives — the binary replication client and the cross-cluster admin (HTTPS) client. A blank value inherits the broker-level setting rather than reverting to the default factory: the factory selects the mechanism rather than the material, so a deployment with a custom broker-client factory must not silently downgrade because a cluster entry did not repeat it.pulsar-admin clusters create|updateexpose them as--tls-factory-class-name/--tls-factory-config.Default value changes (all called out in the PIP's Upgrade section):
tlsHostnameVerificationEnabled(broker/proxy/websocket),tlsEnableHostnameVerification(client),tlsEnableHostnameVerification(functions worker) — and CN-based matching is gone, so a server certificate must carry a matching SubjectAltName. This is the secure-by-default hardening PIP-478 exists to deliver; the remediation (reissue certificates with a SAN, or opt out per component) is documented in the PIP.webServiceTlsProvider(broker and proxy) and the WebSocket proxy'stlsProviderno longer default toConscrypt. Under PIP-337 that default only reached Jetty'sSslContextFactory.setProvider(...), which is inert on a factory that overridesgetSslContext()with a pre-built context — so it never actually selected a provider. This PR makes those keys authoritative on the JSSE axis, andconscrypt-openjdk-uberships native libraries for x86_64 only, so keeping the default would fail the web listener at startup on aarch64 (Apple silicon, ARM servers) and s390x. Unset selects the JVM default, which is what deployments have effectively been running; an operator who wants Conscrypt configures it explicitly and still gets a loud failure where it cannot be loaded.Test fixtures.
tests/certificate-authority/ec/server.cert.pemandjks/broker.keystore.jksare regenerated with alocalhostSubjectAltName — they previously identified the host only through the CN, which SAN-only verification rejects.generate_keystore.shrecords why only the broker keystore needs a SAN (the client and proxy keystores are client identities and are never hostname-verified).Not in this PR. The PIP-337 classes themselves (
PulsarSslFactory,DefaultPulsarSslFactory,SecurityUtility,KeyStoreSSLContext) still exist, now unused by production code; deleting them, along with migrating the Athenz and SASL plugins to the v5 SPI, are the remaining PRs in the series.Verifying this change
This change added tests and can be verified as follows:
ProxyTlsFactoriesTest,ProxyTlsFactoryMetricsTest,DefaultBrokerTlsPolicyTest,TlsFactorySupportTest).DefaultBrokerTlsPolicyTest#theDefaultConfigurationPinsNoJsseProviderOnAnyPurposelocks down the provider-default change;PerfClientUtilsTest#hostnameVerificationAloneDoesNotEnableTlslocks down that hostname verification being on by default does not imply TLS intent. Both were verified to fail against the unfixed code.TestCmdClusters#testTlsFactoryOptionsandReplicatorTlsFactoryTest#perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnsetcover the per-cluster factory: CLI parsing through to theClusterDatasent to the admin API, and the resolution rule across cluster-only, broker-only and both-set. Both were verified to fail against the unfixed code — the both-set case is what catches inverted precedence.TlsWithECCertificateFileTest, the keystore-TLS tests,TlsProducerConsumerTest,AdminApiTlsAuthTest,ReplicatorTlsTestand the proxy authentication tests.:pulsar-common:test :pulsar-broker-common:test :pulsar-client-original:test :pulsar-client-admin-original:test :pulsar-client-tools:test :pulsar-testclient:test :pulsar-websocket:test :pulsar-client-v5:test :pulsar-functions:pulsar-functions-worker:test— 2726 tests, 0 failures.assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicensepasses.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
org.apache.pulsar.client.api.v5.authSPI, and addsbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfigtoClusterData(accessors plus builder methods); removes the v4ClientBuilder.sslFactoryPlugin(...)/sslFactoryPluginParams(...)and thePulsarAdminBuilderequivalents (a source-compatibility break on upgrade, inventoried in the PIP). TheClusterDatafactory-plugin accessors are deprecated and ignored rather than removed, so no existing metadata field changes meaning.webServiceTlsProvider/ the websockettlsProviderno longer default toConscrypt. Both are detailed under Modifications above and in the PIP's Upgrade section.pulsar-admin clusters create|updatelose the PIP-337--tls-factory-plugin/--tls-factory-plugin-paramsand gain--tls-factory-class-name/--tls-factory-config, which write the new per-clusterClusterDatafields.Documentation
doc-required— the configuration surface changes (newtlsFactoryClassName/tlsFactoryConfig/jsseProviderkeys, removedsslFactoryPlugin*keys) and the secure-by-default hostname-verification behaviour need to be reflected in the security documentation on the website.doc-not-neededdocdoc-completeMatching PR in forked repository
PR in forked repository: lhotari#249 (Personal CI — full matrix green)
This PR was prepared with the assistance of Claude Code (Opus 5); the change was reviewed and is submitted by a human contributor who takes responsibility for it, per the ASF Generative Tooling guidance.