diff --git a/pip/pip-478.md b/pip/pip-478.md index 1ecc5ea72cc64..1ec0a7e9effff 100644 --- a/pip/pip-478.md +++ b/pip/pip-478.md @@ -142,7 +142,7 @@ This PIP introduces new API and implementation across the existing `pulsar-clien - **Non-Java client SDKs.** Each non-Java SDK (Python, Go, C++, Node.js) follows its own auth model and will be addressed by per-SDK PIPs. -- **Off-loading the proxy's own broker-client credential I/O (known gap).** Motivation #1's event-loop-safety guarantee is delivered for `PulsarClient` / `PulsarAdmin` (and the broker's outbound clients, which are genuine clients that bind the framework's `ClientAuthenticationServices`). The **proxy's** connection to the broker is not a `PulsarClientImpl`: its lookup path uses a bare `ConnectionPool` and its data path a hand-rolled Netty `DirectProxyHandler`, and neither binds the client auth services (bounded blocking executor, framework HTTP client factory). On the proxy's **lookup** path the credential no longer runs on that loop: `ClientCnx` resolves and memoizes a binary authentication driver for a configuration that no `PulsarClient` owns, and with no services bound the credential call falls back to the framework's process-wide shared blocking pool. What remains is the **data** path: `DirectProxyHandler` resolves the credential inline on the proxy's Netty loop, on connect and on the broker's refresh sentinel — **the same behavior as v4, not a regression introduced by this PIP**. Closing it requires an async rework of that inline `getAuthData()`; the lookup leg additionally still runs without the framework HTTP client factory and on a shared library-owned pool rather than a client-owned bounded executor, which is why reordering proxy startup (the broker-client `Authentication` is created and started before the proxy's event loop, DNS resolver, and TLS factory exist) is still part of the follow-up. Both are deferred rather than bundled into this change. +- **Binding the proxy's broker-client legs to client-owned services (known gap).** Motivation #1's event-loop-safety guarantee is delivered for `PulsarClient` / `PulsarAdmin` (and the broker's outbound clients, which are genuine clients that bind the framework's `ClientAuthenticationServices`). The **proxy's** connection to the broker is not a `PulsarClientImpl`: its lookup path uses a bare `ConnectionPool` and its data path a hand-rolled Netty `DirectProxyHandler`, and neither binds the client auth services (bounded blocking executor, framework HTTP client factory). On the proxy's **lookup** path the credential no longer runs on that loop: `ClientCnx` resolves and memoizes a binary authentication driver for a configuration that no `PulsarClient` owns, and with no services bound the credential call falls back to the framework's process-wide shared blocking pool. The **data** path is off-loaded too: `DirectProxyHandler` opens a per-connection exchange on a `ProxyService`-owned `V5BinaryAuthenticationDriver` and resolves the credential through it — on connect and on the broker's refresh sentinel alike — dispatching the continuation back to the channel's event loop, with challenge rounds strictly serialized (a non-refresh challenge arriving while a round is in flight is dropped) and bounded by the same round cap `ClientCnx` uses. What remains deferred: neither proxy leg binds the framework HTTP client factory, both run credential work on the shared library-owned pool rather than a client-owned bounded executor, and reordering proxy startup (the broker-client `Authentication` is created and started before the proxy's event loop, DNS resolver, and TLS factory exist) is still follow-up work. - **The broader FIPS-compliance mode.** This PIP covers the **TLS-transport** requirements for FIPS: a configurable TLS engine (JDK, not native BoringSSL) wired through every component plus the two configurable provider axes (Motivation #4, Goal #5). It does **not** define a full FIPS-mode profile: FIPS-approved algorithms in message encryption (key-wrap) and authentication (password hashing, token signing), a FIPS distribution/packaging variant (shipping `bc-fips` and excluding non-validated `bcprov` / `netty-tcnative-boringssl` / Conscrypt), and a fail-fast `fipsMode` validation switch are a **separate effort** — Pulsar-wide in scope and independent of this SPI. Concretely, the shipped `pulsar-server` distribution today bundles the **non-FIPS** BouncyCastle provider (`bcprov-jdk18on` / `bcpkix-jdk18on`) and explicitly excludes `bc-fips`, and it ships no `bctls-fips` (the jar registering `BCJSSE`) at all; because the two BouncyCastle families declare the same `org.bouncycastle.*` classes under different signers they cannot coexist on one classpath, so until that packaging effort lands a FIPS deployment assembles the provider classpath itself. The in-tree `pulsar-client-test-bcfips` module assembles a classpath that way — excluding the non-FIPS BouncyCastle jars in favour of `bc-fips` — but it covers the crypto side only: it ships no `bctls-fips` and sets neither provider key, so it is not an end-to-end FIPS TLS test. This PIP deliberately provides only the TLS-transport configurability those deployments require, so the two efforts compose without one blocking the other. diff --git a/pulsar-proxy/src/main/java/org/apache/pulsar/proxy/server/DirectProxyHandler.java b/pulsar-proxy/src/main/java/org/apache/pulsar/proxy/server/DirectProxyHandler.java index 19e37cba3a748..ce5d50c7e1e9a 100644 --- a/pulsar-proxy/src/main/java/org/apache/pulsar/proxy/server/DirectProxyHandler.java +++ b/pulsar-proxy/src/main/java/org/apache/pulsar/proxy/server/DirectProxyHandler.java @@ -42,14 +42,15 @@ import io.netty.util.CharsetUtil; import java.net.InetSocketAddress; import java.util.Arrays; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import lombok.CustomLog; import lombok.Getter; import lombok.SneakyThrows; import org.apache.pulsar.PulsarVersion; import org.apache.pulsar.client.api.Authentication; -import org.apache.pulsar.client.api.AuthenticationDataProvider; -import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.impl.auth.v5.BinaryAuthenticationDriver.AuthenticationExchange; import org.apache.pulsar.common.allocator.PulsarByteBufAllocator; import org.apache.pulsar.common.api.AuthData; import org.apache.pulsar.common.api.proto.BaseCommand; @@ -61,6 +62,7 @@ import org.apache.pulsar.common.protocol.PulsarDecoder; import org.apache.pulsar.common.stats.Rate; import org.apache.pulsar.common.tls.impl.TlsContextAcquisition; +import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.common.util.netty.NettyChannelUtil; @CustomLog @@ -79,8 +81,13 @@ public class DirectProxyHandler { private final String clientAuthMethod; public static final String TLS_HANDLER = "tls"; + // PIP-478: hard cap on broker challenge rounds within a single binary authentication exchange, mirroring + // ClientCnx.MAX_AUTH_CHALLENGE_ROUNDS (both in turn mirror HttpAuthenticationDriver.MAX_CHALLENGE_ROUNDS). + // A broker that answers every CommandAuthResponse with another challenge would otherwise loop against the + // proxy forever, and each round now also schedules credential work onto a blocking pool. + static final int MAX_AUTH_CHALLENGE_ROUNDS = 10; + private final Authentication authentication; - private AuthenticationDataProvider authenticationDataProvider; private final ProxyService service; private final Runnable onHandshakeCompleteAction; final boolean tlsEnabledWithBroker; @@ -259,6 +266,18 @@ public class ProxyBackendHandler extends PulsarDecoder { private final ProxyConfiguration config; private final int protocolVersion; private final FeatureFlags featureFlags; + // PIP-478: the v5 exchange this backend connection authenticates through. Replaced on a broker-pushed + // REFRESH, which starts a fresh exchange. Only ever touched on this channel's event loop. + private AuthenticationExchange authExchange; + // PIP-478: round state. AuthenticationExchange is single-round and non-thread-safe, and serializing + // its rounds is the caller's obligation; this class is its second caller after ClientCnx. While the + // frame decoder is still running (state Init) two challenge frames arriving in one read reach + // handleAuthChallenge in the same event-loop turn, before either resolution has completed, and would + // otherwise drive the same exchange concurrently. Both fields are touched only on this channel's + // event loop (channelActive, handleAuthChallenge, and the continuations dispatched there), so they + // need no synchronization. + private boolean authRoundInProgress; + private int authChallengeRounds; public ProxyBackendHandler(ProxyConfiguration config, int protocolVersion, String remoteHostName, FeatureFlags featureFlags) { @@ -275,16 +294,76 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { if (config.isHaProxyProtocolEnabled()) { writeHAProxyMessage(); } - - // Send the Connect command to broker - authenticationDataProvider = authentication.getAuthData(remoteHostName); - AuthData authData = authenticationDataProvider.authenticate(AuthData.INIT_AUTH_DATA); - ByteBuf command = Commands.newConnect( - authentication.getAuthMethodName(), authData, protocolVersion, - proxyConnection.clientVersion, null /* target broker */, - originalPrincipal, clientAuthData, clientAuthMethod, PulsarVersion.getVersion(), featureFlags); - writeAndFlush(command); isTlsOutboundChannel = ProxyConnection.isTlsChannel(inboundChannel); + + // Send the Connect command to broker. PIP-478: the credential is resolved through a v5 exchange + // rather than by calling the v4 plugin here. This method runs on the Netty event loop, and the v4 + // call it used to make is arbitrary plugin code — an OAuth2 token endpoint round trip, an Athenz + // ZTS fetch, a GSSAPI exchange with the KDC — which stalled every connection multiplexed onto + // that loop for its duration. The exchange's calls always off-load. + // + // The auth method name is read from the v4 plugin here and in handleAuthChallenge below, where + // ClientCnx instead takes it from the exchange that produced the credential. Both are correct for + // the proxy — it owns one started plugin, and the bridge's authMethodName() delegates straight to + // it — and reading it from the plugin does not depend on a round having completed. Deliberate. + authExchange = service.getProxyClientAuthenticationDriver().newAuthenticationExchange(remoteHostName); + sendWhenResolved(authExchange.getAuthDataAsync(), + authData -> Commands.newConnect( + authentication.getAuthMethodName(), authData, protocolVersion, + proxyConnection.clientVersion, null /* target broker */, + originalPrincipal, clientAuthData, clientAuthMethod, PulsarVersion.getVersion(), + featureFlags), + "connect"); + } + + /** + * Send a command built from an asynchronously-resolved credential (PIP-478). + * + *
The continuation is dispatched onto this channel's event loop, so the command is built and + * written there whether the credential was already in memory or needed I/O — command ordering on the + * channel is therefore unchanged from the synchronous version. A failure closes the backend channel: + * the proxy has no credential to send, and leaving the connection open would wait out the broker's + * timeout instead of letting the client retry. That covers a failure to build the command too, not + * just a failure to resolve the credential. + * + *
This is where an authentication round begins: the round is marked in progress so that a
+ * challenge arriving before it completes is dropped rather than re-entering the exchange
+ * concurrently.
+ *
+ * @param resolution the credential being resolved
+ * @param commandBuilder builds the command to send from the resolved credential
+ * @param what what is being authenticated, for logging
+ */
+ private void sendWhenResolved(CompletableFuture The proxy owns one started v4 plugin for all of its broker connections, so it owns one driver:
+ * every {@code DirectProxyHandler} opens its own exchange against it, and the exchange is what carries
+ * per-connection conversation state. Built from the started plugin, because {@link ProxyService}
+ * starts and closes that instance itself — the bridge must not run that lifecycle a second time.
+ *
+ * No {@code ClientAuthenticationServices} are bound: the proxy is not a {@code PulsarClient} and has
+ * no client-owned executor to lend. Credential work therefore lands on the framework's shared blocking
+ * pool, which is the case {@code V5AuthContexts} documents for exactly this caller — the alternative,
+ * running it inline, is the Netty event loop.
+ *
+ * Read on every backend connection's {@code channelActive}, so the hit path is lock-free and only a
+ * miss takes the monitor — as {@code ClientCnx.resolveAuthDriver} does, and for the same reason: this
+ * monitor is the {@link ProxyService} one, shared with the metrics-servlet accessors, and a connection
+ * being set up should not have to queue behind unrelated machinery.
+ *
+ * @return the shared binary authentication driver
+ */
+ public BinaryAuthenticationDriver getProxyClientAuthenticationDriver() {
+ BinaryAuthenticationDriver resolved = proxyClientAuthenticationDriver;
+ if (resolved != null) {
+ return resolved;
+ }
+ synchronized (this) {
+ if (proxyClientAuthenticationDriver == null) {
+ proxyClientAuthenticationDriver = new V5BinaryAuthenticationDriver(
+ V5AuthenticationLoader.forStartedV4Plugin(proxyClientAuthentication));
+ }
+ return proxyClientAuthenticationDriver;
+ }
+ }
+
public synchronized PrometheusMetricsServlet getMetricsServlet() {
return metricsServlet;
}
diff --git a/pulsar-proxy/src/test/java/org/apache/pulsar/proxy/server/DirectProxyHandlerAuthTest.java b/pulsar-proxy/src/test/java/org/apache/pulsar/proxy/server/DirectProxyHandlerAuthTest.java
new file mode 100644
index 0000000000000..e1d9b9f39fb37
--- /dev/null
+++ b/pulsar-proxy/src/test/java/org/apache/pulsar/proxy/server/DirectProxyHandlerAuthTest.java
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.proxy.server;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import org.apache.pulsar.client.api.Authentication;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.impl.auth.v5.BinaryAuthenticationDriver;
+import org.apache.pulsar.client.impl.auth.v5.V5AuthenticationLoader;
+import org.apache.pulsar.client.impl.auth.v5.V5BinaryAuthenticationDriver;
+import org.apache.pulsar.common.api.AuthData;
+import org.apache.pulsar.common.api.proto.CommandAuthChallenge;
+import org.apache.pulsar.common.api.proto.FeatureFlags;
+import org.apache.pulsar.common.protocol.Commands;
+import org.awaitility.Awaitility;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: the proxy's broker-client credential must not be resolved on the Netty event loop, and the
+ * exchange it is resolved through must have its rounds serialized and bounded.
+ *
+ * Both properties live entirely inside {@code DirectProxyHandler.ProxyBackendHandler}, so they are pinned
+ * here against an {@link EmbeddedChannel} rather than through a running proxy: an embedded event loop only
+ * runs the tasks submitted to it when {@code runPendingTasks()} is called, which makes "did this run on the
+ * event loop or off it" an assertion rather than a race. An end-to-end proxy fixture can observe neither —
+ * the handshake completes the same way whichever thread the plugin was called on.
+ */
+public class DirectProxyHandlerAuthTest {
+
+ private static final String AUTH_METHOD = "thread-recording";
+ private static final String BROKER_HOST = "broker.example:6650";
+
+ private ProxyConfiguration proxyConfig;
+ private ProxyService service;
+ private ProxyConnection proxyConnection;
+ private EmbeddedChannel inboundChannel;
+ private EmbeddedChannel backendChannel;
+ private ThreadRecordingAuthentication plugin;
+
+ /**
+ * A v4 plugin that records the thread each of its credential calls runs on, standing in for one that
+ * blocks there — an OAuth2 token endpoint round trip, an Athenz ZTS fetch, a GSSAPI exchange with the KDC.
+ * It answers challenges too, so a challenge round reaches {@code authenticate} a second time.
+ */
+ private static final class ThreadRecordingAuthentication implements Authentication {
+
+ private final List