[feat][misc] PIP-478: default file-based TLS factory implementation - #26271
Conversation
### Motivation This is the second implementation PR for [PIP-478](apache#25890) (design doc), following apache#26222 which added the SPI modules. It adds the **default implementation** of the `PulsarTlsFactory` SPI and the JDK-level TLS primitives it is built from. The PR is purely **additive**: the PIP-337 stack (`PulsarSslFactory`, `DefaultPulsarSslFactory`, `JettySslContextFactory`, `SecurityUtility`, `KeyStoreSSLContext`) still exists and remains the wired implementation, and the new code is exercised only by its own unit tests. Wiring it into the client and the server components, and removing the superseded PIP-337 classes, are later PRs in the series. ### Modifications `pulsar-common` — `org.apache.pulsar.common.tls.impl`, the default factory: - `FileBasedTlsFactory` + `FileBasedTlsFactorySettings`: the default `PulsarTlsFactory`, built from PEM or keystore file material. It owns the purpose→source registry, purpose resolution (direct map lookup, then the role's terminal rule — client purposes resolve to the system default, server purposes fail), and the reload fan-out to subscribers. - `TlsMaterialSource` / `MaterialSource` / `AuthProvidedMaterialSource` / `TlsMaterial`: per-purpose material load, watch and cache. Value equality on the loaded material suppresses spurious rebuilds when files are touched but unchanged, a failed reload keeps the last good material, and contexts are **rebuilt, never mutated in place**, so a native OpenSSL engine never observes a torn context. - `TlsContexts` / `TlsContextAcquisition` / `SynthesizedEngineSslContext` / `TlsSynthesisSpec`: SSL context assembly, per-acquisition read of the current context, and `SSLParameters` companion synthesis. - `TlsKeyStoreLoader`, `TlsFactoryProbe`, `TlsReloadMetrics`, `package-info`. `pulsar-common` — `org.apache.pulsar.common.util.tls`, the JDK-level primitives: - `PemReader` (PEM parsing), `JdkSslContexts` (JDK `SSLContext` assembly), `JcaProviders` (provider-name resolution; new home of the `BC` constant), `JcaKeyStores` (`KeyStore` creation through a pinned provider). - These implement both provider axes carried by `TlsPolicy`: `jsseProvider` pins the `SSLContext` / `KeyManagerFactory` / `TrustManagerFactory` (with the documented key/trust-manager algorithm negotiation, since BCJSSE registers `PKIX` but not `SunX509`), and `jcaProvider` pins the `KeyStore` / `CertificateFactory` / `KeyFactory` engines that parse the material. Both are unset by default, which is exactly today's behaviour. `pulsar-common` — `KeyStoreHolder` gains an optional pinned JCA provider and, on that path only, a random per-instance entry password (a FIPS provider in approved-only mode rejects an empty password-based-KDF password). With no pinned provider the store type and the empty entry password are unchanged, so existing callers keep working. `pulsar-broker-common`: - `broker/tls/DefaultBrokerTlsFactory` + `TlsFactorySupport`: the default broker-side factory, composed from the **existing** base `tls*` fields of `ServiceConfiguration`. No new configuration keys are added here — those arrive with the migration PR. - `jetty/tls/JettyTlsFactory`: the framework-side Jetty reload driver built on the new factory. - Build wiring: `pulsar-tls-factory-api` and `pulsar-common` become direct `api` dependencies, since these classes expose the SPI on the module's public surface. ### Verifications Unit-tested with real in-memory TLS handshakes and rotation (no broker, no sockets), including the OpenSSL engine path and provider pinning. Key suites: `FileBasedTlsFactoryTest`, `TlsMaterialSourceTest`, `SslParametersSynthesisTest`, `TlsReloadMetricsTest`, `AuthProvidedMaterialFoldTest`, `JcaProviderPinningTest`, `JcaProvidersTest`, `JdkSslContextsTest`, `TlsFactorySupportTest`, `JettyTlsFactoryTest`. `./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1243 tests, 0 failures. Assisted-by: Claude Opus 5 (Claude Code)
…ider axes Follow-up to the default file-based TLS factory in the previous commit, applying the findings of a local multi-model review of that change. ### Motivation Three themes. **Engine selection and JEP 421.** The factory mapped an unset `tlsProvider` to the JDK engine, silently moving deployments off the native engine the PIP-337 path had used (it passed a null provider, and `SslContextBuilder` then picks the native engine wherever `netty-tcnative` has a binary). Restoring that default raises a second question: Netty's `OPENSSL` frees its native `SSL_CTX` from a `finalize()` method, and finalization is deprecated for removal (JEP 421) and can already be disabled with `--finalization=disabled`, under which the safety net becomes a permanent native leak. `OPENSSL_REFCNT` is the same engine without the finalizer, and this factory already owns and deterministically releases every context it builds. **Provider pinning did not reach the Netty engine.** `SslContextBuilder.sslContextProvider` pins the `SSLContext` only; handing Netty raw key/trust material makes it build the carrier `KeyStore` and the key/trust manager factories through the JVM provider search order (`SslContext.buildKeyManagerFactory` / `buildTrustManagerFactory` call the no-provider `getInstance` forms). A `jsseProvider=BCJSSE` + `jcaProvider=BCFIPS` deployment therefore got its private-key side from SunJSSE — FIPS-shaped rather than FIPS-compliant, which is the exact property the two axes exist to deliver. **The two provider axes were not documented as distinct.** `tlsProvider` names a Netty *engine*; `webServiceTlsProvider` names a *JSSE (SSLContext) provider* for Jetty, which has no native engine. The similar names invite configuring an engine value where only a JSSE provider is valid. ### Modifications Engine selection (`TlsFactorySupport.engineProvider`): - Unset now selects `SslProvider.OPENSSL_REFCNT` when `OpenSsl.isAvailable()`, else `SslProvider.JDK` — restoring the historical default on the finalizer-free variant. - An explicit engine literal is honored verbatim, so a configured `OPENSSL` still yields `SslProvider.OPENSSL` rather than being silently rewritten to a different enum value. - `FileBasedTlsFactoryTest.closeReleasesEveryBuiltContextToZero` asserts the reference-count ledger actually reaches zero, since without a finalizer it is the only thing freeing the native context. Provider pinning (correctness of the two axes): - `JdkSslContexts` grows `createKeyManagerFactory(PrivateKey, Certificate[], Provider, Provider)` and `createTrustManagers(...)`, extracted from the paths that already existed for the JDK context, and `TlsContexts` routes the Netty builders through them whenever either axis is pinned. With neither pinned the raw-material path is unchanged. - `KeyStoreHolder` no longer wraps `JcaKeyStores`' actionable "provider does not supply store type X" message in a generic "KeyStore creation error". Configuration documentation — `ServiceConfiguration`, `ProxyConfiguration`, `broker.conf`, `standalone.conf` and `proxy.conf` now describe the engine-vs-JSSE axis split, what an unset `tlsProvider` selects, and that Netty engine values are not valid `webServiceTlsProvider` names. No default value changes. Rotation and lifecycle: - `FileBasedTlsFactory.loadInitialInstance` arms the redeliver retry when a context build fails *after* the material refresh consumed the rotation's change signal. Without it every later poll saw `changed=false` and subscribers stayed on the pre-rotation certificate until the files changed again. - `TlsContextAcquisition`'s synthesizing subscription catches throwables in `publish` (it runs inside a `whenComplete` whose future is discarded, so an exception vanished silently) and drops deliveries that land after `dispose()`. - `FileBasedTlsFactory.initialize` requires a non-null `blockingExecutor` instead of falling back to caller-thread execution, and closes a previous `TlsReloadMetrics` before replacing it so a re-initialize cannot orphan its observable gauge. Configuration parity: - `tlsCertRefreshCheckDurationSec <= 0` again means "no background poll", as every v4 call site (`WebService`, `PulsarChannelInitializer`, websocket `ProxyServer`) and `FileBasedTlsFactorySettings` both document, rather than being replaced by the 60s default. PEM parsing: - `PemReader`'s private-key preamble scan consumed two lines per iteration, so whether a key file parsed depended on the parity of the number of lines before `-----BEGIN` — an odd count (one comment, a blank line, an openssl "Bag Attributes" header) swallowed the marker and failed with a misleading "algorithm is not supported". Carried over verbatim from `SecurityUtility`; fixed here since this is now the shared PEM primitive. Documentation accuracy: several javadoc comments described PIP-337 classes as already removed and referenced a `sslFactoryPlugin` rejection that arrives with a later PR; they now describe the state at this point in the series. The `SSLParameters` note on `FileBasedTlsFactory` no longer claims a JDK `SSLContext` carries engine policy (it cannot — those are per-engine `SSLParameters`), and the Conscrypt note records that `SecurityUtility`'s process-global CN-tolerant hostname verifier still wins while both stacks coexist. Removed an unused HTTP rotation-TTL constant and system property whose consumer arrives with the client wiring. ### Verifications `./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1254 tests, 0 failures. New regression tests: `PemReaderTest` (verified to fail against the pre-fix scan), `JcaProviderPinningTest.nettyContextBuildRoutesCarrierKeyStoreThroughThePinnedJcaProvider` (a provider registering no services must make the Netty build fail loudly, which it could not before), the refcount-to-zero assertion above, and the engine-selection cases. `./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes. Assisted-by: Claude Opus 5 (Claude Code)
…tered JCA providers ### Motivation `JcaProviders` is the primitive every TLS policy goes through to resolve the PIP-478 `jsseProvider` / `jcaProvider` names. Two problems with how it handled Bouncy Castle and provider resolution: **Bouncy Castle was mandatory, and only by accident.** `public static final Provider BC_PROVIDER = getProvider()` ran during class initialization and `getProvider()` threw when Bouncy Castle was absent. So merely resolving a named provider — the one thing production code actually uses this class for — required `bcprov` on the classpath, in a deployment that may not want Bouncy Castle at all. Nothing in the new TLS stack needs it: both provider axes are unset by default and the platform providers (SunJSSE and friends) are used, which are also the better optimized ones. Bouncy Castle is needed only where it is explicitly asked for. **Resolution returned a different instance than the operator installed.** It tried `ServiceLoader` before `Security.getProvider`. `ServiceLoader` can only build a provider through its *no-arg* constructor, so where both could answer a name, a freshly constructed provider won over the one the operator registered — losing any provider-level configuration on it, and returning an object that is not the one in the JVM's provider list. `bcprov` ships a `META-INF/services/java.security.Provider` entry, so this was reachable for a pinned `jcaProvider=BC`. (BouncyCastle's *JSSE* provider is unaffected either way: `bctls` ships no services entry, so `BCJSSE` is only ever resolvable through an operator registration — which is also the only way its FIPS mode, a constructor argument, can reach Pulsar.) ### Modifications - `BC_PROVIDER`, the public `getProvider()` and `isBCFIPS()` are replaced by `Optional<ResolvedBouncyCastleProvider> bouncyCastleProvider()` and `ResolvedBouncyCastleProvider requireBouncyCastleProvider()`, where `ResolvedBouncyCastleProvider` is a record carrying the `Provider` together with whether it is the FIPS artifact. The flavour is classified once at resolution instead of being re-derived from the provider's class name on each query, and absence is a value rather than a class-init failure. Resolution is lazy, behind a holder class, so loading `JcaProviders` never requires Bouncy Castle; it still installs the provider process-wide when it does resolve one. - `resolveNamedProvider` consults `Security.getProvider(name)` first and `ServiceLoader` second. The classpath-discovery path is unchanged for a name nobody registered; only *which instance* wins when both can answer changes, and the registered one — the operator's configuration — now does. - `pip/pip-478.md` is updated to describe the new order and no longer documents the shadowing hazard as a consequence operators must live with. Conscrypt is unchanged: it is still resolved during class initialization, reports absence as `null`, and never fails class loading. ### Verifications `./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1258 tests, 0 failures. `JcaProvidersTest.anOperatorRegisteredProviderWinsOverTheServiceLoaderInstance` reproduces the shape with a `META-INF/services`-declared test provider whose configuration is a constructor argument, and was confirmed to fail against the previous resolution order; a companion test pins that the `ServiceLoader` path still resolves a name nobody registered. `./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes. Assisted-by: Claude Opus 5 (Claude Code)
…emand
### Motivation
`jsseProvider=BCJSSE` is the JSSE half of the FIPS configuration the two provider axes exist to
make expressible, but it was the one provider name Pulsar could not resolve on its own.
`bctls` ships no `META-INF/services/java.security.Provider` entry, so `BCJSSE` is invisible to
`ServiceLoader`, and it is absent from `Security` until something registers it. Pinning it
therefore failed unless the operator had already registered the provider themselves. Nor can it
simply be default-constructed: its FIPS mode is a constructor argument fixed at construction
(`new BouncyCastleJsseProvider("fips:BCFIPS")`) while the provider registers under the plain name
`BCJSSE` either way — so a no-arg instance would quietly be the non-FIPS one, which is precisely
the FIPS-shaped-but-not-FIPS outcome the two axes are meant to prevent.
### Modifications
`JcaProviders` resolves `BCJSSE` explicitly, lazily, and reflectively:
- An instance the operator registered wins, whatever mode they built it in.
- Otherwise, when `bctls` is on the classpath, it is constructed and registered here. The mode
follows the BouncyCastle JCA provider that actually resolved: `fips:<name>` when that is the
FIPS artifact, the no-arg constructor otherwise. Resolving the JCA provider first also installs
it, which the FIPS form needs — the JSSE provider looks its crypto provider up by name while
constructing.
- Otherwise it is absent and pinning `jsseProvider=BCJSSE` still fails loudly, now naming the
missing jar.
The decision is a `@VisibleForTesting` pure function (`jsseProviderConfig`) so both branches are
testable without bc-fips, which cannot share a classpath with bcprov. FIPS mode is read back
through the provider's own `isFipsMode()`, reflectively, so this module never needs `bctls` to
compile — the same reflective-loading pattern `pulsar-common` already uses for bcprov.
`bctls-jdk18on` is added to the version catalog and as a **test** dependency of `pulsar-common`,
matching how bcprov is scoped there. It is deliberately not a runtime dependency: which
BouncyCastle artifacts land on the classpath is a packaging decision, made separately.
### Verifications
`./gradlew :pulsar-common:test :pulsar-broker-common:test` — 1261 tests, 0 failures.
New tests: the FIPS/non-FIPS branch selection; on-demand registration from the classpath
(asserting the provider is installed and, on this non-FIPS classpath, does not claim FIPS); and
that the registered provider really supplies what the TLS stack asks of it — `SSLContext.TLS`,
`TrustManagerFactory.PKIX`, and `KeyManagerFactory.PKIX` but *not* the JDK-default `SunX509`,
which is what makes the key-manager algorithm negotiation in `JdkSslContexts` load-bearing.
`./gradlew assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicense` passes.
Assisted-by: Claude Opus 5 (Claude Code)
david-streamlio
left a comment
There was a problem hiding this comment.
Detailed comments inline. The summary — and the one issue I'd call major — are in the conversation comment on this PR, so I won't repeat them here.
Short version: the change is additive and reads well, and almost all of my concerns are about what the migration PR inherits rather than defects in this one. Nothing blocking on its own.
… declaration
Review comment: does `TlsReloadMetrics.create(...)` on the mandatory `initialize()` path risk a
`NoClassDefFoundError`, given `opentelemetry-api` is `compileOnly` in `pulsar-common`?
It does not — but the build file said something false, which is what prompted the question.
`pulsar-common` declares `api(project(":pulsar-tls-factory-api"))`, and that module declares
`api(libs.opentelemetry.api)` because `TlsFactoryInitContext` exposes `OpenTelemetry` on the SPI
surface. The classes therefore reach this module's compile *and* runtime classpaths transitively,
and propagate at api scope to every downstream consumer.
That makes the `compileOnly` declaration redundant, and its comment ("Kept compileOnly (matching
pulsar-tls-factory-api)") wrong, since the API module uses `api`. Deleting both is a better answer
than promoting it to `implementation`: it removes the thing that looked like a risk.
Verified: `./gradlew :pulsar-common:dependencies --configuration runtimeClasspath` lists
io.opentelemetry:opentelemetry-api:1.62.0, and :pulsar-common:compileJava succeeds without the line.
Assisted-by: Claude Opus 5 (Claude Code)
… javadoc Review comment raised three points on `resolveNamedProvider`, all fair: 1. The `ServiceLoader` step constructs every candidate provider as it iterates. That is unavoidable here — the name being matched lives on the instance, not on the service descriptor, so `ServiceLoader.stream()` would defer construction without being able to answer the query — but it was undocumented. Now stated. 2. A provider resolved through `ServiceLoader` is returned without `Security.addProvider`. That is deliberate (resolution should not mutate global provider state, and consumers pass the object straight to `getInstance`), but given how much the javadoc says about *which instance* answers a name, the asymmetry with a later `Security.getProvider` deserved stating. Now stated. 3. An inline comment still described `Security.getProvider` as "step 2". It became step 1 when the resolution order was changed to prefer an operator-registered provider; the comment was stale. Documentation only; no behaviour change. Assisted-by: Claude Opus 5 (Claude Code)
…r with a named call
Review comment: the `if (JcaProviders.CONSCRYPT_PROVIDER != null) { }` static block reads as a
mistake in a new file and only survives checkstyle by luck; the "DO NOT EDIT" it carries no longer
protects anything cryptic.
Correct — it was copied verbatim from `JettySslContextFactory`. Replaced with
`JcaProviders.ensureConscryptRegistered()`, a named no-op whose javadoc states what the call is for
(installing Conscrypt process-wide before Jetty resolves a JSSE provider by name), and dropped the
"DO NOT EDIT".
Assisted-by: Claude Opus 5 (Claude Code)
…module-visible Review comment: the `META-INF/services` entry registers this fixture for the whole `pulsar-common` test source set; could it be scoped, or installed via `Security.addProvider` in a `@BeforeClass`? The visibility is exactly as described, but both alternatives defeat the fixture's purpose. Being discoverable through `ServiceLoader` IS the property under test: the assertion is that when both resolution paths can answer a name, the operator-registered instance wins over the one `ServiceLoader` builds through the no-arg constructor. Registering it programmatically removes the condition under test, and a dedicated source set is a lot of Gradle for an inert fixture. The blast radius is bounded because the provider registers no services at all — it can never satisfy a `getInstance()` call, and is visible only to code enumerating `ServiceLoader<Provider>`. Recorded that reasoning on the fixture so it does not have to be rediscovered. No behaviour change. Assisted-by: Claude Opus 5 (Claude Code)
… policies Review comment: `tlsHostnameVerificationEnabled` is the broker's outbound client setting, but `serverPolicy()` applies it to the `BROKER`/`PROXY`/`WEB` policies — all server-role purposes. Correct. The setting is documented on `ServiceConfiguration` as "whether the hostname is validated when the broker creates a TLS connection with other brokers", and only the client context builders read the flag — `buildNettyServerContext` ignores it. It is inert today, but it leaves a server policy carrying a client-only flag, and a future consumer reading it off the policy would enable endpoint identification on a server engine, i.e. verify the CLIENT's hostname. The suggestion was to leave it unset. `TlsPolicy` has no tri-state for it, and the builder defaults it to true (secure-by-default for clients) — so omitting the setter would have left every server policy claiming verification is on, which is worse than the reported problem. Pinned to false explicitly instead, which is the inert value for a server engine, with the reasoning recorded. `brokerClientPolicy()` keeps mapping the setting, which is where it belongs. No behaviour change: nothing reads the flag for a server purpose. Assisted-by: Claude Opus 5 (Claude Code)
Review comment: the `WEB` purpose is composed from `serverPolicy(conf)`, which reads `tlsProvider`, `tlsProtocols` and `tlsCiphers` — but the web listener has its own keys, and `webServiceTlsProvider` defaults to a non-blank `Conscrypt`. Wired as-is, the web listener would take the binary listener's provider and lists. `WEB` now has its own policy: `webServiceTlsProvider`, `webServiceTlsProtocols` and `webServiceTlsCiphers` take precedence, falling back to `tlsProvider` / `tlsProtocols` / `tlsCiphers` when unset. Material (PEM or keystore) and the insecure flag stay shared, as today. Two clarifications on the reported consequences, which do not change the fix but do change what it is fixing. Both describe master rather than a regression this series would introduce: - `WebService.java:178-180` already passes `config.getTlsCiphers(), config.getTlsProtocols()` to `JettySslContextFactory` — the broker web listener has never read `webServiceTlsProtocols` or `webServiceTlsCiphers`. Honouring them here is new behaviour, and arguably the fix for a long-standing gap rather than the avoidance of a new one. - `webServiceTlsProvider` *is* read today (same call site) but only reaches Jetty's `setProvider()`; the `SSLContext` Jetty serves with comes from `DefaultPulsarSslFactory` via `buildSslConfiguration`, which passes no provider, and the PIP-337 `getSslContext()` override shadows whatever Jetty would have built. So the Conscrypt default has been inert on the broker web path since 4.0. Honouring it now means a deployment on the default will genuinely get Conscrypt for the web listener; Conscrypt ships in the server distribution, and where its native library cannot load the pin fails loudly at startup rather than being silently ignored. `pip/pip-478.md` claimed no code reads `webServiceTlsProvider`; that was true of the proxy but not the broker. Corrected, and extended to state the new precedence. Tests cover the precedence, the fallback, that the binary-listener policy never reads the web keys, that an engine literal in the web provider selects no JSSE provider, and that server-role policies do not carry the outbound hostname-verification flag. Assisted-by: Claude Opus 5 (Claude Code)
…te PIP-337 parity Review comment: `requireTrustedClientCert` is factory-wide and so applies identically to `BROKER`, `PROXY` and `WEB` — which matches PIP-337, but since it is one client-auth knob for three listeners with historically separate wiring, it is worth stating that this is intentional parity rather than a simplification. Agreed. The javadoc said the flag applies to every server purpose but not why. It now says that v4 keyed all three listeners off the same `tlsRequireTrustedClientCertOnConnect`, `JettySslContextFactory` included, so per-listener client-auth was never configurable. Also spelled the property name in full, which was abbreviated. Documentation only. Assisted-by: Claude Opus 5 (Claude Code)
…dle's guard Review comment: `SubscriptionHandle` uses a plain volatile check-then-set while `OneShotHandle` deliberately uses an `AtomicBoolean` with a comment calling that exact pattern a bug. It happens to be safe here, but only because `removeSubscription` is guarded by `subscriptions.remove()` returning true — the flag is not doing the work. That reading is right, and the asymmetry is the problem: two handles a few lines apart with opposite patterns, one carrying a comment that condemns the other, tells the next reader that one of them is wrong. Switched to `AtomicBoolean.compareAndSet` and recorded that `remove()` is the actual linearization point, so the guarantee does not depend on which guard you happen to read. No behaviour change — disposal was already idempotent. Assisted-by: Claude Opus 5 (Claude Code)
…) in the insecure-mode warn set Review comment: `INSECURE_WARNED` is a static set that is never cleared and holds `TlsPolicy` objects, which carry `keyStorePassword` / `trustStorePassword` — so it pins plaintext passwords for the JVM lifetime, once per distinct insecure policy the process ever builds. Correct, and the password retention matters more than the unboundedness: only policies with `allowInsecureConnection=true` enter, so a broker with a fixed policy set adds one or two entries, but a long-lived process constructing many client policies (proxy, function workers, test suites) accumulates both. Now keyed by `policy.hashCode()` rather than the policy, so nothing is retained beyond an int, and capped. Past the cap it warns every time rather than silently going quiet — losing the signal that peer verification is disabled would be a worse failure than a repeated warning. A hash collision costs at most one suppressed warning. Assisted-by: Claude Opus 5 (Claude Code)
…odification time alone Review comment: change detection is mtime-only, so on a filesystem with 1-second mtime granularity a certificate replaced twice within the same second — or restored with a preserved mtime — is missed until the next real change. Not a regression (v4's `FileModifiedTimeUpdater` had the same weakness), but cheap to improve while the code is being written. Taken, and extended slightly. `Files.readAttributes(BasicFileAttributes.class)` returns modification time, size and `fileKey` in the same single stat the poll was already doing, so the snapshot now carries all three: - size catches a replacement of different length; - `fileKey` (the inode, where the filesystem supplies one) catches the write-to-temp-and-rename pattern, which is how most certificate tooling rotates and which same-length rotations would otherwise defeat — size alone would miss a renewed certificate with the same key size. A null `fileKey` (filesystems that supply none) is part of the snapshot value, so those deployments degrade to mtime-plus-size rather than failing. Two keep-last-good tests had to be updated, and the reason is the point of the change: both faked "nothing changed" by restoring only the mtime of a file whose contents they had replaced. The new snapshot sees through that, which is exactly the weakness reported. They now restore the content as well, rewriting the same path so the inode is preserved. Assisted-by: Claude Opus 5 (Claude Code)
…not the framework scheduler Review comment: `pollSafely` runs on `context.scheduler()`, and `RegisteredSource.poll` synchronously stats files, parses PEM/keystores and rebuilds contexts — all under the source monitor. That is exactly the work `initialize()` refuses to accept a null `blockingExecutor` for, on exactly the stated grounds. With several purposes and material on slow or NFS-backed storage it occupies a shared framework scheduler thread for the duration. Correct, and the inconsistency was with our own rationale a few lines up. The scheduler now only triggers the poll; the work runs on the blocking executor. Dispatching gives up `scheduleWithFixedDelay`'s guarantee that one run completes before the next is scheduled, so an in-flight flag stands in for it: a poll slower than the interval skips ticks rather than queueing them behind itself. A rejected dispatch (executor shutting down, queue full) clears the flag so a later tick retries rather than wedging the poll permanently. Assisted-by: Claude Opus 5 (Claude Code)
…of deadlocking Review comment: the javadoc is explicit that consumer callbacks run under the source monitor and that a callback must not `createInstance` for the same purpose and block — but the constraint is documented and unenforceable, and the consumers are written in later PRs by people who will not necessarily read this class. Fair, and the "make it loud rather than a hang" half of the suggestion is the one taken. An acquisition that re-enters for the same purpose now fails the returned future with an `IllegalStateException` naming the problem and pointing at the fix, instead of blocking forever. Failing the future (rather than throwing) keeps the SPI's never-throw-synchronously rule. Only same-purpose re-entry is rejected, because that is the case that cannot succeed; a callback acquiring a different purpose takes a different monitor and is merely inadvisable. On the other half — snapshotting and invoking callbacks outside the lock — the monitor is doing a second job beyond rebuild-and-publish atomicity: it makes deliveries serial per subscription, which the framework's Jetty coordinator relies on for its generation ordering, and `loadInitialInstance` fans out under the same lock when an acquisition observes a rotation first. Moving callbacks out needs per-subscription serialization machinery, which is not something to introduce in the PR that has no consumers yet. The related Jetty finding is addressed separately by taking the reload off the delivery thread, which removes the framework's own violation of the contract. Assisted-by: Claude Opus 5 (Claude Code)
…d close the start race Addresses three related review comments on `JettyTlsFactory`. **The coordinator collapsed to a synchronous reload under the source monitor.** The design composes the companion request and never joins it, but for the DEFAULT factory that future is already complete: `FileBasedTlsFactory.isSupported()` covers only `SslContext` and `SSLContext`, so `createInstance(purpose, SSLParameters.class)` returns `completedFuture(Optional.empty())` and the `whenComplete` runs INLINE — on a delivery thread that is inside the factory's `synchronized` `deliverToSubscribers`. The whole Jetty reload therefore ran with the source monitor held. The javadoc's "never joined on the delivery thread" was literally true and beside the point: it established a source -> coordinator -> Jetty lock chain, and made the framework's own first consumer violate the "cheap non-blocking store" callback contract that `deliverToSubscribers` states. Both builders now take the reload `Executor` and `onDelivery` uses `whenCompleteAsync`, so `publish` never runs on the delivery thread. No callers exist yet, so the signature change is free. The first delivery — which fires synchronously during subscribe and only sets the context — stays inline. (One qualification: only a delivery, i.e. changed material or a pending redeliver, took that path; a steady-state poll with no rotation did not.) **TOCTOU between `isStarted()` and the apply.** A connector starting in that window took its `load()`-time snapshot before the direct setters landed, so the rotated context was silently not served: no exception, the delivery counted a successful reload, and the listener stayed on pre-rotation material until the NEXT material change — which at a 90-day renewal cadence can outlive the certificate. `publish` now always goes through `target.reload(...)`, which takes the same internal lock as Jetty's `doStart()`/`load()`, so the check and the apply stop being separable. The existing pre-start rotation test confirms `reload()` on a not-yet-started factory is benign with the pinned Jetty version. **`join()` was safe only by convention.** The default factory dispatches acquisitions to the blocking executor, so calling these synchronous builders FROM that executor waits on work queued behind itself — plausible for the websocket proxy and the functions worker, which are exactly the components `TlsFactorySupport.initContext` has a dedicated overload for. The constraint is now stated on both public builders. The joins are also centralized so `CompletionException` is unwrapped: a misconfigured purpose surfaces as "No TLS material configured for server purpose WEB" rather than wrapped in a `CompletionException`. Tests: a new test asserts the reload runs on the supplied executor and not on the delivery thread; existing tests pass a same-thread executor so their synchronous assertions stay deterministic. Assisted-by: Claude Opus 5 (Claude Code)
…ightenings Review comment: `TlsMaterialSource` now fails the load for an empty keystore truststore, a keystore with no usable key entry, and a PEM certificate without its private key. These are startup failures on upgrade, and operators should not discover them from a stack trace — the release notes should call all three out explicitly. Agreed, and the enumeration was exactly right. Added to the upgrade section of the PIP so the text exists before the migration PR needs it, including why the empty-truststore case is the security-relevant one (an empty trust list was indistinguishable downstream from "no truststore configured", so the deployment trusted every public CA while believing it had pinned a private one) and that the PEM trust axis is deliberately left untightened for 4.x parity. Documentation only. Assisted-by: Claude Opus 5 (Claude Code)
…identity Review comment: rotation suppression rests on `TlsMaterial`'s generated `equals`, which delegates to `PrivateKey.equals`. That is encoding equality for SunRsaSign and for BC/BCFIPS so it works, but it is an implicit contract on a provider-supplied object — and the whole point of the `jcaProvider` axis is that the provider is swappable. A provider whose keys used identity semantics would make every refresh look changed. Fair, and worth pinning with a test. Two bounds are worth recording alongside it: for the file-backed source the file-stamp baseline short-circuits before equality is consulted, so identity-equals keys would only cause churn on a touch-without-content change; and the failure mode is rebuild churn plus a misleading `pulsar.tls.reload` count, not incorrect TLS. The test asserts against `BC` rather than `BCFIPS`, because the two cannot share a classpath (mismatched jar signers) — which is what the separate bcfips test module exists for. They share the encoding-equality implementation, so the property under test is the same. Assisted-by: Claude Opus 5 (Claude Code)
|
Thanks — this is a careful review, and it found two things that two prior multi-model review passes on this branch missed: the coordinator's Every comment has an inline reply. 17 of the 19 are addressed in 15 commits, one per comment where that made sense. Behaviour changes
Hygiene, docs and tests
Not code changes here, deliberately
One I am pushing back on
Two points on the Verification. 1270 tests, 0 failures across One ask: the review body mentions a summary and "the one issue I'd call major" in a conversation comment, but I don't see one on the PR — could you re-post it? I don't want to answer around it. |
david-streamlio
left a comment
There was a problem hiding this comment.
Thanks for the thorough turnaround — I re-read the delta (15 files, +550/-86) against the prior base and most of it lands cleanly. Verified as correct: the poll dispatch with the pollInFlight flag, the FileStamp record (mtime + size + fileKey() from one stat, which catches the write-to-temp-and-rename rotation that cert-manager and friends use), the AtomicBoolean symmetry on SubscriptionHandle.disposed, bounding INSECURE_WARNED by hashCode so TlsPolicy objects (and their plaintext passwords) aren't pinned for the JVM lifetime, webPolicy() restoring WEB precedence for webServiceTls*, enableHostnameVerification(false) pinned on the server roles, and the unconditional target.reload() — I decompiled Jetty 12.1.10 and confirmed reload() and doStart() do share the same AutoLock, so the TOCTOU fix is sound.
Two of the fixes don't do what they say, though, and in both cases the accompanying test cannot fail. I mutation-tested both rather than argue from reading:
- (major) the re-entrancy guard is evaluated only after the hop to
blockingExecutor, so it's a no-op under any real executor — the original deadlock is unchanged. Running the shipped test verbatim against a real single-threaded executor givesTimeoutException, not theIllegalStateExceptionit asserts. - (moderate) reverting the
whenCompleteAsync(..., reloadExecutor)fix back to inlinewhenCompleteleavesJettyTlsFactoryTestat 17/17 green,rotationReloadRunsOnTheSuppliedExecutorNotTheDeliveryThreadincluded.
Plus three minor items. Details, evidence and suggested fixes inline.
…ead, before dispatch The guard added in the previous round did not work. It was called inside the `runAsync` lambda, so it only ever ran after the hop to `blockingExecutor` — and `Thread.holdsLock` is per-thread: - on a single-threaded blocking executor the nested task queues behind the very callback waiting for it, so the check never runs at all and the callback blocks forever; - on a multi-threaded one the task starts on a different thread, sees `holdsLock == false`, passes the guard, and blocks on `synchronized (source)` one frame later. Same deadlock. It fired in exactly one configuration — a direct executor — and that is the configuration in which the deadlock cannot happen at all, because monitors are reentrant and the nested acquisition simply succeeds inline. So it converted the one working case into a failure while leaving both real deadlocks untouched. The shipped test passed only because the test helper initializes with `Runnable::run`. The check now happens on the caller's thread, before dispatch, in both acquisition paths. `resolve(purpose)` moves with it and is wrapped: it can throw for a null purpose or an unconfigured server purpose, and the SPI forbids throwing on the calling thread, so that failure becomes a failed future too. `rejectReentrantAcquisition` becomes the `reentrantAcquisition(purpose)` exception factory the callers use to build that future. The javadoc gains two things it was missing: that the detection must happen pre-dispatch and why, and that a callback acquiring a *different* purpose avoids this deadlock but can still starve on a single-threaded executor — the previous wording called that "merely inadvisable", which understated it. It also now says that a non-blocking same-purpose acquisition from a callback is rejected as well, since the two cannot be distinguished at the point of the call. The test now uses a real single-threaded blocking executor — the configuration that actually deadlocks — with bounded waits so a regression fails instead of hanging the suite, and covers a rotation delivery as well as the initial one. Verified by mutation: with the pre-dispatch guard removed the test fails; previously the equivalent test passed against the broken code. Assisted-by: Claude Opus 5 (Claude Code)
The test guarding the source -> coordinator -> Jetty lock-chain fix could not fail against the implementation it protects. Both assertions were tautologies: - it submitted a NEW task to the reload executor after the reload had already completed and asserted that task's thread name, which is the executor's own naming and says nothing about where `publish` ran; - it asserted the delivery thread was no longer alive, which `join(10s)` had already guaranteed and which holds under inline execution too. Reverting `whenCompleteAsync(..., reloadExecutor)` to inline `whenComplete` left the whole class green. Replaced with a test that discriminates dispatch from inline. A holding executor parks submitted tasks; the companion resolves synchronously (no `deferCompanions()`), which is precisely the default factory's behaviour, since `SSLParameters` is an unsupported class and `createInstance` returns an already-completed future — the case where an inline `whenComplete` would run the whole Jetty reload on the delivery thread. After `deliver(...)` returns, the assertion is that the rotation has NOT been applied and exactly one task is parked; releasing it then applies the rotation. Gating the companion future instead would not have worked: registering an inline `whenComplete` on an incomplete future also returns promptly, so the delivery thread comes back either way and the assertions would pass under the mutation. Verified by mutation: with `whenCompleteAsync` reverted to `whenComplete` this test fails. Assisted-by: Claude Opus 5 (Claude Code)
…ck to inline execution `reloadExecutor == null ? Runnable::run : reloadExecutor` meant a null argument produced exactly the inline behaviour the parameter had just been introduced to remove — the Jetty reload back on the delivery thread, under the source monitor. Now required. Validated at both public builder entry points rather than in the coordinator: the native-factory path never constructs a coordinator, so a null would have sailed through and only surfaced later if the custom factory stopped supplying its native instance. Validating at the entry also fails before any factory acquisition side effects. A caller that genuinely wants same-thread execution passes `Runnable::run`, which states the intent and is greppable; a silent null is neither. There are no production call sites yet — the two declarations are the only non-test references on this branch — so requiring it now costs nothing and avoids shipping inline as the effective default. Note this makes the argument mandatory, not asynchronous: `Runnable::run` is non-null and still inline. What protects the actual invariant is the dispatch test, which now fails if the reload runs inline. Assisted-by: Claude Opus 5 (Claude Code)
…tty builders `reloadExecutor` was added to both public builders without a matching `@param`, which is exactly where a caller looks to answer "what do I pass, and can it be null?". Both now document it, including that it must not be null and must not execute inline on the delivery thread, and that `Runnable::run` is the explicit opt-in for same-thread execution. Assisted-by: Claude Opus 5 (Claude Code)
…to its test A rebase artifact left this Javadoc block sitting above the reload-executor test, which has its own Javadoc immediately below it; the block describes `preStartRotationRefreshesCompanionBeforeStart`, which had none. Moved onto the test it documents. Assisted-by: Claude Opus 5 (Claude Code)
|
Thanks — this one landed harder than the last. Both mutation results reproduce, and you're right about the cause in each case: the re-entrancy guard fired only under a direct executor, which is the one configuration where the deadlock cannot occur, and the reload-executor test asserted the executor's own thread naming rather than where All six are addressed in 5 commits. The two substantive ones:
Plus 82019a6 (executor required, validated at both entry points so the native-factory path can't bypass it), c7a8542 ( Every changed test in this round was mutation-tested against the unfixed code before I claimed it works — that was the gap in my previous round, and it's a fair correction to have had made twice. 1269 tests, 0 failures locally, plus the full lint/license gate. I've resolved the threads from the first review; these six are the open ones. |
…e broker-client policy tlsHostnameVerificationEnabled is the broker's outbound setting, so it is mapped onto the client-role BROKER_CLIENT policy and pinned off on the server-role ones (BROKER / PROXY / WEB), where the flag would mean verifying the connecting client's hostname instead. Only the negative half of that was asserted, which reads as if the configuration were being ignored. Assert the positive half too — in both positions — by making brokerClientPolicy package-private for the test, and name the honoring site in the comment at the pin. Both directions are mutation-verified: mapping the flag onto serverPolicy, or dropping it from brokerClientPolicy, fails the test. Assisted-by: Claude Code (Opus 5)
david-streamlio
left a comment
There was a problem hiding this comment.
All six items from the last round are addressed, and the two substantive ones are now backed by tests that can actually fail. I re-ran the mutations myself at 4487e52118 rather than take the commit messages for it:
Pre-dispatch guard (323e0cca8c) — removing the Thread.holdsLock(source) check from both acquisition entry points:
FileBasedTlsFactoryTest > reentrantAcquisitionFromAReloadCallbackFailsLoudlyInsteadOfDeadlocking FAILED
java.util.concurrent.TimeoutException
at FileBasedTlsFactoryTest.java:751
Worth noting the bounded wait does what you said it would: the regression surfaces as a failure in 46s, not as a hung suite.
Reload dispatch (afa92685af) — reverting whenCompleteAsync to inline whenComplete:
JettyTlsFactoryTest > rotationReloadRunsOnTheSuppliedExecutorNotTheDeliveryThread FAILED
expected ... to refer to the same object
at JettyTlsFactoryTest.java:646
That's the "must be dispatched, not run inline on the delivery thread" assertion, i.e. the one that matters. Clean run at HEAD: BUILD SUCCESSFUL, both classes green.
Corrections to my review, which you were right about
Four things in my comments were wrong, and three of them would have made the code worse if you'd applied them verbatim:
-
resolve(purpose)is not "just a registry lookup." I said moving it to the caller thread was free. It isn't —FileBasedTlsFactory:411-422doesObjects.requireNonNulland throwsTlsMaterialUnavailableExceptionfor an unconfigured server purpose, and the SPI javadoc's "Never throws synchronously" clause covers argument validation explicitly. My snippet would have violated the SPI contract on the calling thread. Your try/catch is the right shape. -
Gating the companion future doesn't discriminate. You're right and I should have caught this: registering an inline
whenCompleteon an incomplete future also returns promptly, so the delivery thread comes back under both variants and my proposed assertions would have passed under my own mutation. Gating thereloadExecutorwith the companion resolving synchronously is the correct discriminator — and it's the real default-factory path, sinceSSLParametersis unsupported. -
The line-657 javadoc is on
RegisteredSource.deliverToSubscribers, notSubscriptionHandle. Confirmed. -
"Every production caller would pass null" — wrong, and my own comment contradicted it a paragraph later. There are no production callers at all, which is exactly what makes tightening it free now.
Your sharpening on (1) is the part I'd want in the commit log, and it's there: under a direct executor the deadlock is unreachable anyway because monitors are reentrant, so the old guard fired only where nothing could hang and turned a working case into a failure. That's a better characterization than my three-way split.
Also agree on the requireNonNull placement — the native-factory path returns at line 175-178 before the coordinator is constructed, so my suggested placement would have let a null through there. And the point that non-null ≠ asynchronous is the right one: it's the dispatch test, not the null check, that holds the invariant.
Two non-blocking notes
-
The Conscrypt follow-up issue doesn't appear to be filed yet. You said it would be opened and linked from PIP-478; I don't see a link in
pip/pip-478.mdat HEAD. Not a blocker for this PR — the substance is recorded in the review thread — but it's the one commitment here that lives outside the code, and it gates a different PR (the one turning SAN-only verification on by default). Worth filing before this merges so the link exists. -
@nodece's suggestion on
DefaultBrokerTlsFactory:168looks already handled by design rather than missed: that's the server-role policy, andbrokerClientPolicy()does mapconf.isTlsHostnameVerificationEnabled()at line 196.tlsHostnameVerificationEnabledis documented as the broker's outbound setting, so applying it to a server policy would mean verifying the client's hostname. The comment at 158-166 explains this, but since it reads as unaddressed, an explicit reply pointing at line 196 would probably close it out.
CI is red on infrastructure, not code — Preconditions died on Failed to resolve action download info. Error: Service Unavailable and everything downstream was skipped. Needs a re-run.
Approving. Thorough responses throughout this round — particularly catching that my own remediation for the tautological test was itself untestable.
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)
… + 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)
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)
PIP: #25890 (
pip/pip-478.md)Motivation
This is the second implementation PR for PIP-478, following #26222 which landed the two SPI modules (
pulsar-tls-factory-api,pulsar-http-client-api). It adds the default implementation of thePulsarTlsFactorySPI and the JDK-level TLS primitives it is built from.The PR is additive: the PIP-337 stack (
PulsarSslFactory,DefaultPulsarSslFactory,JettySslContextFactory,SecurityUtility,KeyStoreSSLContext) still exists and remains the wired implementation, and the new code is exercised only by its own unit tests. There is no behavioural change to any existing module. Wiring the new factory into the client and the server components, and removing the superseded PIP-337 classes, are later PRs in the series.Modifications
pulsar-common—org.apache.pulsar.common.tls.impl, the default factory:FileBasedTlsFactory+FileBasedTlsFactorySettings: the defaultPulsarTlsFactory, built from PEM or keystore file material. It owns the purpose→source registry, purpose resolution (direct map lookup, then the role's terminal rule — client purposes resolve to the system default, server purposes fail the request), and the reload fan-out to subscribers.TlsMaterialSource/MaterialSource/AuthProvidedMaterialSource/TlsMaterial: per-purpose material load, watch and cache. Value equality on the loaded material suppresses spurious rebuilds when files are touched but unchanged, a failed reload keeps the last good material, and contexts are rebuilt, never mutated in place, so a native OpenSSL engine never observes a torn context.TlsContexts/TlsContextAcquisition/SynthesizedEngineSslContext/TlsSynthesisSpec: SSL context assembly, per-acquisition read of the current context, andSSLParameterscompanion synthesis.TlsKeyStoreLoader,TlsFactoryProbe,TlsReloadMetrics,package-info.pulsar-common—org.apache.pulsar.common.util.tls, the JDK-level primitives:PemReader(PEM parsing),JdkSslContexts(JDKSSLContextassembly),JcaProviders(provider resolution),JcaKeyStores(KeyStorecreation through a pinned provider). Extracted from theSecurityUtilitygrab-bag.TlsPolicy:jsseProviderpins theSSLContext/KeyManagerFactory/TrustManagerFactory, andjcaProviderpins theKeyStore/CertificateFactory/KeyFactoryengines that parse the material. Both are unset by default, which is exactly today's behaviour.KeyStoreHoldergains an optional pinned JCA provider and, on that path only, a random per-instance entry password (a FIPS provider in approved-only mode rejects an empty password-based-KDF password). With no pinned provider its behaviour is unchanged.pulsar-broker-common:broker/tls/DefaultBrokerTlsFactory+TlsFactorySupport: the default broker-side factory, composed from the existing basetls*fields ofServiceConfiguration. No new configuration keys are added in this PR — those arrive with the migration PR.jetty/tls/JettyTlsFactory: the framework-side Jetty reload driver built on the new factory.Engine selection. An unset
tlsProviderselects Netty'sOPENSSL_REFCNTwhen anetty-tcnativebinary is available for the platform, else the JDK engine. This preserves the historical default — the PIP-337 path passes a null provider andSslContextBuilderthen picks the native engine — while keeping the new factory off finalization:OpenSslContextfrees its nativeSSL_CTXfrom afinalize(), which is deprecated for removal (JEP 421) and can already be disabled with--finalization=disabled. Both variants expose real reference counting; this factory owns and deterministically releases every context it builds, so the finalizer only ever masked a bug. An explicitly configured engine literal is honoured verbatim.tlsProviderandwebServiceTlsProviderare now documented as the distinct axes they are — a Netty engine versus a Jetty JSSE (SSLContext) provider — inServiceConfiguration,ProxyConfiguration,broker.conf,standalone.confandproxy.conf. No default values change.Relationship to FIPS and to PIP-489
The main focus of this PR is the default TLS factory above. It does, however, lay the groundwork for a FIPS-compliant TLS transport, which is the subject of PIP-489 (discussion).
PIP-489's own Goals section states that it delivers a FIPS-capable TLS transport by building on PIP-478 rather than defining a parallel TLS configuration path. Because PIP-478 replaces the PIP-337
PulsarSslFactory/PulsarSslConfigurationsurface outright in 5.0, parts of the PIP-489 design that are written against that surface — the per-listener.tlsProvider(...)wiring gaps inPulsarChannelInitializer,WebService, the WebSocket proxy and the function worker — become obsolete onmaster: those call sites do not survive the migration, and the equivalent capability is expressed throughTlsPolicyand the factory instead. The remaining PIP-489 scope — the BC/BC-FIPS packaging story, the non-approved algorithms in the crypto and authentication paths, FIPS documentation, and the distribution variant — is unaffected by this PR.The FIPS-relevant pieces here are:
KeyManagerFactory.X.509with aPKIXalias but not the JDK-defaultSunX509), and pinned in-memory carrier keystores so parsed key material stays inside the pinned module.bctlsis added to the version catalog, andJcaProvidersregistersBCJSSEon demand when it is the pinnedjsseProvider: an operator-registered instance always wins, otherwise it is constructed from the classpath — in FIPS mode bound to the FIPS JCA provider when that is the BouncyCastle artifact present, and in the default mode otherwise.bctlsships noMETA-INF/services/java.security.Providerentry and its FIPS mode is a constructor argument while it registers under the plain nameBCJSSEeither way, so neitherServiceLoaderdiscovery nor default construction can produce the right provider — hence the explicit registration.Optional, so a deployment without it on the classpath still resolves named providers.Deliberately not in this PR:
bctlsis a test dependency only, matching howbcprovis already scoped inpulsar-common(loaded reflectively in main). Which BouncyCastle artifacts land on the Pulsar classpath — selecting the non-FIPS or the FIPS jars via an environment variable in thebin/pulsarscript, since the two cannot coexist on one classpath — will come in a later PR, together with the distribution andLICENSEwiring that implies.Where the rest of PIP-478 goes
Subsequent implementation PRs in this series will cover the migration itself, and with it the TLS-transport essence of PIP-489:
tlsFactoryClassName,jsseProvider/jcaProviderand their broker-client counterparts) and makes the new SPI the TLS path in use — which is what turns the engine and provider selection above into something an operator can actually configure end to end.PulsarSslFactory,SecurityUtility,KeyStoreSSLContextand the deprecated CN-based hostname matching, leaving the new SPI as the only TLS path.Verifying this change
This change added tests and can be verified as follows:
FileBasedTlsFactoryTest,TlsMaterialSourceTest,AuthProvidedMaterialFoldTest— context building, purpose resolution, rotation with keep-last-good, and rebuild-not-mutate semantics, exercised with real in-memory TLS handshakes (no broker, no sockets), including the OpenSSL engine path and a reference-count-reaches-zero assertion (there is no finalizer to fall back on withOPENSSL_REFCNT).SslParametersSynthesisTest,TlsReloadMetricsTest—SSLParameterscompanion synthesis and thepulsar.tls.reloadmetric.JcaProviderPinningTest,JcaProvidersTest,JdkSslContextsTest— the two provider axes: name resolution and ordering, key/trust-manager algorithm negotiation, material loading through a pinned JCA provider, that the Netty builders route their carrier keystore and manager factories through the pins rather than letting Netty build them via the JVM search order, and BCJSSE on-demand registration (including that it suppliesSSLContext.TLSandTrustManagerFactory.PKIXbut notSunX509).PemReaderTest— PEM preamble parsing across leading-line parities.TlsFactorySupportTest,JettyTlsFactoryTest— the broker-side scaffolding and the Jetty reload driver../gradlew :pulsar-common:test :pulsar-broker-common:test— 1261 tests, 0 failures../gradlew 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.bouncycastle:bctls-jdk18onto the version catalog and as a test dependency ofpulsar-common. Nothing new ships in the distribution.org.apache.pulsar.common.tls.implandorg.apache.pulsar.common.util.tlspackages, and theorg.apache.pulsar.broker.tls/org.apache.pulsar.jetty.tlsclasses. All additive and unreferenced by existing code in this PR.KeyStoreHoldergains one method and one constructor overload; its existing behaviour is unchanged.Documentation
doc-requireddoc-not-needed— the new implementation is not wired to any user-facing surface yet, and the PIP-478 design document covers the design. ThetlsProvider/webServiceTlsProvidercomments in the shippedconf/*.conffiles are updated here to describe the two provider axes accurately; no website documentation change is needed.docdoc-completeMatching PR in forked repository
PR in forked repository: lhotari#248 (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.