diff --git a/build-logic/conventions/src/main/kotlin/CheckBinaryLicenseTask.kt b/build-logic/conventions/src/main/kotlin/CheckBinaryLicenseTask.kt index e52ec7bbe6562..415ad1ca8d417 100644 --- a/build-logic/conventions/src/main/kotlin/CheckBinaryLicenseTask.kt +++ b/build-logic/conventions/src/main/kotlin/CheckBinaryLicenseTask.kt @@ -65,7 +65,9 @@ abstract class CheckBinaryLicenseTask : DefaultTask() { "pulsar-client", "pulsar-cli-utils", "pulsar-common", + "pulsar-http-client-api", "pulsar-package", + "pulsar-tls-factory-api", "pulsar-websocket", ) diff --git a/pulsar-bom/build.gradle.kts b/pulsar-bom/build.gradle.kts index 841c1c258e79d..c97b29b7d60fa 100644 --- a/pulsar-bom/build.gradle.kts +++ b/pulsar-bom/build.gradle.kts @@ -37,6 +37,10 @@ dependencies { api(project(":pulsar-client-api")) api(project(":pulsar-client-admin-api")) + // Focused SPI modules (PIP-478): TLS factory SPI + HTTP client SPI + api(project(":pulsar-tls-factory-api")) + api(project(":pulsar-http-client-api")) + // Shaded clients (the published artifacts users depend on) api(project(":pulsar-client-shaded")) api(project(":pulsar-client-admin-shaded")) diff --git a/pulsar-common/build.gradle.kts b/pulsar-common/build.gradle.kts index e7b7f28d57941..55095e0efd27c 100644 --- a/pulsar-common/build.gradle.kts +++ b/pulsar-common/build.gradle.kts @@ -145,6 +145,11 @@ sourceSets["main"].resources.srcDir(generatePulsarBuildInfo.map { dependencies { implementation(libs.slog) + // PIP-478: the purpose-driven TLS factory SPI (org.apache.pulsar.tls) lives in the focused + // pulsar-tls-factory-api module; the default FileBasedTlsFactory impl (org.apache.pulsar.common.tls.impl) + // and the hostname-verification helpers (org.apache.pulsar.common.tls) live here. Exposed as `api` so + // consumers that reference the SPI through pulsar-common keep compiling unchanged. + api(project(":pulsar-tls-factory-api")) api(project(":pulsar-client-api")) api(project(":pulsar-client-admin-api")) @@ -196,6 +201,11 @@ dependencies { compileOnly(libs.swagger.annotations) compileOnly(libs.spotbugs.annotations) + // PIP-478: FileBasedTlsFactory emits the pulsar.tls.* reload instruments via the OpenTelemetry handle + // exposed on TlsFactoryInitContext. Kept compileOnly (matching pulsar-tls-factory-api) — the real + // OpenTelemetry root is always supplied at runtime by the owning component (broker/client), and a noop + // root yields no-op instruments. + compileOnly(libs.opentelemetry.api) // Non-FIPS BouncyCastle provider for tests that exercise SecurityUtility (which loads // org.bouncycastle.jce.provider.BouncyCastleProvider in a static initializer). This matches @@ -208,4 +218,10 @@ dependencies { testImplementation(libs.snappy.java) testImplementation(libs.awaitility) testImplementation(libs.jsonassert) + // PIP-478: the TLS factory tests implement TlsFactoryInitContext, whose openTelemetry() accessor + // exposes the (compileOnly) OpenTelemetry API from pulsar-tls-factory-api. + testImplementation(libs.opentelemetry.api) + // PIP-478: the TLS-reload metrics test reads pulsar.tls.* instruments via an in-memory SDK reader. + testImplementation(libs.opentelemetry.sdk) + testImplementation(libs.opentelemetry.sdk.testing) } diff --git a/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPolicyValidationTest.java b/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPolicyValidationTest.java new file mode 100644 index 0000000000000..ad38735772ba9 --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPolicyValidationTest.java @@ -0,0 +1,76 @@ +/* + * 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.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.testng.annotations.Test; + +/** + * PIP-478: {@link TlsPolicy.Builder#build()} fails loud when a configured field is inconsistent with the + * chosen {@link TlsPolicy.Format}, rather than silently ignoring it. + */ +public class TlsPolicyValidationTest { + + @Test + public void pemPolicyRejectsKeystoreFields() { + assertThatThrownBy(() -> TlsPolicy.builder() + .format(TlsPolicy.Format.PEM) + .trustCertsFilePath("/ca.pem") + .keyStorePath("/key.p12") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("keyStorePath"); + + assertThatThrownBy(() -> TlsPolicy.builder() + .format(TlsPolicy.Format.PEM) + .keyStoreType("PKCS12") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("keyStoreType"); + } + + @Test + public void keystorePolicyRejectsPemFields() { + assertThatThrownBy(() -> TlsPolicy.builder() + .format(TlsPolicy.Format.KEYSTORE) + .keyStorePath("/key.p12") + .certificateFilePath("/cert.pem") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("certificateFilePath"); + } + + @Test + public void consistentPoliciesBuild() { + assertThatCode(() -> TlsPolicy.pem("/ca.pem", "/cert.pem", "/key.pem")) + .doesNotThrowAnyException(); + assertThatCode(() -> TlsPolicy.keyStore("/trust.jks", "pw", "/key.jks", "pw", "JKS")) + .doesNotThrowAnyException(); + // Mixed store TYPES are consistent with KEYSTORE format (only cross-format fields are rejected). + assertThatCode(() -> TlsPolicy.builder().format(TlsPolicy.Format.KEYSTORE) + .keyStorePath("/key.p12").keyStoreType("PKCS12") + .trustStorePath("/trust.jks").trustStoreType("JKS") + .build()).doesNotThrowAnyException(); + // Flag-only policies (system default / insecure) build cleanly. + assertThat(TlsPolicy.builder().build().format()).isEqualTo(TlsPolicy.Format.PEM); + assertThat(TlsPolicy.insecure().allowInsecureConnection()).isTrue(); + } +} diff --git a/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPurposeTest.java b/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPurposeTest.java new file mode 100644 index 0000000000000..9ce7bb5d58298 --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/tls/TlsPurposeTest.java @@ -0,0 +1,59 @@ +/* + * 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.tls; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.HashMap; +import java.util.Map; +import org.testng.annotations.Test; + +/** + * Locks the {@link TlsPurpose} identity contract (PIP-478): equality is over {@code (role, name)}, so a + * purpose resolves to the same purpose→policy map slot regardless of how it was minted. + */ +public class TlsPurposeTest { + + @Test + public void equalsAndHashCodeOverRoleAndName() { + assertThat(TlsPurpose.client("x")).isEqualTo(TlsPurpose.client("x")); + assertThat(TlsPurpose.client("x").hashCode()).isEqualTo(TlsPurpose.client("x").hashCode()); + } + + @Test + public void purposeIsAStableMapKey() { + Map map = new HashMap<>(); + map.put(TlsPurpose.client("x"), "policy"); + + assertThat(map).containsKey(TlsPurpose.client("x")); + assertThat(map.get(TlsPurpose.client("x"))).isEqualTo("policy"); + } + + @Test + public void roleAndNameDistinguish() { + assertThat(TlsPurpose.client("x")).isNotEqualTo(TlsPurpose.server("x")); + assertThat(TlsPurpose.client("x")).isNotEqualTo(TlsPurpose.client("y")); + } + + @Test + public void mintedPurposeCarriesRoleAndName() { + TlsPurpose server = TlsPurpose.server("broker.internal"); + assertThat(server.role()).isEqualTo(TlsPurpose.Role.SERVER); + assertThat(server.name()).isEqualTo("broker.internal"); + } +} diff --git a/pulsar-http-client-api/build.gradle.kts b/pulsar-http-client-api/build.gradle.kts new file mode 100644 index 0000000000000..b0eeb1f9adc29 --- /dev/null +++ b/pulsar-http-client-api/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +// PIP-478: focused, dependency-light API module hosting the framework-managed HTTP client SPI +// (org.apache.pulsar.http). Published so that server-side modules and the v5 client may depend on it +// in later stages — the published-module dependency guard only allows published-on-published deps. +plugins { + id("pulsar.public-java-library-conventions") +} + +dependencies { + // PulsarHttpClientConfig selects TLS material by TlsPurpose, which is part of this SPI's surface; + // exposed as `api` so consumers of PulsarHttpClientConfig see the TLS factory SPI. + api(project(":pulsar-tls-factory-api")) +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpRequest.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpRequest.java new file mode 100644 index 0000000000000..3237c2f5d85a9 --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpRequest.java @@ -0,0 +1,161 @@ +/* + * 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.http; + +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * An immutable HTTP request (PIP-478). + * + *

Construct via {@link #builder(Method, URI)}. The optional {@link Body} is a sealed type so the + * set of body shapes (currently only {@link Bytes}) is closed; the framework's single body-encoding + * site dispatches on the body type. + * + *

Headers are single-valued and keyed by exact name. Unlike {@link HttpResponse} (whose names + * are canonicalised), request header names are NOT case-folded: they are stored verbatim as supplied to + * the builder. Setting a header whose name exactly matches an existing one replaces the prior value + * (last-wins); names that differ only in case are therefore distinct entries, and multi-valued headers are + * not representable. + */ +public final class HttpRequest { + + /** The HTTP method. */ + public enum Method { + GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH + } + + /** A request body. Sealed to the single {@link Bytes} shape. */ + public sealed interface Body permits Bytes { + } + + /** + * A raw byte body with an explicit content type. + * + *

Ownership: the array is handed off, not copied. The caller transfers ownership of + * {@code content} to this body on construction and the framework reads it without copying; neither + * side may mutate the array after construction. Because the component is an array, the record's + * generated {@code equals}/{@code hashCode} are reference-based, not value-based; do not compare + * {@link Bytes} instances for value equality. + * + * @param content the body bytes + * @param contentType the {@code Content-Type} value + */ + public record Bytes(byte[] content, String contentType) implements Body { + } + + private final Method method; + private final URI uri; + private final Map headers; + private final Body body; + + private HttpRequest(Builder b) { + this.method = b.method; + this.uri = b.uri; + this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(b.headers)); + this.body = b.body; + } + + /** + * @return the HTTP method + */ + public Method method() { + return method; + } + + /** + * @return the request URI + */ + public URI uri() { + return uri; + } + + /** + * @return an unmodifiable view of the request headers + */ + public Map headers() { + return headers; + } + + /** + * @return the request body, if any + */ + public Optional body() { + return Optional.ofNullable(body); + } + + /** + * Create a builder. + * + * @param method the HTTP method + * @param uri the request URI + * @return a new {@link Builder} + */ + public static Builder builder(Method method, URI uri) { + return new Builder(method, uri); + } + + /** + * Builder for {@link HttpRequest}. + */ + public static final class Builder { + private final Method method; + private final URI uri; + private final Map headers = new LinkedHashMap<>(); + private Body body; + + private Builder(Method method, URI uri) { + this.method = method; + this.uri = uri; + } + + /** + * Add or replace a header. + * + * @param name the header name + * @param value the header value + * @return this builder + */ + public Builder header(String name, String value) { + headers.put(name, value); + return this; + } + + /** + * Set the request body. + * + * @param body the body + * @return this builder + */ + public Builder body(Body body) { + this.body = body; + return this; + } + + /** + * @return a new immutable {@link HttpRequest} + */ + public HttpRequest build() { + return new HttpRequest(this); + } + } +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpResponse.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpResponse.java new file mode 100644 index 0000000000000..80266143e7f80 --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/HttpResponse.java @@ -0,0 +1,112 @@ +/* + * 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.http; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * An immutable buffered HTTP response (PIP-478). + * + *

The body is fully buffered into a byte array (streaming responses are out of scope for v1). + * Header lookups via {@link #header(String)} are case-insensitive. The header map is single-valued per + * canonical name: two source entries that canonicalise to the same name (case-variants) collapse, and the + * one visited last in the source map's iteration order wins; multi-valued headers are not represented. + * + *

Body ownership: the array is handed off, not copied. {@link #of} takes ownership of the + * {@code body} array without copying it, and {@link #body()} returns that same array without copying; + * the array must not be mutated after the response is constructed or after {@code body()} hands it out. + */ +public final class HttpResponse { + + private final int statusCode; + private final Map headers; + private final byte[] body; + + private HttpResponse(int statusCode, Map headers, byte[] body) { + this.statusCode = statusCode; + Map copy = new LinkedHashMap<>(); + if (headers != null) { + headers.forEach((k, v) -> copy.put(canonical(k), v)); + } + this.headers = Collections.unmodifiableMap(copy); + this.body = body == null ? new byte[0] : body; + } + + /** + * Create a response. + * + * @param statusCode the HTTP status code + * @param headers the response headers (names canonicalised; may be {@code null}) + * @param body the response body bytes (may be {@code null}, treated as empty) + * @return a new {@link HttpResponse} + */ + public static HttpResponse of(int statusCode, Map headers, byte[] body) { + return new HttpResponse(statusCode, headers, body); + } + + /** + * @return the HTTP status code + */ + public int statusCode() { + return statusCode; + } + + /** + * @return an unmodifiable, canonical-cased view of the response headers + */ + public Map headers() { + return headers; + } + + /** + * Look up a header value, case-insensitively. + * + * @param name the header name + * @return the header value if present + */ + public Optional header(String name) { + return Optional.ofNullable(headers.get(canonical(name))); + } + + /** + * @return the raw response body bytes (never {@code null}) + */ + public byte[] body() { + return body; + } + + /** + * Decode the body as UTF-8, ignoring any charset declared in the response {@code Content-Type}. + * + * @return the response body decoded as UTF-8 + */ + public String bodyAsString() { + return new String(body, StandardCharsets.UTF_8); + } + + private static String canonical(String name) { + // RFC 7230 header names are case-insensitive; normalise to lower case for lookups. + return name == null ? null : name.toLowerCase(Locale.ROOT); + } +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClient.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClient.java new file mode 100644 index 0000000000000..6c893270c99e2 --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClient.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.http; + +import java.util.concurrent.CompletableFuture; + +/** + * A framework-managed HTTP client handed to authentication plugins (PIP-478). + * + *

Instances are obtained from + * {@code AuthenticationInitContext.httpClientFactory()}; plugins MUST NOT construct private HTTP + * clients directly, since doing so defeats the framework's shared event-loop / DNS / + * TLS-material-refresh integration. The framework may hand out multiple instances per + * {@code PulsarClient} (for example a different TLS configuration for an OAuth2 token endpoint than for + * HTTP topic lookup) that nonetheless share the underlying resources. + * + *

The HTTP backend is framework-owned (built on AsyncHttpClient) and deliberately not + * pluggable. Implementations must be thread-safe. + */ +public interface PulsarHttpClient extends AutoCloseable { + + /** + * Execute an HTTP request asynchronously. + * + *

Never throws synchronously: all failures — including argument validation — are reported by + * completing the returned future exceptionally, never by throwing on the calling thread. + * + * @param request the request to send + * @return a future completing with the {@link HttpResponse}, or completing exceptionally on + * transport failure + */ + CompletableFuture execute(HttpRequest request); + + /** + * Release this instance. Idempotent. Instances are framework-owned: a plugin MAY close an instance + * it no longer needs, and the framework closes every remaining instance when the owning + * {@code PulsarClient} closes — so calling this is optional for plugins. + */ + @Override + void close(); +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientConfig.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientConfig.java new file mode 100644 index 0000000000000..216924760e92d --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientConfig.java @@ -0,0 +1,177 @@ +/* + * 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.http; + +import java.time.Duration; +import java.util.Objects; +import org.apache.pulsar.tls.TlsPurpose; + +/** + * Per-instance configuration for a {@link PulsarHttpClient} (PIP-478). + * + *

A plugin describes what kind of HTTP client it needs; the framework constructs, pools and + * closes the instance. This deliberately does NOT carry cert/key/trust material directly — the TLS + * material lives in the configured {@code PulsarTlsFactory} and is looked up by {@link #tlsPurpose()}. + * For a plugin that needs its own trust domain (the OAuth2 IdP being the canonical case), the plugin + * selects a distinct purpose — {@link TlsPurpose#CLIENT_OAUTH2}, or a minted variant such as + * {@code TlsPurpose.client("oauth2.myPlugin")} — and the operator configures a + * {@code TlsPolicy} for that purpose; the plugin never handles raw material. Insecure-connection and + * hostname-verification behaviour are baked into the built TLS objects by the factory per purpose, so + * they are not configured here. + */ +public final class PulsarHttpClientConfig { + + private final TlsPurpose tlsPurpose; + private final Duration connectTimeout; + private final Duration readTimeout; + private final Duration requestTimeout; + private final String userAgent; + private final long maxResponseBodyBytes; + + private PulsarHttpClientConfig(Builder b) { + this.tlsPurpose = b.tlsPurpose; + this.connectTimeout = b.connectTimeout; + this.readTimeout = b.readTimeout; + this.requestTimeout = b.requestTimeout; + this.userAgent = b.userAgent; + this.maxResponseBodyBytes = b.maxResponseBodyBytes; + } + + /** + * @return the TLS purpose the framework resolves this client's TLS material by + */ + public TlsPurpose tlsPurpose() { + return tlsPurpose; + } + + /** + * @return the connection timeout + */ + public Duration connectTimeout() { + return connectTimeout; + } + + /** + * @return the read (socket) timeout + */ + public Duration readTimeout() { + return readTimeout; + } + + /** + * @return the overall request timeout + */ + public Duration requestTimeout() { + return requestTimeout; + } + + /** + * @return the {@code User-Agent} header value to send + */ + public String userAgent() { + return userAgent; + } + + /** + * @return the maximum buffered response body size in bytes + */ + public long maxResponseBodyBytes() { + return maxResponseBodyBytes; + } + + /** + * Create a builder for the given TLS purpose. + * + * @param tlsPurpose the TLS purpose used to resolve TLS material + * @return a new {@link Builder} + */ + public static Builder builder(TlsPurpose tlsPurpose) { + return new Builder(tlsPurpose); + } + + /** + * Builder for {@link PulsarHttpClientConfig}. + */ + public static final class Builder { + private static final long DEFAULT_MAX_RESPONSE_BODY_BYTES = 16L * 1024 * 1024; + + private final TlsPurpose tlsPurpose; + private Duration connectTimeout = Duration.ofSeconds(10); + private Duration readTimeout = Duration.ofSeconds(30); + private Duration requestTimeout = Duration.ofSeconds(30); + private String userAgent = "Pulsar-Java-v5"; + private long maxResponseBodyBytes = DEFAULT_MAX_RESPONSE_BODY_BYTES; + + private Builder(TlsPurpose tlsPurpose) { + this.tlsPurpose = Objects.requireNonNull(tlsPurpose, "tlsPurpose must not be null"); + } + + /** + * @param connectTimeout the connection timeout + * @return this builder + */ + public Builder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + /** + * @param readTimeout the read timeout + * @return this builder + */ + public Builder readTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + return this; + } + + /** + * @param requestTimeout the overall request timeout + * @return this builder + */ + public Builder requestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + /** + * @param userAgent the {@code User-Agent} value + * @return this builder + */ + public Builder userAgent(String userAgent) { + this.userAgent = userAgent; + return this; + } + + /** + * @param maxResponseBodyBytes the maximum buffered response body size in bytes + * @return this builder + */ + public Builder maxResponseBodyBytes(long maxResponseBodyBytes) { + this.maxResponseBodyBytes = maxResponseBodyBytes; + return this; + } + + /** + * @return a new immutable {@link PulsarHttpClientConfig} + */ + public PulsarHttpClientConfig build() { + return new PulsarHttpClientConfig(this); + } + } +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientFactory.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientFactory.java new file mode 100644 index 0000000000000..2e845158bd50f --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/PulsarHttpClientFactory.java @@ -0,0 +1,52 @@ +/* + * 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.http; + +/** + * Framework-owned factory for {@link PulsarHttpClient} instances (PIP-478). + * + *

Plugins obtain HTTP clients via {@code AuthenticationInitContext.httpClientFactory()}; they MUST + * NOT construct private clients directly. Multiple instances per {@code PulsarClient} are supported — + * for example OAuth2 mTLS to the identity provider uses a different {@code TlsPurpose}-driven TLS + * configuration than HTTP topic lookup, but they share the underlying event loop / timer / DNS + * resources owned by the framework. + */ +public interface PulsarHttpClientFactory { + + /** + * Create a new {@link PulsarHttpClient} configured per the supplied config. + * + *

The returned instance is owned by the framework; the framework closes it when the + * {@code PulsarClient} is closed. + * + *

Construction-time errors. This is a construction method, not a future-returning request + * method: it resolves the config's {@link org.apache.pulsar.tls.TlsPurpose} to TLS material and + * builds the client eagerly. It therefore throws {@link IllegalStateException} synchronously when the + * factory has already been closed, and when the configured {@code TlsPurpose} cannot be resolved to TLS + * material (an unknown or unbuildable purpose). It performs no request I/O — per-request and network + * failures surface later on the returned {@link PulsarHttpClient}'s request {@code CompletableFuture}s, + * never as a synchronous throw. + * + * @param config per-instance configuration (timeouts, TLS purpose, ...) + * @return a configured HTTP client + * @throws IllegalStateException if the factory is closed, or the config's {@code TlsPurpose} cannot be + * resolved to TLS material + */ + PulsarHttpClient newHttpClient(PulsarHttpClientConfig config); +} diff --git a/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/package-info.java b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/package-info.java new file mode 100644 index 0000000000000..18f246ba36722 --- /dev/null +++ b/pulsar-http-client-api/src/main/java/org/apache/pulsar/http/package-info.java @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/** + * The framework-managed HTTP client SPI (PIP-478). + * + *

Authentication plugins that need HTTP (for example OAuth2's token endpoint or Athenz's ZTS) obtain + * a {@link org.apache.pulsar.http.PulsarHttpClient} from the framework via + * {@code AuthenticationInitContext.httpClientFactory()} rather than constructing their own. The + * framework owns the lifecycle (Netty event loop, timer, DNS cache, TLS material refresh integration) + * and selects each instance's TLS material by {@link org.apache.pulsar.tls.TlsPurpose}; plugins + * describe what they need through a {@link org.apache.pulsar.http.PulsarHttpClientConfig}. + * + *

The HTTP backend is framework-owned (built on AsyncHttpClient) and deliberately not pluggable. The + * SPI is hosted in the focused {@code pulsar-http-client-api} module so it can later serve other HTTP + * needs inside Pulsar (e.g. broker-side JWKS fetching) without importing a client artifact. The module + * depends on {@code pulsar-tls-factory-api} for the {@link org.apache.pulsar.tls.TlsPurpose} value type. + */ +package org.apache.pulsar.http; diff --git a/pulsar-tls-factory-api/build.gradle.kts b/pulsar-tls-factory-api/build.gradle.kts new file mode 100644 index 0000000000000..2f6d38b2a8bd8 --- /dev/null +++ b/pulsar-tls-factory-api/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +// PIP-478: focused, dependency-light API module hosting the purpose-driven TLS factory SPI +// (org.apache.pulsar.tls). Published so that server-side modules (pulsar-common, broker-common, +// proxy, ...) and the v5 client may depend on it in later stages — the published-module dependency +// guard only allows published-on-published deps. +plugins { + id("pulsar.public-java-library-conventions") +} + +dependencies { + // TlsFactoryInitContext exposes OpenTelemetry on the SPI surface, so it is an api dependency: + // an external factory implementation depending only on this module must resolve OpenTelemetry. + // The Netty/Jetty well-known TLS classes appear only as documented Class values referenced in + // plain-text javadoc, so no netty/jetty dependency is needed. + api(libs.opentelemetry.api) +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/PulsarTlsFactory.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/PulsarTlsFactory.java new file mode 100644 index 0000000000000..75c7871167dd9 --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/PulsarTlsFactory.java @@ -0,0 +1,179 @@ +/* + * 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.tls; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** + * The v5 TLS SPI: a purpose-driven, typed instance factory that replaces PIP-337's + * {@code PulsarSslFactory} / {@code PulsarSslConfiguration} (PIP-478). + * + *

Consumers request a fully configured TLS object of a well-known class for a {@link TlsPurpose}; + * how the factory sources key material and builds the object — files, a KMS API, an + * HSM-backed {@code KeyManagerFactory} — is entirely factory-internal. Nothing material-shaped appears + * in this SPI and key material never crosses a Pulsar API. + * + *

Well-known instance classes. A factory need only implement whichever it can build + * directly: + *

+ * As long as a factory supplies at least the JDK {@code SSLContext} for a purpose, the framework can + * derive the Netty and Jetty objects from it. + * + *

{@code SSLParameters} merge order (synthesis path). When the framework synthesizes the Netty / + * Jetty objects from the {@code SSLContext} fallback, it also requests + * {@code createInstance(purpose, SSLParameters.class)} and merges deterministically: + *

    + *
  1. the factory's {@code SSLParameters} (non-null members only) form the engine baseline;
  2. + *
  3. {@code endpointIdentificationAlgorithm} — the factory's value wins when set, otherwise the + * consumer's hostname-verification configuration applies {@code "HTTPS"} on client purposes;
  4. + *
  5. SNI server names are always set per connection from the target endpoint, overriding any factory + * baseline (a factory should not pin SNI);
  6. + *
  7. on server purposes a factory-supplied {@code SSLParameters} is authoritative for + * {@code needClientAuth}/{@code wantClientAuth}, otherwise the consumer's client-auth flag maps as + * usual.
  8. + *
+ * On subscriptions the parameters are re-requested with each {@code SSLContext} delivery, so engine policy + * may rotate with material. The synthesized Jetty factory consults only the protocols, cipher suites, and + * client-auth members (Jetty exposes no setters for the finer members); the full member set applies on the + * Netty synthesis path. + * + *

{@code Optional.empty()} means exactly one thing: the factory does not support the + * requested {@code (purpose, class)} combination. It is NOT a purpose-resolution signal — how a + * factory maps a purpose to configured material is factory-internal. Two guardrails follow: a factory + * that has resolved a purpose to material (or to the system default) and supports the requested + * class must never return {@code empty()} for it — a resolved-but-unbuildable + * request completes the future exceptionally instead, so a real configuration error cannot be + * masked by the framework quietly falling back to {@code SSLContext} synthesis; and when nothing is + * configured for a purpose a factory applies the role's terminal rule — an unconfigured client purpose + * resolves to the system default, an unconfigured server purpose is a configuration error — unless it + * documents divergent resolution. + * + *

Never throws synchronously. Like every future-returning method in this SPI, + * {@code createInstance} reports all failures — including argument validation and build errors — by + * completing the returned future exceptionally; it never throws on the calling thread. + * + *

Thread-safety and lifecycle ordering. {@link #initialize} is called exactly once and + * completes before any {@code createInstance} call. After initialization, {@code createInstance} may be + * invoked concurrently — for the same and for different purposes — as many connections resolve + * TLS in parallel; implementations must be thread-safe. {@link #close} is called at most once and after + * the owning component has stopped issuing {@code createInstance} calls. + * + *

Reload callbacks (subscribing overload) are serial per subscription, never concurrent, + * never invoked on a consumer event loop, and the first delivery happens-before the returned + * future completes. A failed rebuild on rotation keeps serving the last-good instance (logged at WARN, + * recorded in the reload-failure metric) and retries on the next observed material change; a consumer + * callback that throws is caught and logged, and the subscription stays live. + */ +public interface PulsarTlsFactory extends AutoCloseable { + + /** + * Initialize the factory with its runtime services. Completes before the first + * {@code createInstance} call; a failure is fatal to the owning component's startup. + * + * @param context the factory parameters and framework runtime services + * @return a future completing when the factory is ready + */ + CompletableFuture initialize(TlsFactoryInitContext context); + + /** + * Build a one-shot instance of {@code instanceClass} for the given purpose. + * + * @param purpose the TLS purpose to resolve material for + * @param instanceClass the well-known instance class to build + * @param the instance type + * @return a future of a fully configured instance for the purpose, or {@link Optional#empty()} + * when this factory does not support the {@code (purpose, class)} combination; a failure to + * build a SUPPORTED combination completes the future exceptionally instead + */ + CompletableFuture>> createInstance(TlsPurpose purpose, Class instanceClass); + + /** + * One-shot variant carrying the destination endpoint as a per-request HINT. Client-side consumers + * pass the target host/port when they know it (one connection, one instance); a factory that serves + * per-destination material (multi-cluster deployments, per-target workload identities) may key on + * it. + * + *

The default implementation ignores the endpoint and delegates to + * {@link #createInstance(TlsPurpose, Class)} — most factories, including the default file-based one, + * never look at it. The endpoint does NOT replace hostname verification or SNI: those are applied at + * engine creation from the same peer address. + * + *

Never throws synchronously: like every future-returning method here, it reports all failures by + * completing the returned future exceptionally. The default delegation is guarded so that even a + * delegate that throws on the calling thread surfaces as a failed future rather than propagating. + * + * @param purpose the TLS purpose to resolve material for + * @param endpoint the destination host/port hint + * @param instanceClass the well-known instance class to build + * @param the instance type + * @return a future of a fully configured instance, or {@link Optional#empty()} when unsupported + */ + default CompletableFuture>> createInstance( + TlsPurpose purpose, TlsEndpoint endpoint, Class instanceClass) { + try { + return createInstance(purpose, instanceClass); + } catch (Throwable t) { + return CompletableFuture.failedFuture(t); + } + } + + /** + * Like the one-shot form, but additionally subscribes to reloads: {@code onLoadOrReload} receives + * the instance on initial load and a REBUILT instance whenever the underlying material changes. The + * returned future completes after the first delivery. Subscriptions are purpose-scoped and carry no + * endpoint — they serve server-side listeners, which have no destination. + * + * @param purpose the TLS purpose to resolve material for + * @param instanceClass the well-known instance class to build + * @param onLoadOrReload receives the instance on first load and on every subsequent rebuild + * @param the instance type + * @return a future of the subscribing handle, or {@link Optional#empty()} when unsupported + */ + CompletableFuture>> createInstance( + TlsPurpose purpose, Class instanceClass, Consumer onLoadOrReload); + + /** + * Release the factory and all its instances. The component that created the factory owns and closes + * it; a factory instance supplied programmatically to the v5 builder is adopted and closed with the + * client. + */ + @Override + void close(); +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsEndpoint.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsEndpoint.java new file mode 100644 index 0000000000000..6c5a7512e51dd --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsEndpoint.java @@ -0,0 +1,35 @@ +/* + * 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.tls; + +/** + * The destination of an outbound connection, passed to a {@link PulsarTlsFactory} as a per-request + * hint (PIP-478). + * + *

Client-side consumers pass the target host and port when they know it, so a factory that serves + * per-destination material (multi-cluster deployments, per-target workload identities) may specialize + * on it. Factories are free to ignore it — the default file-based factory does. The endpoint does NOT + * replace hostname verification or SNI: those are applied at engine creation from the same peer + * address by whichever component builds the {@code SSLEngine}. + * + * @param host the destination host + * @param port the destination port + */ +public record TlsEndpoint(String host, int port) { +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsFactoryInitContext.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsFactoryInitContext.java new file mode 100644 index 0000000000000..430eb339a376a --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsFactoryInitContext.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.tls; + +import io.opentelemetry.api.OpenTelemetry; +import java.time.Clock; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; + +/** + * The runtime services handed to a {@link PulsarTlsFactory#initialize(TlsFactoryInitContext)} call + * (PIP-478). + * + *

The context is constructed by whichever component owns the factory — the v5 client builder on the + * client side; the broker / proxy / websocket / functions-worker service on the server side — and + * {@code initialize(...)} completes before the first {@code createInstance} call. The framework owns + * and closes these shared services; the factory may retain references for its lifetime. + */ +public interface TlsFactoryInitContext { + + /** + * Factory-specific parameters from the owning component's configuration (the + * {@code tlsFactoryConfig} key on the server side; builder-supplied on the client). + * + * @return the factory parameters (possibly empty) + */ + Map params(); + + /** + * @return a framework-owned scheduler for file-watch polling and rotation work; never a consumer + * event loop + */ + ScheduledExecutorService scheduler(); + + /** + * @return an executor for potentially-blocking material loading; never a consumer event loop + */ + Executor blockingExecutor(); + + /** + * @return the clock a factory should read the current time from — for example to age a cached + * context or stamp a rotation — injectable so tests can drive time deterministically + * instead of sleeping; never {@code null} + */ + Clock clock(); + + /** + * @return the telemetry root; the framework defaults to {@link OpenTelemetry#noop()} if unset + */ + OpenTelemetry openTelemetry(); +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsHandle.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsHandle.java new file mode 100644 index 0000000000000..e8eeef38b7734 --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsHandle.java @@ -0,0 +1,60 @@ +/* + * 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.tls; + +/** + * A handle for a built TLS instance, returned by every {@link PulsarTlsFactory} {@code createInstance} + * form (PIP-478). + * + *

For a one-shot request, {@link #get()} returns the built instance. For a subscribing request, it + * returns the value most recently delivered to the reload callback (the initial load, or the latest + * rebuild), so a consumer can read the live instance on demand without caching callback deliveries. + * + *

Instance ownership. The value returned by {@link #get()} is a factory-owned + * snapshot: consumers never close, release, or mutate it — in particular they must not + * {@code release()} a Netty reference-counted OpenSSL context. {@link #dispose()} only signals that + * this consumer is done; the factory releases a superseded or fully-disposed instance's native + * resources itself, once the last handle referencing it is gone. + * + * @param the built instance type (e.g. {@code io.netty.handler.ssl.SslContext}, + * {@code javax.net.ssl.SSLContext}, or Jetty's {@code SslContextFactory.Server}) + */ +public interface TlsHandle { + + /** + * Returns the current instance snapshot. This never blocks: for a one-shot request it returns + * the already-built instance; for a subscribing request the initial value is present before this + * method is first callable, because the {@code createInstance} future completes only after the first + * reload delivery (see {@link PulsarTlsFactory}). Calling {@code get()} after {@link #dispose()} is + * unspecified — the factory may already have released the instance's backing resources — so a consumer + * MUST stop calling {@code get()} once it has disposed the handle. + * + * @return the built instance for a one-shot request, or the most recently delivered instance for + * a subscribing request (never {@code null}); treated by consumers as an immutable borrow + */ + T get(); + + /** + * Unregister the reload callback (if any) and release the factory-side resources backing this + * handle (background refresh, caches). Signals only that this consumer is done; the factory owns + * the built instance's lifecycle. Idempotent: only the first call has effect; subsequent + * calls are no-ops and never double-release the instance. + */ + void dispose(); +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPolicy.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPolicy.java new file mode 100644 index 0000000000000..e9de0a234aef6 --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPolicy.java @@ -0,0 +1,592 @@ +/* + * 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.tls; + +import java.util.List; +import java.util.Objects; + +/** + * The single, user-facing TLS configuration value: a flat, immutable description of what + * material to use and the policy flags (PIP-478). + * + *

This subsumes the experimental PIP-466 {@code org.apache.pulsar.client.api.v5.config.TlsPolicy} + * (PEM-only) and lives in the neutral SPI module so the client builder and the server components + * consume the same value. To keep it friendly to a future configuration-file loader it is a + * flat value with a {@link Format} discriminator rather than a polymorphic hierarchy — + * one type covering PEM and keystore/truststore, plus the common flags, with static factories for the + * common shapes. + * + *

{@code TlsPolicy} describes material locations, not the loaded material, so it stays a + * small serializable value; loading, caching, and rotation are the internal material source's job + * inside the default {@code FileBasedTlsFactory}, which ships in {@code pulsar-common}. + */ +public final class TlsPolicy { + + /** Whether the TLS material is described as PEM files or as a keystore/truststore pair. */ + public enum Format { + /** PEM files: trust certs, certificate, and private key. */ + PEM, + /** Keystore/truststore (JKS or PKCS12). */ + KEYSTORE + } + + private final Format format; + // format == PEM + private final String trustCertsFilePath; + private final String certificateFilePath; + private final String keyFilePath; + // format == KEYSTORE + private final String trustStorePath; + private final String trustStorePassword; + private final String keyStorePath; + private final String keyStorePassword; + private final String keyStoreType; + private final String trustStoreType; + // common flags (both formats) + private final boolean allowInsecureConnection; + private final boolean enableHostnameVerification; + private final List protocols; + private final List ciphers; + // optional JSSE (SSLContext) provider (a java.security.Provider name) + private final String jsseProvider; + // optional JCA (KeyStore/CertificateFactory/KeyFactory) provider (a java.security.Provider name) + private final String jcaProvider; + + private TlsPolicy(Builder b) { + this.format = b.format; + this.trustCertsFilePath = b.trustCertsFilePath; + this.certificateFilePath = b.certificateFilePath; + this.keyFilePath = b.keyFilePath; + this.trustStorePath = b.trustStorePath; + this.trustStorePassword = b.trustStorePassword; + this.keyStorePath = b.keyStorePath; + this.keyStorePassword = b.keyStorePassword; + this.keyStoreType = b.keyStoreType; + this.trustStoreType = b.trustStoreType; + this.allowInsecureConnection = b.allowInsecureConnection; + this.enableHostnameVerification = b.enableHostnameVerification; + this.protocols = List.copyOf(b.protocols); + this.ciphers = List.copyOf(b.ciphers); + this.jsseProvider = b.jsseProvider; + this.jcaProvider = b.jcaProvider; + } + + /** + * @return the material format discriminator (PEM or keystore) + */ + public Format format() { + return format; + } + + /** + * @return the trusted CA certificate file path (PEM format), or {@code null} + */ + public String trustCertsFilePath() { + return trustCertsFilePath; + } + + /** + * @return the client certificate file path (PEM format), or {@code null} + */ + public String certificateFilePath() { + return certificateFilePath; + } + + /** + * @return the client private key file path (PEM format), or {@code null} + */ + public String keyFilePath() { + return keyFilePath; + } + + /** + * @return the truststore path (keystore format), or {@code null} + */ + public String trustStorePath() { + return trustStorePath; + } + + /** + * @return the truststore password (keystore format), or {@code null} + */ + public String trustStorePassword() { + return trustStorePassword; + } + + /** + * @return the keystore path (keystore format), or {@code null} + */ + public String keyStorePath() { + return keyStorePath; + } + + /** + * @return the keystore password (keystore format), or {@code null} + */ + public String keyStorePassword() { + return keyStorePassword; + } + + /** + * @return the keystore type (e.g. {@code JKS} / {@code PKCS12}); blank/{@code null} means the JDK + * {@link java.security.KeyStore#getDefaultType() default keystore type} + */ + public String keyStoreType() { + return keyStoreType; + } + + /** + * @return the truststore type (e.g. {@code JKS} / {@code PKCS12}); blank/{@code null} means the JDK + * {@link java.security.KeyStore#getDefaultType() default keystore type} + */ + public String trustStoreType() { + return trustStoreType; + } + + /** + * @return whether connecting to endpoints with untrusted certificates is allowed + */ + public boolean allowInsecureConnection() { + return allowInsecureConnection; + } + + /** + * @return whether the peer hostname is verified against the certificate + */ + public boolean enableHostnameVerification() { + return enableHostnameVerification; + } + + /** + * @return the enabled TLS protocols, or an empty list to use the defaults + */ + public List protocols() { + return protocols; + } + + /** + * @return the enabled TLS cipher suites, or an empty list to use the defaults + */ + public List ciphers() { + return ciphers; + } + + /** + * The JSSE (SSLContext) provider — a {@link java.security.Provider} name that supplies an + * {@link javax.net.ssl.SSLContext} (TLS) implementation (e.g. the BouncyCastle JSSE provider {@code BCJSSE} + * for FIPS, with {@code BCFIPS} registered separately as the crypto provider it uses) — used to build the + * TLS {@code SSLContext}. When set (non-blank), the default file-based factory builds the JDK Netty engine + * with this provider installed as the {@code SSLContext} provider, taking precedence over the factory-level + * OpenSSL/JDK engine choice. Blank/{@code null} means the platform default. This is the JSSE provider that + * builds the {@code SSLContext}, not a crypto-only {@code java.security.Provider}: a crypto-only provider + * such as {@code BCFIPS} exposes no {@code SSLContext.TLS} and cannot be named here directly. + * + * @return the JSSE (SSLContext) provider name, or {@code null}/blank for the platform default + */ + public String jsseProvider() { + return jsseProvider; + } + + /** + * The JCA (material) provider — a {@link java.security.Provider} name used to create the + * {@code java.security} engine classes that parse and hold the key material: + * {@link java.security.KeyStore}, {@link java.security.cert.CertificateFactory} and + * {@link java.security.KeyFactory}. This is the second, independent axis next to {@link #jsseProvider()}: + * a FIPS deployment sets {@code jsseProvider=BCJSSE} (the BouncyCastle JSSE provider, which registers no + * {@code KeyStore}/{@code CertificateFactory} services) and {@code jcaProvider=BCFIPS} (the + * BouncyCastle FIPS crypto provider, which registers no JSSE services), so the material is parsed — and + * the {@code PrivateKey} objects manufactured — inside the validated module. + * + *

Note the provider names: the BouncyCastle FIPS JSSE provider registers under the name {@code BCJSSE} + * even when constructed in FIPS mode ({@code new BouncyCastleJsseProvider("fips:BCFIPS")}); there is no + * provider named {@code BCFIPSJSSE}. + * + *

JSSE service types are never taken from here. {@code SSLContext}, {@code KeyManagerFactory} + * and {@code TrustManagerFactory} are JSSE service types and stay pinned to {@link #jsseProvider()}; a + * crypto-only provider registers none of them, so applying this field there would break a FIPS deployment + * rather than enable it. + * + *

Explicit-only, no legacy routing. Blank/{@code null} means exactly today's behaviour — the JVM + * provider search order — and, unlike {@link #jsseProvider()}, no legacy v4 configuration value is ever + * routed into this field. Do not add such a fallback "for symmetry". + * + *

Orthogonal to {@link #keyStoreType()}/{@link #trustStoreType()}: those choose which store + * format, this chooses who supplies it. When a pinned provider does not register the requested + * store type the load fails loudly (naming the types the provider does register) rather than falling back + * to another provider, which would silently void the property the pin was set to obtain. + * + * @return the JCA (material) provider name, or {@code null}/blank for the JVM provider search order + */ + public String jcaProvider() { + return jcaProvider; + } + + /** + * Create a PEM-format policy. + * + * @param trustCerts the trusted CA certificate file path (may be {@code null} for system default) + * @param cert the client certificate file path (may be {@code null} when not using mTLS) + * @param key the client private key file path (may be {@code null} when not using mTLS) + * @return a new PEM-format {@link TlsPolicy} + */ + public static TlsPolicy pem(String trustCerts, String cert, String key) { + return builder() + .format(Format.PEM) + .trustCertsFilePath(trustCerts) + .certificateFilePath(cert) + .keyFilePath(key) + .build(); + } + + /** + * Create a keystore-format policy that uses a single store type for both the keystore and the + * truststore (the common case). To use different types (e.g. a PKCS12 keystore with a JKS truststore), + * build via {@link #builder()} and set {@link Builder#keyStoreType(String)} and + * {@link Builder#trustStoreType(String)} separately. + * + * @param trustStore the truststore path + * @param trustStorePw the truststore password + * @param keyStore the keystore path (may be {@code null} when not using mTLS) + * @param keyStorePw the keystore password (may be {@code null} when not using mTLS) + * @param storeType the store type (e.g. {@code JKS} / {@code PKCS12}) applied to BOTH the keystore and + * the truststore + * @return a new keystore-format {@link TlsPolicy} + */ + public static TlsPolicy keyStore(String trustStore, String trustStorePw, + String keyStore, String keyStorePw, String storeType) { + return builder() + .format(Format.KEYSTORE) + .trustStorePath(trustStore) + .trustStorePassword(trustStorePw) + .keyStorePath(keyStore) + .keyStorePassword(keyStorePw) + .keyStoreType(storeType) + .trustStoreType(storeType) + .build(); + } + + /** + * Create an insecure PEM policy that accepts any certificate and skips hostname verification + * (development only). + * + * @return a new insecure {@link TlsPolicy} + */ + public static TlsPolicy insecure() { + return builder() + .allowInsecureConnection(true) + .enableHostnameVerification(false) + .build(); + } + + /** + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TlsPolicy that)) { + return false; + } + return allowInsecureConnection == that.allowInsecureConnection + && enableHostnameVerification == that.enableHostnameVerification + && format == that.format + && Objects.equals(trustCertsFilePath, that.trustCertsFilePath) + && Objects.equals(certificateFilePath, that.certificateFilePath) + && Objects.equals(keyFilePath, that.keyFilePath) + && Objects.equals(trustStorePath, that.trustStorePath) + && Objects.equals(trustStorePassword, that.trustStorePassword) + && Objects.equals(keyStorePath, that.keyStorePath) + && Objects.equals(keyStorePassword, that.keyStorePassword) + && Objects.equals(keyStoreType, that.keyStoreType) + && Objects.equals(trustStoreType, that.trustStoreType) + && protocols.equals(that.protocols) + && ciphers.equals(that.ciphers) + && Objects.equals(jsseProvider, that.jsseProvider) + && Objects.equals(jcaProvider, that.jcaProvider); + } + + @Override + public int hashCode() { + return Objects.hash(format, trustCertsFilePath, certificateFilePath, keyFilePath, + trustStorePath, trustStorePassword, keyStorePath, keyStorePassword, keyStoreType, + trustStoreType, allowInsecureConnection, enableHostnameVerification, protocols, ciphers, + jsseProvider, jcaProvider); + } + + @Override + public String toString() { + // Passwords are intentionally masked so the value can be logged without leaking secrets. + return "TlsPolicy{format=" + format + + ", trustCertsFilePath=" + trustCertsFilePath + + ", certificateFilePath=" + certificateFilePath + + ", keyFilePath=" + keyFilePath + + ", trustStorePath=" + trustStorePath + + ", trustStorePassword=" + (trustStorePassword == null ? "null" : "****") + + ", keyStorePath=" + keyStorePath + + ", keyStorePassword=" + (keyStorePassword == null ? "null" : "****") + + ", keyStoreType=" + keyStoreType + + ", trustStoreType=" + trustStoreType + + ", allowInsecureConnection=" + allowInsecureConnection + + ", enableHostnameVerification=" + enableHostnameVerification + + ", protocols=" + protocols + + ", ciphers=" + ciphers + + ", jsseProvider=" + jsseProvider + + ", jcaProvider=" + jcaProvider + + '}'; + } + + /** + * Builder for {@link TlsPolicy}. Defaults to {@link Format#PEM}, secure connections, and hostname + * verification enabled. + */ + public static final class Builder { + private Format format = Format.PEM; + private String trustCertsFilePath; + private String certificateFilePath; + private String keyFilePath; + private String trustStorePath; + private String trustStorePassword; + private String keyStorePath; + private String keyStorePassword; + private String keyStoreType; + private String trustStoreType; + private boolean allowInsecureConnection = false; + private boolean enableHostnameVerification = true; + private List protocols = List.of(); + private List ciphers = List.of(); + private String jsseProvider; + private String jcaProvider; + + private Builder() { + } + + /** + * @param format the material format discriminator + * @return this builder + */ + public Builder format(Format format) { + this.format = Objects.requireNonNull(format, "format must not be null"); + return this; + } + + /** + * @param trustCertsFilePath the trusted CA certificate file path (PEM) + * @return this builder + */ + public Builder trustCertsFilePath(String trustCertsFilePath) { + this.trustCertsFilePath = trustCertsFilePath; + return this; + } + + /** + * @param certificateFilePath the client certificate file path (PEM) + * @return this builder + */ + public Builder certificateFilePath(String certificateFilePath) { + this.certificateFilePath = certificateFilePath; + return this; + } + + /** + * @param keyFilePath the client private key file path (PEM) + * @return this builder + */ + public Builder keyFilePath(String keyFilePath) { + this.keyFilePath = keyFilePath; + return this; + } + + /** + * @param trustStorePath the truststore path (keystore format) + * @return this builder + */ + public Builder trustStorePath(String trustStorePath) { + this.trustStorePath = trustStorePath; + return this; + } + + /** + * @param trustStorePassword the truststore password (keystore format) + * @return this builder + */ + public Builder trustStorePassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + return this; + } + + /** + * @param keyStorePath the keystore path (keystore format) + * @return this builder + */ + public Builder keyStorePath(String keyStorePath) { + this.keyStorePath = keyStorePath; + return this; + } + + /** + * @param keyStorePassword the keystore password (keystore format) + * @return this builder + */ + public Builder keyStorePassword(String keyStorePassword) { + this.keyStorePassword = keyStorePassword; + return this; + } + + /** + * @param keyStoreType the keystore type (e.g. {@code JKS} / {@code PKCS12}); blank/{@code null} uses + * the JDK default keystore type + * @return this builder + */ + public Builder keyStoreType(String keyStoreType) { + this.keyStoreType = keyStoreType; + return this; + } + + /** + * @param trustStoreType the truststore type (e.g. {@code JKS} / {@code PKCS12}); blank/{@code null} + * uses the JDK default keystore type + * @return this builder + */ + public Builder trustStoreType(String trustStoreType) { + this.trustStoreType = trustStoreType; + return this; + } + + /** + * @param allowInsecureConnection whether to accept untrusted certificates + * @return this builder + */ + public Builder allowInsecureConnection(boolean allowInsecureConnection) { + this.allowInsecureConnection = allowInsecureConnection; + return this; + } + + /** + * @param enableHostnameVerification whether to verify the peer hostname + * @return this builder + */ + public Builder enableHostnameVerification(boolean enableHostnameVerification) { + this.enableHostnameVerification = enableHostnameVerification; + return this; + } + + /** + * @param protocols the enabled TLS protocols + * @return this builder + */ + public Builder protocols(List protocols) { + this.protocols = protocols == null ? List.of() : List.copyOf(protocols); + return this; + } + + /** + * @param ciphers the enabled TLS cipher suites + * @return this builder + */ + public Builder ciphers(List ciphers) { + this.ciphers = ciphers == null ? List.of() : List.copyOf(ciphers); + return this; + } + + /** + * @param jsseProvider the JSSE (SSLContext) provider name (a {@link java.security.Provider} name that + * supplies an {@link javax.net.ssl.SSLContext} implementation, e.g. the BouncyCastle + * JSSE provider {@code BCJSSE} for FIPS, with {@code BCFIPS} registered separately as + * the crypto provider it uses); blank/{@code null} uses the platform default. When set, + * the default file-based factory pins the JDK engine with this provider as the + * {@code SSLContext} provider (overriding the factory engine choice). + * @return this builder + */ + public Builder jsseProvider(String jsseProvider) { + // Normalize blank to null so a commented-out-but-empty config key produces a policy equal to an + // unset one (policy value equality drives rotation-change suppression). + this.jsseProvider = trimToNull(jsseProvider); + return this; + } + + /** + * @param jcaProvider the JCA (material) provider name — a {@link java.security.Provider} name used to + * create the {@link java.security.KeyStore}, + * {@link java.security.cert.CertificateFactory} and {@link java.security.KeyFactory} + * engines that parse the TLS material (e.g. {@code BCFIPS} for FIPS, alongside + * {@code jsseProvider=BCJSSE}); blank/{@code null} uses the JVM provider search + * order, i.e. the behaviour of releases before PIP-478. JSSE service types + * ({@code SSLContext}/{@code KeyManagerFactory}/{@code TrustManagerFactory}) are + * never taken from this provider — see {@link TlsPolicy#jcaProvider()}. + * @return this builder + */ + public Builder jcaProvider(String jcaProvider) { + // Normalize blank to null, as for jsseProvider: server config surfaces pass the raw config value. + this.jcaProvider = trimToNull(jcaProvider); + return this; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * @return a new immutable {@link TlsPolicy} + * @throws IllegalArgumentException if a configured field is inconsistent with the chosen + * {@link Format} — a keystore/truststore field on a {@link Format#PEM} policy, or a PEM file + * field on a {@link Format#KEYSTORE} policy. Validating here (a constructor/builder may throw + * synchronously) keeps the fail-loud contract: a misplaced field is a configuration error, not + * a silently-ignored value. + */ + public TlsPolicy build() { + validateFormatConsistency(); + return new TlsPolicy(this); + } + + private void validateFormatConsistency() { + if (format == Format.PEM) { + rejectForFormat("trustStorePath", trustStorePath); + rejectForFormat("trustStorePassword", trustStorePassword); + rejectForFormat("keyStorePath", keyStorePath); + rejectForFormat("keyStorePassword", keyStorePassword); + rejectForFormat("keyStoreType", keyStoreType); + rejectForFormat("trustStoreType", trustStoreType); + } else { // Format.KEYSTORE + rejectForFormat("trustCertsFilePath", trustCertsFilePath); + rejectForFormat("certificateFilePath", certificateFilePath); + rejectForFormat("keyFilePath", keyFilePath); + } + } + + private void rejectForFormat(String field, String value) { + if (value != null && !value.isBlank()) { + throw new IllegalArgumentException("TlsPolicy field '" + field + "' is set but is not valid for " + + "format " + format + "; use the fields matching the chosen format (PEM: " + + "trustCertsFilePath/certificateFilePath/keyFilePath; KEYSTORE: " + + "trustStorePath/keyStorePath/... with keyStoreType/trustStoreType), or set the format " + + "to match the material."); + } + } + } +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPurpose.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPurpose.java new file mode 100644 index 0000000000000..bd6367d7f030c --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/TlsPurpose.java @@ -0,0 +1,145 @@ +/* + * 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.tls; + +import java.util.Objects; + +/** + * Identifies why TLS is requested and in what role (PIP-478). + * + *

A {@code TlsPurpose} is a simple named key, not a type hierarchy: a + * {@link Role} (client or server) and an open {@link #name()}. A {@link PulsarTlsFactory} serves + * distinct TLS material per purpose; the well-known purposes are exposed as constants, and components + * may mint additional open-named purposes with {@link #client(String)} / {@link #server(String)}. + * + *

Terminal resolution. When nothing is configured for a purpose, resolution ends: for the + * {@link Role#CLIENT} role it resolves to the system default (the OS trust store, no client + * certificate); for the {@link Role#SERVER} role it is a configuration error. In particular the OAuth2 / + * identity-provider purpose ({@link #CLIENT_OAUTH2}) resolves to the system default rather than reusing + * Pulsar-cluster TLS material, since the identity provider is a different trust domain. + * + *

The value is immutable. Instances are safe to use as map keys — {@link #equals(Object)} / + * {@link #hashCode()} are defined over the role and name. + */ +public final class TlsPurpose { + + /** Whether the purpose describes an outbound (client) or inbound (server) TLS endpoint. */ + public enum Role { + /** Outbound TLS: the local endpoint acts as a TLS client. */ + CLIENT, + /** Inbound TLS: the local endpoint acts as a TLS server. */ + SERVER + } + + // Well-known CLIENT purposes. + + /** Pulsar-cluster traffic: binary protocol, HTTP topic lookup, and the admin client. */ + public static final TlsPurpose CLIENT_DEFAULT = new TlsPurpose(Role.CLIENT, "default"); + + /** + * OAuth2 / identity-provider calls. An unconfigured {@code CLIENT_OAUTH2} resolves to the system + * default rather than to the cluster material, because the identity provider is a different trust + * domain and its TLS material must not be shared with Pulsar-cluster connections. + */ + public static final TlsPurpose CLIENT_OAUTH2 = new TlsPurpose(Role.CLIENT, "oauth2"); + + /** + * A server component's own outbound Pulsar-client traffic — geo-replication, proxy→broker, + * websocket→broker, and functions-worker→broker connections. A distinct trust domain + * from both the server listeners and any application client (configured through the dedicated + * {@code brokerClient*} keys on the server side). + */ + public static final TlsPurpose BROKER_CLIENT = new TlsPurpose(Role.CLIENT, "broker-client"); + + // Well-known SERVER purposes. + + /** The broker's binary protocol listener(s). */ + public static final TlsPurpose BROKER = new TlsPurpose(Role.SERVER, "broker"); + + /** The proxy's binary protocol front-end. */ + public static final TlsPurpose PROXY = new TlsPurpose(Role.SERVER, "proxy"); + + /** A component's Jetty web service (broker / proxy / functions-worker). */ + public static final TlsPurpose WEB = new TlsPurpose(Role.SERVER, "web"); + + private final Role role; + private final String name; + + private TlsPurpose(Role role, String name) { + this.role = Objects.requireNonNull(role, "role must not be null"); + this.name = Objects.requireNonNull(name, "name must not be null"); + } + + /** + * @return whether this purpose is a client-role (outbound) or server-role (inbound) endpoint + */ + public Role role() { + return role; + } + + /** + * @return the well-known or plugin-minted name, e.g. {@code "default"}, {@code "oauth2"}, + * {@code "broker-client"}, {@code "broker"} + */ + public String name() { + return name; + } + + /** + * Mint a client-role purpose. An unconfigured client purpose resolves to the system default (OS + * trust store, no client certificate). + * + * @param name the open purpose name + * @return a new client-role {@link TlsPurpose} + */ + public static TlsPurpose client(String name) { + return new TlsPurpose(Role.CLIENT, name); + } + + /** + * Mint a server-role purpose. An unconfigured server purpose is a configuration error. + * + * @param name the open purpose name + * @return a new server-role {@link TlsPurpose} + */ + public static TlsPurpose server(String name) { + return new TlsPurpose(Role.SERVER, name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TlsPurpose that)) { + return false; + } + return role == that.role && name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(role, name); + } + + @Override + public String toString() { + return "TlsPurpose{" + role + ' ' + name + '}'; + } +} diff --git a/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/package-info.java b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/package-info.java new file mode 100644 index 0000000000000..033918700e9a6 --- /dev/null +++ b/pulsar-tls-factory-api/src/main/java/org/apache/pulsar/tls/package-info.java @@ -0,0 +1,41 @@ +/* + * 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. + */ + +/** + * The purpose-driven TLS SPI that replaces PIP-337's SSL factory (PIP-478). + * + *

A {@link org.apache.pulsar.tls.PulsarTlsFactory} answers requests for fully configured TLS + * objects ({@code io.netty.handler.ssl.SslContext}, Jetty's {@code SslContextFactory.Server}, or + * {@code javax.net.ssl.SSLContext}) per {@link org.apache.pulsar.tls.TlsPurpose}, delivering + * rebuilt instances through reload callbacks on rotation. A factory that supplies only the + * {@code SSLContext} fallback may additionally supply a {@code javax.net.ssl.SSLParameters} companion — + * the optional engine-policy baseline (protocols, cipher suites, client-auth mode, endpoint + * identification) consulted on the synthesis path with a deterministic merge order documented on + * {@link org.apache.pulsar.tls.PulsarTlsFactory}. How the factory sources key material and builds + * the objects is entirely factory-internal — nothing material-shaped appears in the SPI and key material + * never crosses a Pulsar API. The single user-facing configuration value is + * {@link org.apache.pulsar.tls.TlsPolicy}. + * + *

This SPI is hosted in the focused, dependency-light {@code pulsar-tls-factory-api} module so both + * the v5 client builder and the server-side components (and the sibling broker-side PIP) can consume + * the same SPI without dragging in heavyweight dependencies. The default {@code PulsarTlsFactory} + * implementation ({@code FileBasedTlsFactory} under {@code org.apache.pulsar.common.tls.impl}) and the + * JDK-only hostname-verification helpers ship in {@code pulsar-common}, not in this module. + */ +package org.apache.pulsar.tls; diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b8c0b2850a24..28be05724f712 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -71,6 +71,9 @@ include("buildtools") include("pulsar-config-validation") include("pulsar-client-api") include("pulsar-client-api-v5") +// Focused, dependency-light SPI modules (PIP-478): TLS factory SPI and HTTP client SPI +include("pulsar-tls-factory-api") +include("pulsar-http-client-api") // Tier 1 include("pulsar-client-admin-api")