From e03fb51f2cb76f12c65ede345b6c9ee7d2ed0fba Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Wed, 6 Nov 2024 10:13:37 +0900 Subject: [PATCH 01/12] `ServerHttpResponse` sets the response `statusCode` correctly (#5884) Motivation: It seems like Armeria's spring integration response `ServerHttpResponse` is not always set correctly. In detail, it seems like we can confirm that `getStatusCode` returns `UNKNOWN` unless `setStatusCode` is called explicitly. 1. When a successful response is returned, we refer to the response headers used. This is similar to the upstream implementation which falls back to the response which is actually returned. https://github.com/spring-projects/spring-framework/blob/d2ea5b444812b4c55e00fecb7b6451073677061d/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java#L72-L76 2. When `Armeria` returns a response instead of `Spring`, the response is never committed. Now, the `doOnCancel` is listened to and the response is committed accordingly. Modifications: - `ArmeriaServerHttpResponse#getStatusCode` now also checks the response headers when returning a value - `doOnCancel` now also tries to set the response code and commit the `ArmeriaServerHttpResponse` Result: - https://github.com/line/armeria/issues/5882 --- .../boot2-webflux-autoconfigure/build.gradle | 2 + ...ractServerHttpResponseVersionSpecific.java | 12 +- .../reactive/AbstractServerHttpResponse.java | 3 + ...ractServerHttpResponseVersionSpecific.java | 10 +- .../reactive/ArmeriaHttpHandlerAdapter.java | 11 ++ .../reactive/ArmeriaServerHttpResponse.java | 9 ++ .../spring/web/reactive/ObservationTest.java | 137 ++++++++++++++++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java diff --git a/spring/boot2-webflux-autoconfigure/build.gradle b/spring/boot2-webflux-autoconfigure/build.gradle index a3f58820359..37ca955225b 100644 --- a/spring/boot2-webflux-autoconfigure/build.gradle +++ b/spring/boot2-webflux-autoconfigure/build.gradle @@ -86,6 +86,8 @@ task generateSources(type: Copy) { exclude '**/package-info.java' exclude '**/org.springframework.boot.autoconfigure.AutoConfiguration.imports' exclude '**/TlsUtil.java' + // Micrometer observation is not supported for spring-boot-2 + exclude '**/ObservationTest.java' } into "${project.ext.genSrcDir}" diff --git a/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java b/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java index 5b51d68b9b3..293401a9040 100644 --- a/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java +++ b/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java @@ -51,7 +51,14 @@ public boolean setStatusCode(@Nullable HttpStatus status) { @Override @Nullable public HttpStatus getStatusCode() { - return statusCode != null ? HttpStatus.resolve(statusCode) : null; + if (statusCode != null) { + return HttpStatus.resolve(statusCode); + } + final Integer statusCode0 = getStatusCode0(); + if (statusCode0 != null) { + return HttpStatus.resolve(statusCode0); + } + return null; } @Override @@ -67,6 +74,7 @@ public boolean setRawStatusCode(@Nullable Integer statusCode) { @Override @Nullable public Integer getRawStatusCode() { - return statusCode; + final HttpStatus statusCode = getStatusCode(); + return statusCode != null ? statusCode.value() : null; } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java index efc41de27ce..2ee52c105d4 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java @@ -303,4 +303,7 @@ protected abstract Mono writeAndFlushWithInternal( */ protected void touchDataBuffer(DataBuffer buffer) { } + + @Nullable + abstract Integer getStatusCode0(); } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java index 46bde5e8008..e0c2d40b89b 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java @@ -50,7 +50,14 @@ public boolean setStatusCode(@Nullable HttpStatusCode status) { @Override @Nullable public HttpStatusCode getStatusCode() { - return statusCode; + if (statusCode != null) { + return statusCode; + } + final Integer statusCode0 = getStatusCode0(); + if (statusCode0 != null) { + return HttpStatusCode.valueOf(statusCode0); + } + return null; } @Override @@ -62,6 +69,7 @@ public boolean setRawStatusCode(@Nullable Integer statusCode) { @Override @Nullable public Integer getRawStatusCode() { + final HttpStatusCode statusCode = getStatusCode(); return statusCode != null ? statusCode.value() : null; } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java index f8dd3ae00e5..0b5f4282277 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java @@ -15,6 +15,7 @@ */ package com.linecorp.armeria.spring.web.reactive; +import static com.linecorp.armeria.common.logging.RequestLogProperty.RESPONSE_HEADERS; import static java.util.Objects.requireNonNull; import java.util.concurrent.CompletableFuture; @@ -28,6 +29,7 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.logging.RequestLog; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; @@ -70,6 +72,15 @@ Mono handle(ServiceRequestContext ctx, HttpRequest req, CompletableFuture< .doOnError(cause -> { logger.debug("{} Failed to handle a request", ctx, cause); convertedResponse.setComplete(cause).subscribe(); + }) + .doOnCancel(() -> { + // If Armeria has already returned a response (e.g. RequestTimeout) + // make a best effort to reflect this in the returned response + final RequestLog requestLog = ctx.log().getIfAvailable(RESPONSE_HEADERS); + if (requestLog != null) { + convertedResponse.setRawStatusCode(requestLog.responseStatus().code()); + convertedResponse.setComplete().subscribe(); + } }); } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java index ab8e630ecb1..f45246b87f4 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java @@ -120,6 +120,15 @@ protected void applyStatusCode() { } } + @Nullable + @Override + Integer getStatusCode0() { + if (future.isDone() && !future.isCompletedExceptionally()) { + return armeriaHeaders.status().code(); + } + return null; + } + @Override protected void applyHeaders() { getHeaders().forEach((name, values) -> armeriaHeaders.add(HttpHeaderNames.of(name), values)); diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java new file mode 100644 index 00000000000..02653219e39 --- /dev/null +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.spring.web.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationHandler; +import reactor.core.publisher.Mono; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ObservationTest { + + private static final AtomicReference ctxStatusRef = new AtomicReference<>(); + + @SpringBootApplication + @Configuration + static class TestConfiguration { + @RestController + static class TestController { + @GetMapping("/hello") + Mono hello(@RequestParam String mode) { + return switch (mode) { + case "throw" -> throw new RuntimeException("exception thrown"); + case "success" -> Mono.just("world"); + case "timeout" -> Mono.never(); + default -> throw new RuntimeException("Unexpected mode: " + mode); + }; + } + } + + @Bean + public ArmeriaServerConfigurator serverConfigurator() { + return sb -> sb.decorator(LoggingService.newDecorator()) + .requestTimeout(Duration.ofSeconds(1)); + } + + @Bean + public ObservationHandler observationHandler() { + return new ObservationHandler<>() { + @Override + public boolean supportsContext(Context context) { + return true; + } + + @Override + public void onStop(Context context) { + final KeyValue keyValue = context.getLowCardinalityKeyValue("status"); + if (keyValue != null) { + ctxStatusRef.set(keyValue.getValue()); + } + } + }; + } + } + + @LocalServerPort + int port; + + @BeforeEach + void beforeEach() { + ctxStatusRef.set(null); + } + + @Test + void ok() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=success"); + assertThat(response.status().code()).isEqualTo(200); + assertThat(response.contentUtf8()).isEqualTo("world"); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("200"); + } + + @Test + void throwsException() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=throw"); + assertThat(response.status().code()).isEqualTo(500); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("500"); + } + + @Test + void timesOut() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=timeout"); + assertThat(response.status().code()).isEqualTo(503); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("503"); + } +} From 2ea65f57525ee883c167837b79c30d044fa69854 Mon Sep 17 00:00:00 2001 From: Trustin Lee Date: Thu, 7 Nov 2024 11:13:54 +0900 Subject: [PATCH 02/12] Update GraalVM native image metadata (#5946) Motivation: GraalVM native image tool fails to generate a working binary since Armeria 1.30.0, due to its outdated metadata. Special thanks to @Dogacel who reported this issue. Modifications: - Updated the GraalVM native image metadata. - Removed the redundent `../../` from the resource lookup in `ThriftMetadatAccess`. Result: Armeria is again compatible with GraalVM native image tool. Co-authored-by: jrhee17 --- .../armeria/jni-config.json | 2 + .../armeria/reflect-config.json | 3337 +++++++++++++++-- .../armeria/resource-config.json | 10 + native-image-config/build.gradle.kts | 4 +- .../common/thrift/ThriftMetadataAccess.java | 2 +- 5 files changed, 3099 insertions(+), 256 deletions(-) diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json index 3d15324de66..e2716fa747a 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json @@ -145,6 +145,8 @@ "name" : "", "parameterTypes" : [ "long", "byte[][]", "java.lang.String", "io.netty.internal.tcnative.CertificateVerifier" ] } ] +}, { + "name" : "io.netty.internal.tcnative.Library" }, { "name" : "io.netty.internal.tcnative.NativeStaticallyReferencedJniMethods" }, { diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json index 7efa9b1a948..948f2671632 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json @@ -10,6 +10,8 @@ "name" : "[I" }, { "name" : "[J" +}, { + "name" : "[Lcom.fasterxml.jackson.databind.deser.BeanDeserializerModifier;" }, { "name" : "[Lcom.fasterxml.jackson.databind.deser.Deserializers;" }, { @@ -79,6 +81,12 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "ch.qos.logback.classic.joran.SerializedModelConfigurator", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "ch.qos.logback.classic.jul.LevelChangePropagator", "queryAllPublicMethods" : true, @@ -129,6 +137,12 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "ch.qos.logback.classic.util.DefaultJoranConfigurator", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "ch.qos.logback.core.Appender", "queryAllDeclaredMethods" : true, @@ -185,7 +199,11 @@ }, { "name" : "ch.qos.logback.core.spi.ContextAware", "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true + "queryAllDeclaredConstructors" : true, + "queriedMethods" : [ { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.String" ] + } ] }, { "name" : "ch.qos.logback.core.spi.ContextAwareBase", "queryAllDeclaredMethods" : true, @@ -254,6 +272,293 @@ "name" : "com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider", "allDeclaredFields" : true, "queryAllDeclaredMethods" : true +}, { + "name" : "com.google.api.CustomHttpPattern", + "queriedMethods" : [ { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpBody", + "methods" : [ { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getContentType", + "parameterTypes" : [ ] + }, { + "name" : "getContentTypeBytes", + "parameterTypes" : [ ] + }, { + "name" : "getData", + "parameterTypes" : [ ] + }, { + "name" : "getDefaultInstance", + "parameterTypes" : [ ] + }, { + "name" : "getExtensions", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsCount", + "parameterTypes" : [ ] + }, { + "name" : "getExtensionsList", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpBody$Builder", + "queriedMethods" : [ { + "name" : "addExtensions", + "parameterTypes" : [ "com.google.protobuf.Any" ] + }, { + "name" : "clearContentType", + "parameterTypes" : [ ] + }, { + "name" : "clearData", + "parameterTypes" : [ ] + }, { + "name" : "clearExtensions", + "parameterTypes" : [ ] + }, { + "name" : "getContentType", + "parameterTypes" : [ ] + }, { + "name" : "getData", + "parameterTypes" : [ ] + }, { + "name" : "getExtensions", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsBuilder", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsCount", + "parameterTypes" : [ ] + }, { + "name" : "getExtensionsList", + "parameterTypes" : [ ] + }, { + "name" : "setContentType", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setContentTypeBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setData", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setExtensions", + "parameterTypes" : [ "int", "com.google.protobuf.Any" ] + } ] +}, { + "name" : "com.google.api.HttpRule", + "methods" : [ { + "name" : "getAdditionalBindingsList", + "parameterTypes" : [ ] + }, { + "name" : "getBody", + "parameterTypes" : [ ] + }, { + "name" : "getPatternCase", + "parameterTypes" : [ ] + }, { + "name" : "getPost", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "getSelector", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getAdditionalBindings", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsCount", + "parameterTypes" : [ ] + }, { + "name" : "getBodyBytes", + "parameterTypes" : [ ] + }, { + "name" : "getCustom", + "parameterTypes" : [ ] + }, { + "name" : "getDelete", + "parameterTypes" : [ ] + }, { + "name" : "getDeleteBytes", + "parameterTypes" : [ ] + }, { + "name" : "getGet", + "parameterTypes" : [ ] + }, { + "name" : "getGetBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPatch", + "parameterTypes" : [ ] + }, { + "name" : "getPatchBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPostBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPut", + "parameterTypes" : [ ] + }, { + "name" : "getPutBytes", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBodyBytes", + "parameterTypes" : [ ] + }, { + "name" : "getSelectorBytes", + "parameterTypes" : [ ] + }, { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpRule$Builder", + "queriedMethods" : [ { + "name" : "addAdditionalBindings", + "parameterTypes" : [ "com.google.api.HttpRule" ] + }, { + "name" : "clearAdditionalBindings", + "parameterTypes" : [ ] + }, { + "name" : "clearBody", + "parameterTypes" : [ ] + }, { + "name" : "clearCustom", + "parameterTypes" : [ ] + }, { + "name" : "clearDelete", + "parameterTypes" : [ ] + }, { + "name" : "clearGet", + "parameterTypes" : [ ] + }, { + "name" : "clearPatch", + "parameterTypes" : [ ] + }, { + "name" : "clearPattern", + "parameterTypes" : [ ] + }, { + "name" : "clearPost", + "parameterTypes" : [ ] + }, { + "name" : "clearPut", + "parameterTypes" : [ ] + }, { + "name" : "clearResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "clearSelector", + "parameterTypes" : [ ] + }, { + "name" : "getAdditionalBindings", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsBuilder", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsCount", + "parameterTypes" : [ ] + }, { + "name" : "getAdditionalBindingsList", + "parameterTypes" : [ ] + }, { + "name" : "getBody", + "parameterTypes" : [ ] + }, { + "name" : "getCustom", + "parameterTypes" : [ ] + }, { + "name" : "getCustomBuilder", + "parameterTypes" : [ ] + }, { + "name" : "getDelete", + "parameterTypes" : [ ] + }, { + "name" : "getGet", + "parameterTypes" : [ ] + }, { + "name" : "getPatch", + "parameterTypes" : [ ] + }, { + "name" : "getPatternCase", + "parameterTypes" : [ ] + }, { + "name" : "getPost", + "parameterTypes" : [ ] + }, { + "name" : "getPut", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "getSelector", + "parameterTypes" : [ ] + }, { + "name" : "setAdditionalBindings", + "parameterTypes" : [ "int", "com.google.api.HttpRule" ] + }, { + "name" : "setBody", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setBodyBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setCustom", + "parameterTypes" : [ "com.google.api.CustomHttpPattern" ] + }, { + "name" : "setDelete", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setDeleteBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setGet", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setGetBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPatch", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPatchBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPost", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPostBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPut", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPutBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setResponseBody", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setResponseBodyBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setSelector", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setSelectorBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + } ] }, { "name" : "com.google.common.util.concurrent.AbstractFuture", "fields" : [ { @@ -2132,6 +2437,13 @@ } ] }, { "name" : "com.linecorp.armeria.client.Bootstraps$1" +}, { + "name" : "com.linecorp.armeria.client.DefaultEventLoopScheduler", + "fields" : [ { + "name" : "acquisitionStartIndex" + }, { + "name" : "lastCleanupTimeNanos" + } ] }, { "name" : "com.linecorp.armeria.client.Http1ResponseDecoder", "queriedMethods" : [ { @@ -2197,6 +2509,12 @@ "name" : "channelActive", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext" ] } ] +}, { + "name" : "com.linecorp.armeria.client.HttpClientPipelineConfigurator$ClientSslHandler", + "queriedMethods" : [ { + "name" : "channelActive", + "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext" ] + } ] }, { "name" : "com.linecorp.armeria.client.HttpClientPipelineConfigurator$DowngradeHandler" }, { @@ -2223,6 +2541,21 @@ "name" : "userEventTriggered", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object" ] } ] +}, { + "name" : "com.linecorp.armeria.client.retrofit2.ArmeriaCallFactory$ArmeriaCall", + "fields" : [ { + "name" : "executionState" + } ] +}, { + "name" : "com.linecorp.armeria.common.CompletableRpcResponse", + "fields" : [ { + "name" : "cause" + } ] +}, { + "name" : "com.linecorp.armeria.common.DefaultConcurrentAttributes", + "fields" : [ { + "name" : "attributes" + } ] }, { "name" : "com.linecorp.armeria.common.HttpHeaderNames", "allDeclaredFields" : true @@ -2238,6 +2571,8 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.common.ResponseEntity" }, { "name" : "com.linecorp.armeria.common.annotation.Nullable", "queryAllPublicMethods" : true @@ -2283,38 +2618,107 @@ "parameterTypes" : [ "java.lang.String" ] } ] }, { - "name" : "com.linecorp.armeria.common.sse.ServerSentEvent" + "name" : "com.linecorp.armeria.common.logging.DefaultRequestLog", + "fields" : [ { + "name" : "deferredFlags" + }, { + "name" : "flags" + } ] }, { - "name" : "com.linecorp.armeria.common.stream.AbortedStreamException", + "name" : "com.linecorp.armeria.common.multipart.MultipartDecoder", "fields" : [ { - "name" : "INSTANCE" + "name" : "delegatedSubscriber" } ] }, { - "name" : "com.linecorp.armeria.common.util.Version", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "artifactId", - "parameterTypes" : [ ] - }, { - "name" : "artifactVersion", - "parameterTypes" : [ ] - }, { - "name" : "commitTimeMillis", - "parameterTypes" : [ ] - }, { - "name" : "longCommitHash", - "parameterTypes" : [ ] - }, { - "name" : "repositoryStatus", - "parameterTypes" : [ ] + "name" : "com.linecorp.armeria.common.multipart.MultipartEncoder", + "fields" : [ { + "name" : "completionFuture" }, { - "name" : "shortCommitHash", - "parameterTypes" : [ ] + "name" : "subscribed" } ] }, { - "name" : "com.linecorp.armeria.common.zookeeper.ServerSetsInstanceConverter$FinagleServiceInstanceDeserializer", + "name" : "com.linecorp.armeria.common.sse.ServerSentEvent" +}, { + "name" : "com.linecorp.armeria.common.stream.AbortedStreamException", + "fields" : [ { + "name" : "INSTANCE" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.AggregationSupport", + "fields" : [ { + "name" : "aggregation" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatArrayStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatArrayStreamMessage$ConcatArraySubscriber", + "fields" : [ { + "name" : "cancelled" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatPublisherStreamMessage", + "fields" : [ { + "name" : "outerSubscriber" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatPublisherStreamMessage$InnerSubscriber", + "fields" : [ { + "name" : "cancelled" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.DefaultStreamMessage", + "fields" : [ { + "name" : "state" + }, { + "name" : "subscription" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.DeferredStreamMessage", + "fields" : [ { + "name" : "abortCause" + }, { + "name" : "collectingFuture" + }, { + "name" : "downstreamSubscription" + }, { + "name" : "subscribedToUpstream" + }, { + "name" : "upstream" + } ] +}, { + "name" : "com.linecorp.armeria.common.util.AsyncCloseableSupport", + "fields" : [ { + "name" : "closing" + } ] +}, { + "name" : "com.linecorp.armeria.common.util.Version", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "artifactId", + "parameterTypes" : [ ] + }, { + "name" : "artifactVersion", + "parameterTypes" : [ ] + }, { + "name" : "commitTimeMillis", + "parameterTypes" : [ ] + }, { + "name" : "longCommitHash", + "parameterTypes" : [ ] + }, { + "name" : "repositoryStatus", + "parameterTypes" : [ ] + }, { + "name" : "shortCommitHash", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.linecorp.armeria.common.zookeeper.ServerSetsInstanceConverter$FinagleServiceInstanceDeserializer", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -2325,12 +2729,31 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.internal.client.DefaultClientRequestContext", + "fields" : [ { + "name" : "additionalRequestHeaders" + }, { + "name" : "whenInitialized" + } ] +}, { + "name" : "com.linecorp.armeria.internal.client.grpc.ArmeriaClientCall", + "fields" : [ { + "name" : "pendingMessages" + }, { + "name" : "pendingTask" + } ] }, { "name" : "com.linecorp.armeria.internal.common.AbstractHttp2ConnectionHandler", "queriedMethods" : [ { "name" : "close", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "io.netty.channel.ChannelPromise" ] } ] +}, { + "name" : "com.linecorp.armeria.internal.common.NonWrappingRequestContext", + "fields" : [ { + "name" : "contextHook" + } ] }, { "name" : "com.linecorp.armeria.internal.common.ReadSuppressingHandler", "queriedMethods" : [ { @@ -2538,6 +2961,23 @@ "fields" : [ { "name" : "label" } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.AbortedStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.FixedStreamMessage", + "fields" : [ { + "name" : "abortCause" + }, { + "name" : "executor" + } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.RecoverableStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] }, { "name" : "com.linecorp.armeria.internal.consul.AgentServiceClient$Service", "allDeclaredFields" : true, @@ -2654,16 +3094,31 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.internal.server.DefaultServiceRequestContext", + "fields" : [ { + "name" : "additionalResponseHeaders" + }, { + "name" : "additionalResponseTrailers" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.COMPOSITE$Mappings" }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DH$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSTU4145$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.EC$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.EC$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.ECGOST$Mappings" }, { @@ -2677,9 +3132,23 @@ }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.IES$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.X509$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.X509$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA256", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.x509.CertificateFactory", "methods" : [ { @@ -3019,11 +3488,34 @@ }, { "name" : "thread" } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.guava.util.concurrent.AggregateFutureState", + "fields" : [ { + "name" : "remaining" + }, { + "name" : "seenExceptions" + } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.ConcurrentAutoTable", + "fields" : [ { + "name" : "_cat" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.NonBlockingHashMap", "fields" : [ { "name" : "_kvs" } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.NonBlockingHashMap$CHM", + "fields" : [ { + "name" : "_copyDone" + }, { + "name" : "_copyIdx" + }, { + "name" : "_newkvs" + }, { + "name" : "_resizers" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.jctools.queues.BaseMpscLinkedArrayQueueColdProducerFields", "fields" : [ { @@ -3063,6 +3555,11 @@ "name" : "patternString", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.server.DefaultUnloggedExceptionsReporter", + "fields" : [ { + "name" : "scheduled" + } ] }, { "name" : "com.linecorp.armeria.server.Http1RequestDecoder", "queriedMethods" : [ { @@ -3096,6 +3593,8 @@ "name" : "userEventTriggered", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object" ] } ] +}, { + "name" : "com.linecorp.armeria.server.HttpServerCodec" }, { "name" : "com.linecorp.armeria.server.HttpServerHandler", "queriedMethods" : [ { @@ -3517,6 +4016,11 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.server.grpc.StreamingServerCall", + "fields" : [ { + "name" : "pendingMessages" + } ] }, { "name" : "com.linecorp.armeria.server.protobuf.ProtobufRequestConverterFunction", "methods" : [ { @@ -3932,6 +4436,11 @@ } ] }, { "name" : "io.grpc.internal.PickFirstLoadBalancerProvider" +}, { + "name" : "io.grpc.internal.SerializingExecutor", + "fields" : [ { + "name" : "runState" + } ] }, { "name" : "io.grpc.kotlin.AbstractCoroutineServerImpl", "queryAllDeclaredMethods" : true @@ -3973,6 +4482,16 @@ }, { "name" : "io.micrometer.core.instrument.DistributionSummary$Builder", "queryAllPublicMethods" : true +}, { + "name" : "io.micrometer.core.instrument.distribution.AbstractTimeWindowHistogram", + "fields" : [ { + "name" : "rotating" + } ] +}, { + "name" : "io.micrometer.core.instrument.distribution.TimeWindowMax", + "fields" : [ { + "name" : "rotating" + } ] }, { "name" : "io.netty.bootstrap.ServerBootstrap$1" }, { @@ -3992,6 +4511,11 @@ "fields" : [ { "name" : "refCnt" } ] +}, { + "name" : "io.netty.channel.AbstractChannelHandlerContext", + "fields" : [ { + "name" : "handlerState" + } ] }, { "name" : "io.netty.channel.ChannelDuplexHandler", "queriedMethods" : [ { @@ -4064,6 +4588,13 @@ "name" : "exceptionCaught", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Throwable" ] } ] +}, { + "name" : "io.netty.channel.ChannelOutboundBuffer", + "fields" : [ { + "name" : "totalPendingSize" + }, { + "name" : "unwritable" + } ] }, { "name" : "io.netty.channel.ChannelOutboundHandlerAdapter", "queriedMethods" : [ { @@ -4145,6 +4676,18 @@ "name" : "write", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object", "io.netty.channel.ChannelPromise" ] } ] +}, { + "name" : "io.netty.channel.DefaultChannelConfig", + "fields" : [ { + "name" : "autoRead" + }, { + "name" : "writeBufferWaterMark" + } ] +}, { + "name" : "io.netty.channel.DefaultChannelPipeline", + "fields" : [ { + "name" : "estimatorHandle" + } ] }, { "name" : "io.netty.channel.DefaultChannelPipeline$HeadContext", "queriedMethods" : [ { @@ -4346,6 +4889,11 @@ "name" : "io.netty.channel.unix.DatagramSocketAddress" }, { "name" : "io.netty.channel.unix.DomainDatagramSocketAddress" +}, { + "name" : "io.netty.channel.unix.FileDescriptor", + "fields" : [ { + "name" : "state" + } ] }, { "name" : "io.netty.channel.unix.PeerCredentials" }, { @@ -4591,6 +5139,11 @@ "name" : "io.netty.internal.tcnative.SSLPrivateKeyMethodTask" }, { "name" : "io.netty.internal.tcnative.SSLTask" +}, { + "name" : "io.netty.resolver.dns.Cache$Entries", + "fields" : [ { + "name" : "expirationFuture" + } ] }, { "name" : "io.netty.resolver.dns.DnsNameResolver", "methods" : [ { @@ -4631,9 +5184,43 @@ "fields" : [ { "name" : "refCnt" } ] +}, { + "name" : "io.netty.util.DefaultAttributeMap", + "fields" : [ { + "name" : "attributes" + } ] +}, { + "name" : "io.netty.util.DefaultAttributeMap$DefaultAttribute", + "fields" : [ { + "name" : "attributeMap" + } ] +}, { + "name" : "io.netty.util.Recycler$DefaultHandle", + "fields" : [ { + "name" : "state" + } ] }, { "name" : "io.netty.util.ReferenceCountUtil", "queryAllDeclaredMethods" : true +}, { + "name" : "io.netty.util.ResourceLeakDetector$DefaultResourceLeak", + "fields" : [ { + "name" : "droppedRecords" + }, { + "name" : "head" + } ] +}, { + "name" : "io.netty.util.concurrent.DefaultPromise", + "fields" : [ { + "name" : "result" + } ] +}, { + "name" : "io.netty.util.concurrent.SingleThreadEventExecutor", + "fields" : [ { + "name" : "state" + }, { + "name" : "threadProperties" + } ] }, { "name" : "io.netty.util.internal.NativeLibraryUtil", "methods" : [ { @@ -4709,287 +5296,1903 @@ }, { "name" : "java.lang.CharSequence", "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.Character", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.Class", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "methods" : [ { - "name" : "getAnnotatedSuperclass", - "parameterTypes" : [ ] + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] }, { - "name" : "getModule", - "parameterTypes" : [ ] + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] }, { - "name" : "getRecordComponents", - "parameterTypes" : [ ] + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] }, { - "name" : "isRecord", - "parameterTypes" : [ ] + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] }, { - "name" : "isSealed", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.ClassLoader", - "methods" : [ { - "name" : "registerAsParallelCapable", - "parameterTypes" : [ ] - } ], - "queriedMethods" : [ { - "name" : "getDefinedPackage", + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compare", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "getUnnamedModule", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.ClassValue" -}, { - "name" : "java.lang.Comparable", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.Deprecated", - "queryAllDeclaredMethods" : true, - "queryAllPublicMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "forRemoval", - "parameterTypes" : [ ] + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "since", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Double", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.Exception", - "allDeclaredFields" : true -}, { - "name" : "java.lang.Float", - "fields" : [ { - "name" : "TYPE" - } ], - "queriedMethods" : [ { - "name" : "", + "name" : "concat", "parameterTypes" : [ "java.lang.String" ] - } ] -}, { - "name" : "java.lang.IllegalArgumentException" -}, { - "name" : "java.lang.Integer", - "fields" : [ { - "name" : "TYPE" - } ], - "methods" : [ { - "name" : "", + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", "parameterTypes" : [ "java.lang.String" ] - } ], - "queriedMethods" : [ { - "name" : "toString", + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", "parameterTypes" : [ "int" ] } ] }, { - "name" : "java.lang.Iterable", - "queryAllDeclaredMethods" : true -}, { - "name" : "java.lang.Long", + "name" : "java.lang.Character", "fields" : [ { "name" : "TYPE" - } ], - "queriedMethods" : [ { - "name" : "toString", - "parameterTypes" : [ "long" ] } ] }, { - "name" : "java.lang.Module", + "name" : "java.lang.Class", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, "methods" : [ { - "name" : "getDescriptor", + "name" : "getAnnotatedSuperclass", "parameterTypes" : [ ] }, { - "name" : "isExported", - "parameterTypes" : [ "java.lang.String" ] - } ], - "queriedMethods" : [ { - "name" : "addExports", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] - }, { - "name" : "canRead", - "parameterTypes" : [ "java.lang.Module" ] + "name" : "getModule", + "parameterTypes" : [ ] }, { - "name" : "getClassLoader", + "name" : "getRecordComponents", "parameterTypes" : [ ] }, { - "name" : "getName", + "name" : "isRecord", "parameterTypes" : [ ] }, { - "name" : "getPackages", + "name" : "isSealed", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getAnnotatedInterfaces", "parameterTypes" : [ ] }, { - "name" : "getResourceAsStream", - "parameterTypes" : [ "java.lang.String" ] + "name" : "getDeclaredMethod", + "parameterTypes" : [ "java.lang.String", "java.lang.Class[]" ] }, { - "name" : "isExported", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + "name" : "getMethod", + "parameterTypes" : [ "java.lang.String", "java.lang.Class[]" ] }, { - "name" : "isNamed", + "name" : "getNestHost", "parameterTypes" : [ ] }, { - "name" : "isOpen", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] - } ] -}, { - "name" : "java.lang.NullPointerException" -}, { - "name" : "java.lang.Object", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "", + "name" : "getNestMembers", "parameterTypes" : [ ] }, { - "name" : "toString", + "name" : "getPermittedSubclasses", "parameterTypes" : [ ] + }, { + "name" : "isNestmateOf", + "parameterTypes" : [ "java.lang.Class" ] } ] }, { - "name" : "java.lang.OutOfMemoryError" -}, { - "name" : "java.lang.ProcessHandle", + "name" : "java.lang.ClassLoader", "methods" : [ { - "name" : "current", - "parameterTypes" : [ ] + "name" : "getDefinedPackage", + "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "pid", + "name" : "registerAsParallelCapable", "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Runtime", - "methods" : [ { - "name" : "version", + } ], + "queriedMethods" : [ { + "name" : "getUnnamedModule", "parameterTypes" : [ ] } ] }, { - "name" : "java.lang.Runtime$Version", - "methods" : [ { - "name" : "feature", - "parameterTypes" : [ ] - } ] + "name" : "java.lang.ClassValue" }, { - "name" : "java.lang.RuntimeException" + "name" : "java.lang.Comparable", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] + } ] }, { - "name" : "java.lang.RuntimePermission" + "name" : "java.lang.Deprecated", + "queryAllDeclaredMethods" : true, + "queryAllPublicMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "forRemoval", + "parameterTypes" : [ ] + }, { + "name" : "since", + "parameterTypes" : [ ] + } ] }, { - "name" : "java.lang.SecurityManager", + "name" : "java.lang.Double", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.Exception", + "allDeclaredFields" : true +}, { + "name" : "java.lang.Float", + "fields" : [ { + "name" : "TYPE" + } ], "queriedMethods" : [ { - "name" : "checkPermission", - "parameterTypes" : [ "java.security.Permission" ] + "name" : "", + "parameterTypes" : [ "java.lang.String" ] } ] }, { - "name" : "java.lang.Short", + "name" : "java.lang.IllegalArgumentException" +}, { + "name" : "java.lang.Integer", "fields" : [ { "name" : "TYPE" + } ], + "methods" : [ { + "name" : "", + "parameterTypes" : [ "java.lang.String" ] + } ], + "queriedMethods" : [ { + "name" : "toString", + "parameterTypes" : [ "int" ] } ] }, { - "name" : "java.lang.StackTraceElement", - "queryAllPublicMethods" : true + "name" : "java.lang.Iterable", + "queryAllDeclaredMethods" : true }, { - "name" : "java.lang.StackWalker", + "name" : "java.lang.Long", + "fields" : [ { + "name" : "TYPE" + } ], + "queriedMethods" : [ { + "name" : "toString", + "parameterTypes" : [ "long" ] + } ] +}, { + "name" : "java.lang.Module", "methods" : [ { - "name" : "getInstance", - "parameterTypes" : [ "java.lang.StackWalker$Option" ] + "name" : "getDescriptor", + "parameterTypes" : [ ] + }, { + "name" : "isExported", + "parameterTypes" : [ "java.lang.String" ] + } ], + "queriedMethods" : [ { + "name" : "addExports", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + }, { + "name" : "canRead", + "parameterTypes" : [ "java.lang.Module" ] + }, { + "name" : "getClassLoader", + "parameterTypes" : [ ] + }, { + "name" : "getName", + "parameterTypes" : [ ] + }, { + "name" : "getPackages", + "parameterTypes" : [ ] + }, { + "name" : "getResourceAsStream", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "isExported", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + }, { + "name" : "isNamed", + "parameterTypes" : [ ] + }, { + "name" : "isOpen", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + } ] +}, { + "name" : "java.lang.NullPointerException" +}, { + "name" : "java.lang.Object", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + }, { + "name" : "toString", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.OutOfMemoryError" +}, { + "name" : "java.lang.ProcessHandle", + "methods" : [ { + "name" : "current", + "parameterTypes" : [ ] + }, { + "name" : "pid", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Runtime", + "methods" : [ { + "name" : "version", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Runtime$Version", + "methods" : [ { + "name" : "feature", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.RuntimeException" +}, { + "name" : "java.lang.RuntimePermission" +}, { + "name" : "java.lang.SecurityManager", + "queriedMethods" : [ { + "name" : "checkPermission", + "parameterTypes" : [ "java.security.Permission" ] + } ] +}, { + "name" : "java.lang.Short", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.StackTraceElement", + "queryAllPublicMethods" : true +}, { + "name" : "java.lang.StackWalker", + "methods" : [ { + "name" : "getInstance", + "parameterTypes" : [ "java.lang.StackWalker$Option" ] + }, { + "name" : "walk", + "parameterTypes" : [ "java.util.function.Function" ] + } ] +}, { + "name" : "java.lang.StackWalker$Option" +}, { + "name" : "java.lang.StackWalker$StackFrame", + "methods" : [ { + "name" : "getDeclaringClass", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.String", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "fields" : [ { + "name" : "TYPE" + } ], + "methods" : [ { + "name" : "", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + } ] +}, { + "name" : "java.lang.System", + "methods" : [ { + "name" : "getSecurityManager", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Thread", + "fields" : [ { + "name" : "threadLocalRandomProbe" + } ] +}, { + "name" : "java.lang.Throwable", + "allDeclaredFields" : true, + "methods" : [ { + "name" : "getSuppressed", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "addSuppressed", + "parameterTypes" : [ "java.lang.Throwable" ] + } ] +}, { + "name" : "java.lang.Void", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.annotation.Inherited", + "queryAllPublicMethods" : true +}, { + "name" : "java.lang.annotation.Repeatable", + "queryAllPublicMethods" : true, + "methods" : [ { + "name" : "value", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.annotation.Retention", + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "value", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.annotation.RetentionPolicy" +}, { + "name" : "java.lang.annotation.Target", + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true +}, { + "name" : "java.lang.constant.Constable", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] + } ] +}, { + "name" : "java.lang.constant.ConstantDesc", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] }, { - "name" : "walk", - "parameterTypes" : [ "java.util.function.Function" ] - } ] -}, { - "name" : "java.lang.StackWalker$Option" -}, { - "name" : "java.lang.StackWalker$StackFrame", - "methods" : [ { - "name" : "getDeclaringClass", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.String", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "", + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] }, { "name" : "valueOf", "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] } ] -}, { - "name" : "java.lang.System", - "methods" : [ { - "name" : "getSecurityManager", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Thread", - "fields" : [ { - "name" : "threadLocalRandomProbe" - } ] -}, { - "name" : "java.lang.Throwable", - "allDeclaredFields" : true, - "methods" : [ { - "name" : "getSuppressed", - "parameterTypes" : [ ] - } ], - "queriedMethods" : [ { - "name" : "addSuppressed", - "parameterTypes" : [ "java.lang.Throwable" ] - } ] -}, { - "name" : "java.lang.Void", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.annotation.Inherited", - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.annotation.Repeatable", - "queryAllPublicMethods" : true, - "methods" : [ { - "name" : "value", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.annotation.Retention", - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "value", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.annotation.RetentionPolicy" -}, { - "name" : "java.lang.annotation.Target", - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true -}, { - "name" : "java.lang.constant.Constable", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.constant.ConstantDesc", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true }, { "name" : "java.lang.invoke.MethodHandle", "queriedMethods" : [ { @@ -5211,6 +7414,12 @@ "name" : "", "parameterTypes" : [ "java.lang.String", "java.lang.String" ] } ] +}, { + "name" : "java.net.UnixDomainSocketAddress", + "methods" : [ { + "name" : "of", + "parameterTypes" : [ "java.lang.String" ] + } ] }, { "name" : "java.nio.Bits", "fields" : [ { @@ -5382,12 +7591,34 @@ "fields" : [ { "name" : "e" } ] +}, { + "name" : "java.util.concurrent.ForkJoinTask", + "fields" : [ { + "name" : "aux" + }, { + "name" : "status" + } ] }, { "name" : "java.util.concurrent.ScheduledThreadPoolExecutor", "queriedMethods" : [ { "name" : "setRemoveOnCancelPolicy", "parameterTypes" : [ "boolean" ] } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicBoolean", + "fields" : [ { + "name" : "value" + } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicMarkableReference", + "fields" : [ { + "name" : "pair" + } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicReference", + "fields" : [ { + "name" : "value" + } ] }, { "name" : "java.util.concurrent.atomic.LongAdder", "queryAllPublicConstructors" : true, @@ -5402,6 +7633,13 @@ "name" : "sum", "parameterTypes" : [ ] } ] +}, { + "name" : "java.util.concurrent.atomic.Striped64", + "fields" : [ { + "name" : "base" + }, { + "name" : "cellsBusy" + } ] }, { "name" : "java.util.logging.LogManager", "methods" : [ { @@ -5551,6 +7789,11 @@ } ] }, { "name" : "kotlin.Nothing" +}, { + "name" : "kotlin.SafePublicationLazyImpl", + "fields" : [ { + "name" : "_value" + } ] }, { "name" : "kotlin.String" }, { @@ -5587,6 +7830,11 @@ "fields" : [ { "name" : "label" } ] +}, { + "name" : "kotlin.reflect.full.KCallables$callSuspendBy$1", + "fields" : [ { + "name" : "label" + } ] }, { "name" : "kotlin.reflect.full.KClasses" }, { @@ -5598,6 +7846,81 @@ }, { "name" : "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter", "allPublicFields" : true +}, { + "name" : "kotlinx.coroutines.CancellableContinuationImpl", + "fields" : [ { + "name" : "_decisionAndIndex$volatile" + }, { + "name" : "_parentHandle$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.CancelledContinuation", + "fields" : [ { + "name" : "_resumed$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.CompletedExceptionally", + "fields" : [ { + "name" : "_handled$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.DispatchedCoroutine", + "fields" : [ { + "name" : "_decision$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.EventLoopImplBase", + "fields" : [ { + "name" : "_delayed$volatile" + }, { + "name" : "_isCompleted$volatile" + }, { + "name" : "_queue$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.InvokeOnCancelling", + "fields" : [ { + "name" : "_invoked$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.JobSupport", + "fields" : [ { + "name" : "_parentHandle$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.JobSupport$Finishing", + "fields" : [ { + "name" : "_exceptionsHolder$volatile" + }, { + "name" : "_isCompleting$volatile" + }, { + "name" : "_rootCause$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.channels.BufferedChannel", + "fields" : [ { + "name" : "_closeCause$volatile" + }, { + "name" : "bufferEnd$volatile" + }, { + "name" : "bufferEndSegment$volatile" + }, { + "name" : "closeHandler$volatile" + }, { + "name" : "completedExpandBuffersAndPauseFlag$volatile" + }, { + "name" : "receiveSegment$volatile" + }, { + "name" : "receivers$volatile" + }, { + "name" : "sendSegment$volatile" + }, { + "name" : "sendersAndCloseStatus$volatile" + } ] }, { "name" : "kotlinx.coroutines.flow.AbstractFlow$collect$1", "fields" : [ { @@ -5606,7 +7929,103 @@ }, { "name" : "kotlinx.coroutines.flow.Flow" }, { - "name" : "kotlinx.coroutines.internal.StackTraceRecoveryKt" + "name" : "kotlinx.coroutines.internal.AtomicOp", + "fields" : [ { + "name" : "_consensus$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.ConcurrentLinkedListNode", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_prev$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.DispatchedContinuation", + "fields" : [ { + "name" : "_reusableCancellableContinuation$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LimitedDispatcher", + "fields" : [ { + "name" : "runningWorkers$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeLinkedListNode", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_prev$volatile" + }, { + "name" : "_removedRef$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeTaskQueue", + "fields" : [ { + "name" : "_cur$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeTaskQueueCore", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.Segment", + "fields" : [ { + "name" : "cleanedAndPointers$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.StackTraceRecoveryKt" +}, { + "name" : "kotlinx.coroutines.internal.ThreadSafeHeap", + "fields" : [ { + "name" : "_size$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.CoroutineScheduler", + "fields" : [ { + "name" : "_isTerminated$volatile" + }, { + "name" : "controlState$volatile" + }, { + "name" : "parkedWorkersStack$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker", + "fields" : [ { + "name" : "workerCtl$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.WorkQueue", + "fields" : [ { + "name" : "blockingTasksInBuffer$volatile" + }, { + "name" : "consumerIndex$volatile" + }, { + "name" : "lastScheduledTask$volatile" + }, { + "name" : "producerIndex$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.sync.MutexImpl", + "fields" : [ { + "name" : "owner$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.sync.SemaphoreImpl", + "fields" : [ { + "name" : "_availablePermits$volatile" + }, { + "name" : "deqIdx$volatile" + }, { + "name" : "enqIdx$volatile" + }, { + "name" : "head$volatile" + }, { + "name" : "tail$volatile" + } ] }, { "name" : "net.bytebuddy.description.method.MethodDescription$InDefinedShape$AbstractBase$Executable", "queryAllPublicMethods" : true @@ -5803,8 +8222,18 @@ }, { "name" : "net.bytebuddy.utility.JavaModule$Resolver", "queryAllPublicMethods" : true +}, { + "name" : "org.HdrHistogram.AbstractHistogram", + "fields" : [ { + "name" : "maxValue" + }, { + "name" : "minNonZeroValue" + } ] }, { "name" : "org.HdrHistogram.ConcurrentHistogram", + "fields" : [ { + "name" : "totalCount" + } ], "methods" : [ { "name" : "", "parameterTypes" : [ "long", "long", "int" ] @@ -5818,6 +8247,15 @@ "name" : "", "parameterTypes" : [ "long", "long", "int" ] } ] +}, { + "name" : "org.HdrHistogram.WriterReaderPhaser", + "fields" : [ { + "name" : "evenEndEpoch" + }, { + "name" : "oddEndEpoch" + }, { + "name" : "startEpoch" + } ] }, { "name" : "org.apache.curator.shaded.com.google.common.collect.ImmutableCollection", "allDeclaredFields" : true, @@ -6014,6 +8452,9 @@ }, { "name" : "checkObjectEnd", "parameterTypes" : [ "com.fasterxml.jackson.core.JsonToken" ] + }, { + "name" : "fieldNamesEqual", + "parameterTypes" : [ "com.fasterxml.jackson.core.JsonParser", "java.lang.String", "java.lang.String" ] }, { "name" : "mapUnknownEnumValue", "parameterTypes" : [ "int" ] @@ -6164,6 +8605,9 @@ "name" : "org.curioswitch.common.protobuf.json.TypeSpecificMarshaller$org$curioswitch$common$protobuf$json$TypeSpecificMarshaller$buildOrFindMarshaller$ByteBuddy", "allDeclaredFields" : true, "methods" : [ { + "name" : "", + "parameterTypes" : [ "com.google.api.HttpBody" ] + }, { "name" : "", "parameterTypes" : [ "com.google.rpc.BadRequest$FieldViolation" ] }, { @@ -6259,6 +8703,12 @@ }, { "name" : "", "parameterTypes" : [ "testing.grpc.Messages$TestMessage" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$ArbitraryHttpWrappedRequest" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$ArbitraryHttpWrappedResponse" ] }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoAnyRequest" ] @@ -6307,6 +8757,12 @@ }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampAndDurationResponse" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampRequest" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampResponse" ] }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoValueRequest" ] @@ -12681,8 +15137,317 @@ "name" : "log", "parameterTypes" : [ "org.slf4j.Marker", "java.lang.String", "int", "java.lang.String", "java.lang.Object[]", "java.lang.Throwable" ] } ] +}, { + "name" : "reactor.core.publisher.FluxArray$ArraySubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxAutoConnect", + "fields" : [ { + "name" : "remaining" + } ] +}, { + "name" : "reactor.core.publisher.FluxCombineLatest$CombineLatestCoordinator", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxCombineLatest$CombineLatestInner", + "fields" : [ { + "name" : "s" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatArray$ConcatArrayDelayErrorSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatArray$ConcatArraySubscriber", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatMapNoPrefetch$FluxConcatMapNoPrefetchSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$BaseSink", + "fields" : [ { + "name" : "disposable" + }, { + "name" : "requestConsumer" + }, { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$BufferAsyncSink", + "fields" : [ { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$SerializedFluxSink", + "fields" : [ { + "name" : "error" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxFirstWithSignal$RaceCoordinator", + "fields" : [ { + "name" : "winner" + } ] +}, { + "name" : "reactor.core.publisher.FluxGenerate$GenerateSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxInterval$IntervalRunnable", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxIterable$IterableSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxLimitRequest$FluxLimitRequestSubscriber", + "fields" : [ { + "name" : "requestRemaining" + } ] +}, { + "name" : "reactor.core.publisher.FluxMergeSequential$MergeSequentialInner", + "fields" : [ { + "name" : "subscription" + } ] +}, { + "name" : "reactor.core.publisher.FluxMergeSequential$MergeSequentialMain", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish", + "fields" : [ { + "name" : "connection" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish$PubSubInner", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish$PublishSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "state" + }, { + "name" : "subscribers" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublishOn$PublishOnSubscriber", + "fields" : [ { + "name" : "discardGuard" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxSwitchMapNoPrefetch$SwitchMapMain", + "fields" : [ { + "name" : "requested" + }, { + "name" : "state" + }, { + "name" : "throwable" + } ] +}, { + "name" : "reactor.core.publisher.FluxZip$ZipScalarCoordinator", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.LambdaSubscriber", + "fields" : [ { + "name" : "subscription" + } ] }, { "name" : "reactor.core.publisher.Mono" +}, { + "name" : "reactor.core.publisher.MonoCallable$MonoCallableSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoCompletionStage$MonoCompletionStageSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoCreate$DefaultMonoSink", + "fields" : [ { + "name" : "disposable" + }, { + "name" : "requestConsumer" + }, { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoDelay$MonoDelayRunnable", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoNext$NextSubscriber", + "fields" : [ { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.MonoPublishOn$PublishOnSubscriber", + "fields" : [ { + "name" : "future" + }, { + "name" : "value" + } ] +}, { + "name" : "reactor.core.publisher.MonoSupplier$MonoSupplierSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoZip$ZipCoordinator", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoZip$ZipInner", + "fields" : [ { + "name" : "s" + } ] +}, { + "name" : "reactor.core.publisher.Operators$DeferredSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.Operators$MultiSubscriptionSubscriber", + "fields" : [ { + "name" : "missedProduced" + }, { + "name" : "missedRequested" + }, { + "name" : "missedSubscription" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.Operators$ScalarSubscription", + "fields" : [ { + "name" : "once" + } ] +}, { + "name" : "reactor.core.publisher.StrictSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "s" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.scheduler.ParallelScheduler", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.scheduler.PeriodicWorkerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + } ] +}, { + "name" : "reactor.core.scheduler.SchedulerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + } ] +}, { + "name" : "reactor.core.scheduler.SingleScheduler", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.scheduler.WorkerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + }, { + "name" : "thread" + } ] +}, { + "name" : "reactor.test.DefaultStepVerifierBuilder$DefaultVerifySubscriber", + "fields" : [ { + "name" : "errors" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.util.concurrent.MpscLinkedQueue", + "fields" : [ { + "name" : "consumerNode" + }, { + "name" : "producerNode" + } ] +}, { + "name" : "reactor.util.concurrent.MpscLinkedQueue$LinkedQueueNode", + "fields" : [ { + "name" : "next" + } ] +}, { + "name" : "reactor.util.concurrent.SpscArrayQueueConsumer", + "fields" : [ { + "name" : "consumerIndex" + } ] +}, { + "name" : "reactor.util.concurrent.SpscArrayQueueProducer", + "fields" : [ { + "name" : "producerIndex" + } ] +}, { + "name" : "reactor.util.concurrent.SpscLinkedArrayQueue", + "fields" : [ { + "name" : "consumerIndex" + }, { + "name" : "producerIndex" + } ] }, { "name" : "retrofit2.http.DELETE", "methods" : [ { @@ -12725,10 +15490,54 @@ "name" : "value", "parameterTypes" : [ ] } ] +}, { + "name" : "scala.Equals", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.Product", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.Serializable", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.collection.concurrent.CNodeBase", + "fields" : [ { + "name" : "csize" + } ] +}, { + "name" : "scala.collection.concurrent.INodeBase", + "fields" : [ { + "name" : "mainnode" + } ] +}, { + "name" : "scala.collection.concurrent.MainNode", + "fields" : [ { + "name" : "prev" + } ] +}, { + "name" : "scala.collection.concurrent.TrieMap", + "fields" : [ { + "name" : "root" + } ] }, { "name" : "scala.concurrent.ExecutionContext" }, { "name" : "scala.concurrent.Future" +}, { + "name" : "scalapb.GeneratedMessage", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] }, { "name" : "scalapb.descriptors.Descriptor", "fields" : [ { @@ -12740,6 +15549,12 @@ }, { "name" : "sortedFields$lzy1" } ] +}, { + "name" : "scalapb.lenses.Updatable", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] }, { "name" : "sun.management.ClassLoadingImpl", "queryAllPublicConstructors" : true @@ -12932,6 +15747,16 @@ "name" : "", "parameterTypes" : [ "java.security.SecureRandomParameters" ] } ] +}, { + "name" : "sun.security.provider.NativePRNG$NonBlocking", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "", + "parameterTypes" : [ "java.security.SecureRandomParameters" ] + } ] }, { "name" : "sun.security.provider.SHA", "methods" : [ { @@ -13043,6 +15868,12 @@ "fields" : [ { "name" : "trustManager" } ] +}, { + "name" : "sun.security.ssl.SSLContextImpl$DefaultSSLContext", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "sun.security.ssl.SSLContextImpl$TLSContext", "fields" : [ { diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json index 8d374f928bd..406d2ff5dd9 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json @@ -1,6 +1,16 @@ { "resources" : { "includes" : [ { + "pattern" : "\\QMETA-INF/services/ch.qos.logback.classic.spi.Configurator\\E" + }, { + "pattern" : "\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E" + }, { + "pattern" : "\\Qcom/linecorp/armeria/internal/common/thrift/thrift-options.properties\\E" + }, { + "pattern" : "\\Qlogback.scmo\\E" + }, { + "pattern" : "\\Qprometheus.properties\\E" + }, { "pattern" : "^META-INF/[^/]+\\.versions\\.properties$" }, { "pattern" : "^META-INF/armeria/grpc/.*$" diff --git a/native-image-config/build.gradle.kts b/native-image-config/build.gradle.kts index 5a9c31188b8..350b1096185 100644 --- a/native-image-config/build.gradle.kts +++ b/native-image-config/build.gradle.kts @@ -123,7 +123,6 @@ tasks.register("simplifyNativeImageConfig", SimplifyNativeImageConfigTask::class excludedResourceRegexes.apply { // Exclude the resource files that are referenced only from the tests. add("""Test(?:Utils?)?\.""".toRegex()) - add("""^test(?:ing)?[/\\.]""".toRegex()) add("""^CatalogManager\.properties$""".toRegex()) add("""^META-INF/armeria/grpc$""".toRegex()) add("""^META-INF/dgminfo""".toRegex()) @@ -143,12 +142,13 @@ tasks.register("simplifyNativeImageConfig", SimplifyNativeImageConfigTask::class add("""^jndi\.properties$""".toRegex()) add("""^junit-platform\.properties$""".toRegex()) add("""^log4testng\.properties$""".toRegex()) - add("""^logback-test\.xml$""".toRegex()) + add("""^logback-test\.(xml|properties|scmo)$""".toRegex()) add("""^mockito-extensions/""".toRegex()) add("""^mozilla/""".toRegex()) add("""^org/apache/hc/""".toRegex()) add("""^org/apache/http/""".toRegex()) add("""^org/apache/xml/""".toRegex()) + add("""^test(?:ing)?[/\\.]""".toRegex()) add("""^testcontainers\.properties$""".toRegex()) } } diff --git a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java index 7e61bad7d06..637cec96a96 100644 --- a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java +++ b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java @@ -36,7 +36,7 @@ public final class ThriftMetadataAccess { private static boolean preInitializeThriftClass; - private static final String THRIFT_OPTIONS_PROPERTIES = "../../common/thrift/thrift-options.properties"; + private static final String THRIFT_OPTIONS_PROPERTIES = "thrift-options.properties"; static { try { From d1491ca96a6f45bf21d9e26719e1edf3b765a0ed Mon Sep 17 00:00:00 2001 From: Ikhun Um Date: Thu, 7 Nov 2024 12:50:56 +0900 Subject: [PATCH 03/12] Fixed `AsyncServerInterceptor` to be compatible with OpenTelmetry (#5938) Motivation: There was a bug in `AsyncServerInterceptor` where a `ForwardingServerCall` was not unwrapped properly via reflection. Because of that, OpenTelemetry's `TracingServerCall` that wraps the existing `ServerCall` using `ForwardingServerCall` was rejected by the following condition. https://github.com/line/armeria/blob/6dd5cd18dcdc32431316f272128287b2037249d3/grpc/src/main/java/com/linecorp/armeria/server/grpc/DeferredListener.java#L51-L52 Modifications: - Use the correct reflection API to unsafely access `ForwardingServerCall.delegate()`. Result: - Fix a bug where `AsyncServerInterceptor` is incompatible with the OpenTelemetry gRPC agent. - Closes #5937 --- .../armeria/server/grpc/ServerCallUtil.java | 7 ++++--- .../armeria/server/grpc/DeferredListenerTest.java | 13 ++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java index 66965d7c038..0996ad7c8e8 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java @@ -18,7 +18,7 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; +import java.lang.reflect.Method; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.server.grpc.AbstractServerCall; @@ -34,8 +34,9 @@ final class ServerCallUtil { static { try { - delegateMH = MethodHandles.lookup().findVirtual(ForwardingServerCall.class, "delegate", - MethodType.methodType(ServerCall.class)); + final Method delegate = ForwardingServerCall.class.getDeclaredMethod("delegate"); + delegate.setAccessible(true); + delegateMH = MethodHandles.lookup().unreflect(delegate); } catch (NoSuchMethodException | IllegalAccessException e) { delegateMH = null; } diff --git a/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java b/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java index b69ff8ef5d1..7fd73938689 100644 --- a/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java @@ -29,6 +29,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.google.common.util.concurrent.MoreExecutors; @@ -44,6 +46,7 @@ import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; import io.grpc.ServerCall; import io.netty.channel.EventLoop; import testing.grpc.Messages.SimpleRequest; @@ -60,10 +63,14 @@ void shouldHaveRequestContextInThread() { AsyncServerInterceptor.class.getName()); } - @Test - void shouldLazilyExecuteCallbacks() { + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void shouldLazilyExecuteCallbacks(boolean wrap) { final EventLoop eventLoop = CommonPools.workerGroup().next(); - final UnaryServerCall serverCall = newServerCall(eventLoop, null); + ServerCall serverCall = newServerCall(eventLoop, null); + if (wrap) { + serverCall = new SimpleForwardingServerCall(serverCall) {}; + } assertListenerEvents(serverCall, eventLoop); final Executor blockingExecutor = From 1bb781a7d93e0ec4dc082719f1cb573877161709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=A0=95=EC=9A=A9?= Date: Thu, 7 Nov 2024 12:53:56 +0900 Subject: [PATCH 04/12] Add StreamMessage.timeout() (#5761) Motivation: Currently, the `aggregate()` and `subscribe()` methods of `StreamMessage` do not have the ability to set a timeout. This provides the ability to detect when a client or server has not responded for a period of time and handle it appropriately. Additionally, the timeout API can be used to detect idle streams by setting a timeout until the next message. Modifications: 1. Added the `TimeoutStreamMessage` class - Wrap a `StreamMessage` to provide timeout functionality - You can set a timeout feature by adding the `timeout()` method to the `StreamMessage` interface. 2. Added the `TimeoutSubscriber` class - Schedule a timeout for each message. - `StreamTimeoutMode` allows you to set different timeout modes. 3. Added `StreamTimeoutMode` enumeration - Defines the `UNTIL_FIRST`, `UNTIL_NEXT`, and `UNTIL_EOS` modes. 4. Added a timeout method - Added the `timeout` method to the `StreamMessage`, `HttpResponse`, and `HttpRequest` interfaces to provide the ability to set a timeout. Result: - Closes #5744 - This change adds timeout functionality to the `aggregate()` and `subscribe()` methods of `StreamMessage` and HTTP requests/responses. - This allows idle streams to be detected and handled appropriately. - You can use `StreamTimeoutMode` to set the timeout between the arrival of the first message, the arrival of the next message, or the end of the stream. --------- Co-authored-by: Ikhun Um Co-authored-by: jrhee17 --- .../armeria/client/RestClientPreparation.java | 2 + .../TransformingRequestPreparation.java | 3 +- .../linecorp/armeria/common/HttpRequest.java | 14 ++ .../linecorp/armeria/common/HttpResponse.java | 13 + .../common/StreamTimeoutException.java | 39 +++ .../armeria/common/stream/StreamMessage.java | 46 ++++ .../common/stream/StreamTimeoutMode.java | 60 +++++ .../common/stream/TimeoutStreamMessage.java | 229 +++++++++++++++++ .../armeria/common/websocket/WebSocket.java | 15 ++ .../stream/TimeoutStreamMessageTest.java | 238 ++++++++++++++++++ 10 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java diff --git a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java index f913b070436..36c0815d70b 100644 --- a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java @@ -173,6 +173,7 @@ public RestClientPreparation content(MediaType contentType, String content) { @Override @FormatMethod + @SuppressWarnings("FormatStringAnnotation") public RestClientPreparation content(@FormatString String format, Object... content) { delegate.content(format, content); return this; @@ -180,6 +181,7 @@ public RestClientPreparation content(@FormatString String format, Object... cont @Override @FormatMethod + @SuppressWarnings("FormatStringAnnotation") public RestClientPreparation content(MediaType contentType, @FormatString String format, Object... content) { delegate.content(contentType, format, content); diff --git a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java index 535fc1c0e16..e90b0ee9302 100644 --- a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java @@ -25,6 +25,7 @@ import org.reactivestreams.Publisher; import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; import com.linecorp.armeria.common.Cookie; import com.linecorp.armeria.common.ExchangeType; @@ -192,7 +193,7 @@ public TransformingRequestPreparation content(String format, Object... con @Override @FormatMethod @SuppressWarnings("FormatStringAnnotation") - public TransformingRequestPreparation content(MediaType contentType, String format, + public TransformingRequestPreparation content(MediaType contentType, @FormatString String format, Object... content) { delegate.content(contentType, format, content); return this; diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java index 1d75eb565fe..6c4f337c761 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java @@ -21,6 +21,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Formatter; import java.util.List; import java.util.Locale; @@ -47,6 +48,7 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.internal.common.DefaultHttpRequest; import com.linecorp.armeria.internal.common.DefaultSplitHttpRequest; @@ -816,4 +818,16 @@ default HttpRequest subscribeOn(EventExecutor eventExecutor) { requireNonNull(eventExecutor, "eventExecutor"); return of(headers(), HttpMessage.super.subscribeOn(eventExecutor)); } + + @UnstableApi + @Override + default HttpRequest timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default HttpRequest timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(headers(), HttpMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java index 21de0c4cda5..32d2b810d3e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java @@ -53,6 +53,7 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.common.util.Exceptions; import com.linecorp.armeria.internal.common.AbortedHttpResponse; @@ -1196,4 +1197,16 @@ default HttpResponse recover(Class causeClass, default HttpResponse subscribeOn(EventExecutor eventExecutor) { return of(HttpMessage.super.subscribeOn(eventExecutor)); } + + @UnstableApi + @Override + default HttpResponse timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default HttpResponse timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(HttpMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java b/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java new file mode 100644 index 00000000000..112183f3f78 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import java.time.Duration; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.stream.StreamMessage; + +/** + * A {@link TimeoutException} raised when a stream operation exceeds the configured timeout. + * + * @see StreamMessage#timeout(Duration) + */ +public final class StreamTimeoutException extends TimeoutException { + + private static final long serialVersionUID = 7585558758307122722L; + + /** + * Creates a new instance with the specified {@code message}. + */ + public StreamTimeoutException(@Nullable String message) { + super(message); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java index 60727d202bc..9c5d0c96e3b 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java @@ -29,6 +29,7 @@ import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -49,6 +50,7 @@ import com.linecorp.armeria.common.CommonPools; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.StreamTimeoutException; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.util.Exceptions; @@ -1210,4 +1212,48 @@ default StreamMessage subscribeOn(EventExecutor eventExecutor) { requireNonNull(eventExecutor, "eventExecutor"); return new SubscribeOnStreamMessage<>(this, eventExecutor); } + + /** + * Configures a timeout for the stream based on the specified duration with + * {@link StreamTimeoutMode#UNTIL_NEXT}. If no events are received within the specified duration, + * the stream will be terminated with a {@link StreamTimeoutException}. + * + *

Example usage: + *

{@code
+     * StreamMessage stream = ...;
+     * // An item must be received within 10 seconds of the previous item to avoid a timeout.
+     * StreamMessage timeoutStream = stream.timeout(Duration.ofSeconds(10));
+     * }
+ * + * @param timeoutDuration the duration before a timeout occurs + * @return a new {@link StreamMessage} with the specified timeout duration and default mode + */ + @UnstableApi + default StreamMessage timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + /** + * Configures a timeout for the stream based on the specified duration and mode. >If no events are received + * within the specified duration, the stream will be terminated with a {@link StreamTimeoutException}. + * + *

Example usage: + *

{@code
+     * StreamMessage stream = ...;
+     * StreamMessage timeoutStream = stream.timeout(
+     *     Duration.ofSeconds(10),
+     *     StreamTimeoutMode.UNTIL_FIRST
+     * );
+     * }
+ * + * @param timeoutDuration the duration before a timeout occurs + * @param timeoutMode the mode in which the timeout is applied (see {@link StreamTimeoutMode} for details) + * @return a new {@link StreamMessage} with the specified timeout duration and mode applied + */ + @UnstableApi + default StreamMessage timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + requireNonNull(timeoutDuration, "timeoutDuration"); + requireNonNull(timeoutMode, "timeoutMode"); + return new TimeoutStreamMessage<>(this, timeoutDuration, timeoutMode); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java new file mode 100644 index 00000000000..620fec4bcad --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.stream; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Stream Timeout Mode consists of three modes. + * + *
    + *
  • {@link #UNTIL_FIRST} - Based on the first data chunk. + * If the first data chunk is not received within the specified time, + * a {@link StreamTimeoutException} is thrown.
  • + *
  • {@link #UNTIL_NEXT} - Based on each data chunk. + * If each data chunk is not received within the specified time after the previous chunk, + * a {@link StreamTimeoutException} is thrown.
  • + *
  • {@link #UNTIL_EOS} - Based on the entire stream. + * If all data chunks are not received within the specified time before the end of the stream, + * a {@link StreamTimeoutException} is thrown.
  • + *
+ */ +@UnstableApi +public enum StreamTimeoutMode { + + /** + * Based on the first data chunk. + * If the first data chunk is not received within the specified time, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_FIRST, + + /** + * Based on each data chunk. + * If each data chunk is not received within the specified time after the previous chunk, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_NEXT, + + /** + * Based on the entire stream. + * If all data chunks are not received within the specified time before the end of the stream, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_EOS +} diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java new file mode 100644 index 00000000000..4e13c462d68 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java @@ -0,0 +1,229 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.stream; + +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.unsafe.PooledObjects; + +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.ScheduledFuture; + +/** + * This class provides timeout functionality to a base StreamMessage. + * If data is not received within the specified time, a {@link StreamTimeoutException} is thrown. + * + *

The timeout functionality helps to release resources and throw appropriate exceptions + * if the stream becomes inactive or data is not received within a certain time frame, + * thereby improving system efficiency. + * + * @param the type of the elements signaled + */ +final class TimeoutStreamMessage implements StreamMessage { + + private final StreamMessage delegate; + private final Duration timeoutDuration; + private final StreamTimeoutMode timeoutMode; + + /** + * Creates a new TimeoutStreamMessage with the specified base stream message and timeout settings. + * + * @param delegate the original stream message + * @param timeoutDuration the duration before a timeout occurs + * @param timeoutMode the mode in which the timeout is applied (see {@link StreamTimeoutMode} for details) + */ + TimeoutStreamMessage(StreamMessage delegate, Duration timeoutDuration, + StreamTimeoutMode timeoutMode) { + this.delegate = requireNonNull(delegate, "delegate"); + this.timeoutDuration = requireNonNull(timeoutDuration, "timeoutDuration"); + this.timeoutMode = requireNonNull(timeoutMode, "timeoutMode"); + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public long demand() { + return delegate.demand(); + } + + @Override + public CompletableFuture whenComplete() { + return delegate.whenComplete(); + } + + /** + * Subscribes the given subscriber to this stream with timeout logic applied. + * + * @param subscriber the subscriber to this stream + * @param executor the executor for running timeout tasks and stream operations + * @param options subscription options + * @see StreamMessage#subscribe(Subscriber, EventExecutor, SubscriptionOption...) + */ + @Override + public void subscribe(Subscriber subscriber, EventExecutor executor, + SubscriptionOption... options) { + delegate.subscribe(new TimeoutSubscriber<>(subscriber, executor, timeoutDuration, timeoutMode), + executor, options); + } + + @Override + public void abort() { + delegate.abort(); + } + + @Override + public void abort(Throwable cause) { + delegate.abort(cause); + } + + static final class TimeoutSubscriber implements Runnable, Subscriber, Subscription { + + private static final String TIMEOUT_MESSAGE = "Stream timed out after %d ms (timeout mode: %s)"; + private final Subscriber delegate; + private final EventExecutor executor; + private final StreamTimeoutMode timeoutMode; + private final Duration timeoutDuration; + private final long timeoutNanos; + @Nullable + private ScheduledFuture timeoutFuture; + @Nullable + private Subscription subscription; + private long lastEventTimeNanos; + private boolean completed; + private volatile boolean canceled; + + TimeoutSubscriber(Subscriber delegate, EventExecutor executor, Duration timeoutDuration, + StreamTimeoutMode timeoutMode) { + this.delegate = requireNonNull(delegate, "delegate"); + this.executor = requireNonNull(executor, "executor"); + this.timeoutDuration = requireNonNull(timeoutDuration, "timeoutDuration"); + timeoutNanos = timeoutDuration.toNanos(); + this.timeoutMode = requireNonNull(timeoutMode, "timeoutMode"); + } + + private ScheduledFuture scheduleTimeout(long delay) { + return executor.schedule(this, delay, TimeUnit.NANOSECONDS); + } + + void cancelSchedule() { + if (timeoutFuture != null && !timeoutFuture.isCancelled()) { + timeoutFuture.cancel(false); + } + } + + @Override + public void run() { + if (timeoutMode == StreamTimeoutMode.UNTIL_NEXT) { + final long currentTimeNanos = System.nanoTime(); + final long elapsedNanos = currentTimeNanos - lastEventTimeNanos; + + if (elapsedNanos < timeoutNanos) { + final long delayNanos = timeoutNanos - elapsedNanos; + timeoutFuture = scheduleTimeout(delayNanos); + return; + } + } + completed = true; + delegate.onError(new StreamTimeoutException( + String.format(TIMEOUT_MESSAGE, timeoutDuration.toMillis(), timeoutMode))); + assert subscription != null; + subscription.cancel(); + } + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + delegate.onSubscribe(this); + if (completed || canceled) { + return; + } + lastEventTimeNanos = System.nanoTime(); + timeoutFuture = scheduleTimeout(timeoutNanos); + } + + @Override + public void onNext(T t) { + if (completed || canceled) { + PooledObjects.close(t); + return; + } + switch (timeoutMode) { + case UNTIL_NEXT: + lastEventTimeNanos = System.nanoTime(); + break; + case UNTIL_FIRST: + cancelSchedule(); + timeoutFuture = null; + break; + case UNTIL_EOS: + break; + } + delegate.onNext(t); + } + + @Override + public void onError(Throwable throwable) { + if (completed) { + return; + } + completed = true; + cancelSchedule(); + delegate.onError(throwable); + } + + @Override + public void onComplete() { + if (completed) { + return; + } + completed = true; + cancelSchedule(); + delegate.onComplete(); + } + + @Override + public void request(long l) { + assert subscription != null; + subscription.request(l); + } + + @Override + public void cancel() { + canceled = true; + cancelSchedule(); + assert subscription != null; + subscription.cancel(); + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java b/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java index 10ae7240377..85e68b3591e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java +++ b/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java @@ -15,8 +15,11 @@ */ package com.linecorp.armeria.common.websocket; +import java.time.Duration; + import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.internal.common.websocket.WebSocketWrapper; /** @@ -39,4 +42,16 @@ static WebSocketWriter streaming() { static WebSocket of(StreamMessage delegate) { return new WebSocketWrapper(delegate); } + + @UnstableApi + @Override + default WebSocket timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default WebSocket timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(StreamMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java b/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java new file mode 100644 index 00000000000..085372b27f6 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.testing.junit5.common.EventLoopExtension; + +class TimeoutStreamMessageTest { + + @RegisterExtension + static final EventLoopExtension executor = new EventLoopExtension(); + + @Test + public void timeoutNextMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_NEXT); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + subscription.request(1); + } + + @Override + public void onNext(String s) { + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutNextMode() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_NEXT); + + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } + + @Test + void timeoutFirstMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_FIRST); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onNext(String s) { + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutModeFirst() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_FIRST); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } + + @Test + void timeoutEOSMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(2), StreamTimeoutMode.UNTIL_EOS); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + executor.get().schedule(() -> subscription.request(1), 1, TimeUnit.SECONDS); + } + + @Override + public void onNext(String s) { + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutEOSMode() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(2), StreamTimeoutMode.UNTIL_EOS); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } +} From 66285136ffcd6c94a97daf9656c25aefa64833cb Mon Sep 17 00:00:00 2001 From: Issei Miyoshi <38831921+my4-dev@users.noreply.github.com> Date: Thu, 7 Nov 2024 16:25:38 +0900 Subject: [PATCH 05/12] Provide a way to dynamically update TLS certificates (#5228) Motivation: #5033 API design note: - `TlsKeyPair` represents a pair of `PrivateKey` and `X509Certificate` chain. - This API is used as an official key pair type in Armeria. - All APIs for specifying key pairs in `TlsSetters` have been deprecated in favor of `TlsSetters tls(TlsKeyPair)`. ```java TlsKeyPair.of(privateKey, certificate); TlsKeyPair.of(privateKey, keyPassword, certificate); ``` - `TlsProvider` dynamically resolves a `TlsKeyPair` for the given hostname when a connection is established. - DNS wildcard format is supported as a hostname. - `*` is used as a special hostname to get the `TlsKeyPair` for the default virtual host. ```java TlsProvider .builder() // Set the default key pair. .keyPair(TlsKeyPair.of(...)) // Set the key pair for "*.example.com". .keyPair("*.example.com", TlsKeyPair.of(...)) .build(); ``` - To dynamically update/reload `TlsKeyPair`, a custom `TlsProvider` can be implemented. ```java class DynamicTlsProvider implements TlsProvider { @Override public TlsKeyPair keyPair(String hostname) { // relodableCache will be updated periodically by a scheduler return relodableCache.get(hostname); } } ``` - The newly returned key pair is used for the TLS handshake of new connections. - `ServerTlsConfig` and `ClientTlsConfig` are added to override the default values and customize `SslContextBuilder`. - Unlike `TlsProvider`, `*TlsConfig` are immutable so all `TlsKeyPair`s returned by a `TlsProvider` build `SslContext` with the same configuration. - Both server and client allow `TlsProvider` and `TlsKeyPair` for TLS configurations. ```java Server .builder() // For dynamic usage .tlsProvider(tlsProvider) // For customizing TLS .tlsProvider(tlsProvider, serverTlsConfig) // For sample usage .tls(tlsKeyPair) .build() ClientFactory .builder() // For dynamic usage .tlsProvider(tlsProvider) // For customizing TLS .tlsProvider(tlsProvider, clientTlsConfig) // For sample usage .tls(tlsKeyPair) .build() ``` - Some internal implementations for TLS handshake have been changed to create `SslContext` dynamically. Modifications: - Server - Add `TlsProviderMapping` that converts `TlsProvider` into SslContext `Mapping` for `SniHandler`. - A dynamic `TlsProvider` can be used to update the certificates without `Server.reconfigure()`. - Add a setter method for `TlsProvider` to `ServerBuilder`. - A builder method for `VirtualHost` isn't added because a `TlsProvider` can contain multiple certificates. - If necessary, I will consider `TlsProvider` at the virtual host level later. - Client - Fix `Bootstraps` to create a `Bootstrap` with a `TlsKeyPair` returned by `TlsProvider` when a new connection is created. - If no `TlsProvider` is set, the original behavior that returns predefined `BootStraap` is used. - Add options for `TlsProvider` to `ClientFactoryBuilder`. - Common - `TlsProvider` provides separate builders for the client and server. - `TlsKeyPair` provides various factory methods to easily create a key pair from different resources. - Cache `SslContext`s and expire them after 1 hour of inactivity. - If you think that the caching strategy will not be effective, I am willing to revert it. - Add `CloseableMeterBinder` to unregister when the associated resource is unused. - Deprecate) The following APIs have been deprecated: - `TlsSetters tls(File keyCertChainFile, File keyFile)` - `TlsSetters tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword)` - `TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream)` - `TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword)` - `TlsSetters tls(PrivateKey key, X509Certificate... keyCertChain)` - `TlsSetters tls(PrivateKey key, Iterable keyCertChain)` - `TlsSetters tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain)` - `TlsSetters tls(PrivateKey key, @Nullable String keyPassword, Iterable keyCertChain)` Result: - Closes #5033 - You can now set TLS configurations dynamically using `TlsProvider` --------- Co-authored-by: Ikhun Um Co-authored-by: jrhee17 --- .../armeria/server/RoutersBenchmark.java | 2 +- build.gradle | 4 + .../client/AbstractClientOptionsBuilder.java | 20 +- .../linecorp/armeria/client/Bootstraps.java | 174 ++++++--- .../armeria/client/ClientFactoryBuilder.java | 131 +++++-- .../armeria/client/ClientFactoryOptions.java | 35 ++ .../armeria/client/ClientTlsConfig.java | 104 ++++++ .../client/ClientTlsConfigBuilder.java | 95 +++++ .../armeria/client/HttpChannelPool.java | 12 +- .../armeria/client/HttpClientFactory.java | 44 ++- .../HttpClientPipelineConfigurator.java | 2 +- .../armeria/client/NullTlsProvider.java | 30 ++ .../armeria/common/AbstractTlsConfig.java | 92 +++++ .../common/AbstractTlsConfigBuilder.java | 123 +++++++ .../com/linecorp/armeria/common/Flags.java | 2 +- .../armeria/common/MappedTlsProvider.java | 106 ++++++ .../armeria/common/StaticTlsProvider.java | 61 ++++ .../linecorp/armeria/common/TlsKeyPair.java | 178 +++++++++ .../linecorp/armeria/common/TlsProvider.java | 87 +++++ .../armeria/common/TlsProviderBuilder.java | 155 ++++++++ .../linecorp/armeria/common/TlsSetters.java | 62 +++- .../metric/AbstractCloseableMeterBinder.java | 50 +++ .../common/metric/CertificateMetrics.java | 84 +++-- .../common/metric/CloseableMeterBinder.java | 31 ++ .../common/metric/EventLoopMetrics.java | 10 +- .../common/metric/MoreMeterBinders.java | 19 +- .../common}/IgnoreHostsTrustManager.java | 43 +-- .../internal/common/SslContextFactory.java | 337 ++++++++++++++++++ .../internal/common/TlsProviderUtil.java | 59 +++ .../internal/common/util/CertificateUtil.java | 49 +++ .../internal/common/util/KeyStoreUtil.java | 49 +-- .../internal/common/util/SslContextUtil.java | 6 +- .../HttpServerPipelineConfigurator.java | 20 +- .../armeria/server/ServerBuilder.java | 134 +++++-- .../armeria/server/ServerSslContextUtil.java | 9 +- .../armeria/server/ServerTlsConfig.java | 70 ++++ .../server/ServerTlsConfigBuilder.java | 51 +++ .../armeria/server/TlsProviderMapping.java | 51 +++ .../linecorp/armeria/server/VirtualHost.java | 14 +- .../armeria/server/VirtualHostBuilder.java | 89 ++--- .../client/ClientTlsProviderBuilderTest.java | 68 ++++ .../armeria/client/ClientTlsProviderTest.java | 311 ++++++++++++++++ .../client/IgnoreHostsTrustManagerTest.java | 1 + .../armeria/client/TlsProviderCacheTest.java | 179 ++++++++++ .../armeria/client/TlsProviderMTlsTest.java | 84 +++++ .../TlsProviderTrustedCertificatesTest.java | 233 ++++++++++++ .../proxy/ProxyClientIntegrationTest.java | 65 +++- .../common/util/KeyStoreUtilTest.java | 12 +- .../ServerTlsCertificateMetricsTest.java | 2 +- .../armeria/server/ServerTlsProviderTest.java | 191 ++++++++++ .../server/TlsProviderMappingTest.java | 75 ++++ ...ostAnnotatedServiceBindingBuilderTest.java | 2 +- .../server/VirtualHostBuilderTest.java | 30 +- ...verriddenBuilderMethodsReturnTypeTest.java | 103 +++--- .../SelfSignedCertificateRuleDelegate.java | 14 + .../SelfSignedCertificateExtension.java | 10 + 56 files changed, 3703 insertions(+), 371 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfig.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/TlsProvider.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java rename core/src/main/java/com/linecorp/armeria/{client => internal/common}/IgnoreHostsTrustManager.java (70%) create mode 100644 core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java diff --git a/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java b/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java index f91d2226685..3804c10c922 100644 --- a/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java +++ b/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java @@ -61,7 +61,7 @@ public class RoutersBenchmark { FALLBACK_SERVICE = newServiceConfig(Route.ofCatchAll()); HOST = new VirtualHost( "localhost", "localhost", 0, null, - null, SERVICES, FALLBACK_SERVICE, RejectedRouteHandler.DISABLED, + null, null, SERVICES, FALLBACK_SERVICE, RejectedRouteHandler.DISABLED, unused -> NOPLogger.NOP_LOGGER, FALLBACK_SERVICE.defaultServiceNaming(), FALLBACK_SERVICE.defaultLogName(), 0, 0, false, AccessLogWriter.disabled(), CommonPools.blockingTaskExecutor(), 0, SuccessFunction.ofDefault(), diff --git a/build.gradle b/build.gradle index 2a28be232be..ccb39c888fc 100644 --- a/build.gradle +++ b/build.gradle @@ -117,6 +117,10 @@ allprojects { doFirst { addTestOutputListener({ descriptor, event -> if (event.message.contains('LEAK: ')) { + if (isCi) { + logger.warn("Leak is detected in ${descriptor.className}.${descriptor.displayName}\n" + + "${event.message}") + } hasLeak.set(true) } }) diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java b/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java index ac8d7cd7709..50cd5b966f1 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java @@ -29,6 +29,8 @@ import java.util.function.Function; import java.util.function.Supplier; +import com.google.common.collect.ImmutableList; + import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.client.redirect.RedirectConfig; import com.linecorp.armeria.common.HttpHeaderNames; @@ -532,20 +534,20 @@ protected final ClientOptions buildOptions() { */ protected final ClientOptions buildOptions(@Nullable ClientOptions baseOptions) { final Collection> optVals = options.values(); - final int numOpts = optVals.size(); - final int extra = contextCustomizer == null ? 3 : 4; - final ClientOptionValue[] optValArray = optVals.toArray(new ClientOptionValue[numOpts + extra]); - optValArray[numOpts] = ClientOptions.DECORATION.newValue(decoration.build()); - optValArray[numOpts + 1] = ClientOptions.HEADERS.newValue(headers.build()); - optValArray[numOpts + 2] = ClientOptions.CONTEXT_HOOK.newValue(contextHook); + final ImmutableList.Builder> additionalValues = + ImmutableList.builder(); + additionalValues.addAll(optVals); + additionalValues.add(ClientOptions.DECORATION.newValue(decoration.build())); + additionalValues.add(ClientOptions.HEADERS.newValue(headers.build())); + additionalValues.add(ClientOptions.CONTEXT_HOOK.newValue(contextHook)); if (contextCustomizer != null) { - optValArray[numOpts + 3] = ClientOptions.CONTEXT_CUSTOMIZER.newValue(contextCustomizer); + additionalValues.add(ClientOptions.CONTEXT_CUSTOMIZER.newValue(contextCustomizer)); } if (baseOptions != null) { - return ClientOptions.of(baseOptions, optValArray); + return ClientOptions.of(baseOptions, additionalValues.build()); } else { - return ClientOptions.of(optValArray); + return ClientOptions.of(additionalValues.build()); } } } diff --git a/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java b/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java index 6849c11f216..ac4616ef6e4 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java +++ b/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java @@ -26,6 +26,8 @@ import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.common.SslContextFactory.SslContextMode; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; @@ -36,56 +38,42 @@ final class Bootstraps { - private final Bootstrap[][] inetBootstraps; - private final Bootstrap @Nullable [][] unixBootstraps; private final EventLoop eventLoop; private final SslContext sslCtxHttp1Only; private final SslContext sslCtxHttp1Or2; + @Nullable + private final SslContextFactory sslContextFactory; + + private final HttpClientFactory clientFactory; + private final Bootstrap inetBaseBootstrap; + @Nullable + private final Bootstrap unixBaseBootstrap; + private final Bootstrap[][] inetBootstraps; + private final Bootstrap @Nullable [][] unixBootstraps; - Bootstraps(HttpClientFactory clientFactory, EventLoop eventLoop, SslContext sslCtxHttp1Or2, - SslContext sslCtxHttp1Only) { + Bootstraps(HttpClientFactory clientFactory, EventLoop eventLoop, + SslContext sslCtxHttp1Or2, SslContext sslCtxHttp1Only, + @Nullable SslContextFactory sslContextFactory) { this.eventLoop = eventLoop; this.sslCtxHttp1Or2 = sslCtxHttp1Or2; this.sslCtxHttp1Only = sslCtxHttp1Only; + this.sslContextFactory = sslContextFactory; + this.clientFactory = clientFactory; + + inetBaseBootstrap = clientFactory.newInetBootstrap(); + inetBaseBootstrap.group(eventLoop); + inetBootstraps = staticBootstrapMap(inetBaseBootstrap); - final Bootstrap inetBaseBootstrap = clientFactory.newInetBootstrap(); - final Bootstrap unixBaseBootstrap = clientFactory.newUnixBootstrap(); - inetBootstraps = newBootstrapMap(inetBaseBootstrap, clientFactory, eventLoop); + unixBaseBootstrap = clientFactory.newUnixBootstrap(); if (unixBaseBootstrap != null) { - unixBootstraps = newBootstrapMap(unixBaseBootstrap, clientFactory, eventLoop); + unixBaseBootstrap.group(eventLoop); + unixBootstraps = staticBootstrapMap(unixBaseBootstrap); } else { unixBootstraps = null; } } - /** - * Returns a {@link Bootstrap} corresponding to the specified {@link SocketAddress} - * {@link SessionProtocol} and {@link SerializationFormat}. - */ - Bootstrap get(SocketAddress remoteAddress, SessionProtocol desiredProtocol, - SerializationFormat serializationFormat) { - if (!httpAndHttpsValues().contains(desiredProtocol)) { - throw new IllegalArgumentException("Unsupported session protocol: " + desiredProtocol); - } - - if (remoteAddress instanceof InetSocketAddress) { - return select(inetBootstraps, desiredProtocol, serializationFormat); - } - - assert remoteAddress instanceof DomainSocketAddress : remoteAddress; - - if (unixBootstraps == null) { - throw new IllegalArgumentException("Domain sockets are not supported by " + - eventLoop.getClass().getName()); - } - - return select(unixBootstraps, desiredProtocol, serializationFormat); - } - - private Bootstrap[][] newBootstrapMap(Bootstrap baseBootstrap, - HttpClientFactory clientFactory, - EventLoop eventLoop) { - baseBootstrap.group(eventLoop); + private Bootstrap[][] staticBootstrapMap(Bootstrap baseBootstrap) { final Set sessionProtocols = httpAndHttpsValues(); final Bootstrap[][] maps = (Bootstrap[][]) Array.newInstance( Bootstrap.class, SessionProtocol.values().length, 2); @@ -93,8 +81,8 @@ private Bootstrap[][] newBootstrapMap(Bootstrap baseBootstrap, // which will help us find a bug. for (SessionProtocol p : sessionProtocols) { final SslContext sslCtx = determineSslContext(p); - setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, true); - setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, false); + createAndSetBootstrap(baseBootstrap, maps, p, sslCtx, true); + createAndSetBootstrap(baseBootstrap, maps, p, sslCtx, false); } return maps; } @@ -106,22 +94,18 @@ SslContext determineSslContext(SessionProtocol desiredProtocol) { return desiredProtocol.isExplicitHttp1() ? sslCtxHttp1Only : sslCtxHttp1Or2; } - private static Bootstrap select(Bootstrap[][] bootstraps, SessionProtocol desiredProtocol, - SerializationFormat serializationFormat) { + private Bootstrap select(boolean isDomainSocket, SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + final Bootstrap[][] bootstraps = isDomainSocket ? unixBootstraps : inetBootstraps; + assert bootstraps != null; return bootstraps[desiredProtocol.ordinal()][toIndex(serializationFormat)]; } - private static void setBootstrap(Bootstrap bootstrap, HttpClientFactory clientFactory, Bootstrap[][] maps, - SessionProtocol p, SslContext sslCtx, boolean webSocket) { - bootstrap.handler(new ChannelInitializer() { - @Override - protected void initChannel(Channel ch) throws Exception { - ch.pipeline().addLast(new HttpClientPipelineConfigurator( - clientFactory, webSocket, p, sslCtx)); - } - } - ); - maps[p.ordinal()][toIndex(webSocket)] = bootstrap; + private void createAndSetBootstrap(Bootstrap baseBootstrap, Bootstrap[][] maps, + SessionProtocol desiredProtocol, SslContext sslContext, + boolean webSocket) { + maps[desiredProtocol.ordinal()][toIndex(webSocket)] = newBootstrap(baseBootstrap, desiredProtocol, + sslContext, webSocket, false); } private static int toIndex(boolean webSocket) { @@ -131,4 +115,92 @@ private static int toIndex(boolean webSocket) { private static int toIndex(SerializationFormat serializationFormat) { return toIndex(serializationFormat == SerializationFormat.WS); } + + /** + * Returns a {@link Bootstrap} corresponding to the specified {@link SocketAddress} + * {@link SessionProtocol} and {@link SerializationFormat}. + */ + Bootstrap getOrCreate(SocketAddress remoteAddress, SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + if (!httpAndHttpsValues().contains(desiredProtocol)) { + throw new IllegalArgumentException("Unsupported session protocol: " + desiredProtocol); + } + + final boolean isDomainSocket = remoteAddress instanceof DomainSocketAddress; + if (isDomainSocket && unixBaseBootstrap == null) { + throw new IllegalArgumentException("Domain sockets are not supported by " + + eventLoop.getClass().getName()); + } + + if (sslContextFactory == null || !desiredProtocol.isTls()) { + return select(isDomainSocket, desiredProtocol, serializationFormat); + } + + final Bootstrap baseBootstrap = isDomainSocket ? unixBaseBootstrap : inetBaseBootstrap; + assert baseBootstrap != null; + return newBootstrap(baseBootstrap, remoteAddress, desiredProtocol, serializationFormat); + } + + private Bootstrap newBootstrap(Bootstrap baseBootstrap, SocketAddress remoteAddress, + SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + final boolean webSocket = serializationFormat == SerializationFormat.WS; + final SslContext sslContext = newSslContext(remoteAddress, desiredProtocol); + return newBootstrap(baseBootstrap, desiredProtocol, sslContext, webSocket, true); + } + + private Bootstrap newBootstrap(Bootstrap baseBootstrap, SessionProtocol desiredProtocol, + SslContext sslContext, boolean webSocket, boolean closeSslContext) { + final Bootstrap bootstrap = baseBootstrap.clone(); + bootstrap.handler(clientChannelInitializer(desiredProtocol, sslContext, webSocket, closeSslContext)); + return bootstrap; + } + + SslContext getOrCreateSslContext(SocketAddress remoteAddress, SessionProtocol desiredProtocol) { + if (sslContextFactory == null) { + return determineSslContext(desiredProtocol); + } else { + return newSslContext(remoteAddress, desiredProtocol); + } + } + + private SslContext newSslContext(SocketAddress remoteAddress, SessionProtocol desiredProtocol) { + final String hostname; + if (remoteAddress instanceof InetSocketAddress) { + hostname = ((InetSocketAddress) remoteAddress).getHostString(); + } else { + assert remoteAddress instanceof DomainSocketAddress; + hostname = "unix:" + ((DomainSocketAddress) remoteAddress).path(); + } + + final SslContextMode sslContextMode = + desiredProtocol.isExplicitHttp1() ? SslContextFactory.SslContextMode.CLIENT_HTTP1_ONLY + : SslContextFactory.SslContextMode.CLIENT; + assert sslContextFactory != null; + return sslContextFactory.getOrCreate(sslContextMode, hostname); + } + + boolean shouldReleaseSslContext(SslContext sslContext) { + return sslContext != sslCtxHttp1Only && sslContext != sslCtxHttp1Or2; + } + + void releaseSslContext(SslContext sslContext) { + if (sslContextFactory != null) { + sslContextFactory.release(sslContext); + } + } + + private ChannelInitializer clientChannelInitializer(SessionProtocol p, SslContext sslCtx, + boolean webSocket, boolean closeSslContext) { + return new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + if (closeSslContext) { + ch.closeFuture().addListener(unused -> releaseSslContext(sslCtx)); + } + ch.pipeline().addLast(new HttpClientPipelineConfigurator( + clientFactory, webSocket, p, sslCtx)); + } + }; + } } diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java index 92a28d5c825..029ffab983e 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java @@ -24,10 +24,7 @@ import static io.netty.handler.codec.http2.Http2CodecUtil.MAX_INITIAL_WINDOW_SIZE; import static java.util.Objects.requireNonNull; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.IOError; -import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.ProxySelector; @@ -53,7 +50,6 @@ import com.google.common.base.MoreObjects.ToStringHelper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.io.ByteStreams; import com.google.common.primitives.Ints; import com.linecorp.armeria.client.proxy.ProxyConfig; @@ -63,12 +59,15 @@ import com.linecorp.armeria.common.Http1HeaderNaming; import com.linecorp.armeria.common.Request; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.outlier.OutlierDetection; import com.linecorp.armeria.common.util.EventLoopGroups; import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.IgnoreHostsTrustManager; import com.linecorp.armeria.internal.common.RequestContextUtil; import com.linecorp.armeria.internal.common.util.ChannelUtil; @@ -127,6 +126,11 @@ public final class ClientFactoryBuilder implements TlsSetters { private final List> maxNumEventLoopsFunctions = new ArrayList<>(); private boolean tlsNoVerifySet; private final Set insecureHosts = new HashSet<>(); + @Nullable + private TlsProvider tlsProvider; + @Nullable + private ClientTlsConfig tlsConfig; + private boolean staticTlsSettingsSet; ClientFactoryBuilder() { connectTimeoutMillis(Flags.defaultConnectTimeoutMillis()); @@ -286,6 +290,7 @@ private void channelOptions(Map, Object> newChannelOptions) { */ public ClientFactoryBuilder tlsNoVerify() { checkState(insecureHosts.isEmpty(), "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + ensureNoTlsProvider(); tlsNoVerifySet = true; return this; } @@ -299,6 +304,7 @@ public ClientFactoryBuilder tlsNoVerify() { */ public ClientFactoryBuilder tlsNoVerifyHosts(String... insecureHosts) { checkState(!tlsNoVerifySet, "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + ensureNoTlsProvider(); this.insecureHosts.addAll(Arrays.asList(insecureHosts)); return this; } @@ -306,7 +312,10 @@ public ClientFactoryBuilder tlsNoVerifyHosts(String... insecureHosts) { /** * Configures SSL or TLS for client certificate authentication with the specified {@code keyCertChainFile} * and cleartext {@code keyFile}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile) { return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); @@ -315,18 +324,22 @@ public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile) { /** * Configures SSL or TLS for client certificate authentication with the specified {@code keyCertChainFile}, * {@code keyFile} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - requireNonNull(keyCertChainFile, "keyCertChainFile"); - requireNonNull(keyFile, "keyFile"); - return tlsCustomizer(customizer -> customizer.keyManager(keyCertChainFile, keyFile, keyPassword)); + return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } /** * Configures SSL or TLS for client certificate authentication with the specified * {@code keyCertChainInputStream} and cleartext {@code keyInputStream}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); @@ -335,32 +348,26 @@ public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream /** * Configures SSL or TLS for client certificate authentication with the specified * {@code keyCertChainInputStream} and {@code keyInputStream} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { requireNonNull(keyCertChainInputStream, "keyCertChainInputStream"); requireNonNull(keyInputStream, "keyInputStream"); - - // Retrieve the content of the given streams so that they can be consumed more than once. - final byte[] keyCertChain; - final byte[] key; - try { - keyCertChain = ByteStreams.toByteArray(keyCertChainInputStream); - key = ByteStreams.toByteArray(keyInputStream); - } catch (IOException e) { - throw new IOError(e); - } - - return tlsCustomizer(customizer -> customizer.keyManager(new ByteArrayInputStream(keyCertChain), - new ByteArrayInputStream(key), - keyPassword)); + return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, + keyPassword); } /** * Configures SSL or TLS for client certificate authentication with the specified cleartext * {@link PrivateKey} and {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyCertChain); @@ -369,7 +376,10 @@ public ClientFactoryBuilder tls(PrivateKey key, X509Certificate... keyCertChain) /** * Configures SSL or TLS for client certificate authentication with the specified cleartext * {@link PrivateKey} and {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#of(PrivateKey, Iterable)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(PrivateKey key, Iterable keyCertChain) { return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyCertChain); @@ -378,7 +388,11 @@ public ClientFactoryBuilder tls(PrivateKey key, Iterable keyCertChain) { - requireNonNull(key, "key"); - requireNonNull(keyCertChain, "keyCertChain"); - - for (X509Certificate keyCert : keyCertChain) { - requireNonNull(keyCert, "keyCertChain contains null."); - } + return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } - return tlsCustomizer(customizer -> customizer.keyManager(key, keyPassword, keyCertChain)); + /** + * Configures SSL or TLS for client certificate authentication with the specified {@link TlsKeyPair}. + */ + @Override + public ClientFactoryBuilder tls(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return tlsCustomizer(customizer -> customizer.keyManager(tlsKeyPair.privateKey(), + tlsKeyPair.certificateChain())); } /** @@ -420,6 +440,8 @@ public ClientFactoryBuilder tls(KeyManagerFactory keyManagerFactory) { @Override public ClientFactoryBuilder tlsCustomizer(Consumer tlsCustomizer) { requireNonNull(tlsCustomizer, "tlsCustomizer"); + ensureNoTlsProvider(); + staticTlsSettingsSet = true; @SuppressWarnings("unchecked") final ClientFactoryOptionValue> oldTlsCustomizerValue = (ClientFactoryOptionValue>) @@ -439,6 +461,44 @@ public ClientFactoryBuilder tlsCustomizer(Consumer tl return this; } + /** + * Sets the {@link TlsProvider} that provides {@link TlsKeyPair}s for client certificate authentication. + *

+     * ClientFactory
+     *   .builder()
+     *   .tlsProvider(
+     *     TlsProvider.builder()
+     *                // Set the default key pair.
+     *                .keyPair(TlsKeyPair.of(...))
+     *                // Set the key pair for "example.com".
+     *                .keyPair("example.com", TlsKeyPair.of(...))
+     *                .build())
+     * 
+ */ + @UnstableApi + public ClientFactoryBuilder tlsProvider(TlsProvider tlsProvider) { + requireNonNull(tlsProvider, "tlsProvider"); + checkState(!staticTlsSettingsSet, + "Cannot configure the TlsProvider because static TLS settings have been set already."); + this.tlsProvider = tlsProvider; + tlsConfig = null; + return this; + } + + /** + * Sets the {@link TlsProvider} that provides {@link TlsKeyPair}s for client certificate authentication. + */ + @UnstableApi + public ClientFactoryBuilder tlsProvider(TlsProvider tlsProvider, ClientTlsConfig tlsConfig) { + tlsProvider(tlsProvider); + this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig"); + return this; + } + + private void ensureNoTlsProvider() { + checkState(tlsProvider == null, "Cannot configure TLS settings because a TlsProvider has been set."); + } + /** * Allows the bad cipher suites listed in * RFC7540 for TLS handshake. @@ -959,10 +1019,17 @@ private ClientFactoryOptions buildOptions() { return ClientFactoryOptions.ADDRESS_RESOLVER_GROUP_FACTORY.newValue(addressResolverGroupFactory); }); - if (tlsNoVerifySet) { - tlsCustomizer(b -> b.trustManager(InsecureTrustManagerFactory.INSTANCE)); - } else if (!insecureHosts.isEmpty()) { - tlsCustomizer(b -> b.trustManager(IgnoreHostsTrustManager.of(insecureHosts))); + if (tlsProvider != null) { + option(ClientFactoryOptions.TLS_PROVIDER, tlsProvider); + if (tlsConfig != null) { + option(ClientFactoryOptions.TLS_CONFIG, tlsConfig); + } + } else { + if (tlsNoVerifySet) { + tlsCustomizer(b -> b.trustManager(InsecureTrustManagerFactory.INSTANCE)); + } else if (!insecureHosts.isEmpty()) { + tlsCustomizer(b -> b.trustManager(IgnoreHostsTrustManager.of(insecureHosts))); + } } final ClientFactoryOptions newOptions = ClientFactoryOptions.of(options.values()); diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java index 5042a49bdd5..ae12b569dd6 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java @@ -35,6 +35,8 @@ import com.linecorp.armeria.common.Flags; import com.linecorp.armeria.common.Http1HeaderNaming; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.outlier.OutlierDetection; import com.linecorp.armeria.common.util.AbstractOptions; @@ -46,6 +48,7 @@ import io.netty.channel.ChannelPipeline; import io.netty.channel.EventLoop; import io.netty.channel.EventLoopGroup; +import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.resolver.AddressResolverGroup; @@ -107,6 +110,21 @@ public final class ClientFactoryOptions public static final ClientFactoryOption TLS_ENGINE_TYPE = ClientFactoryOption.define("tlsEngineType", Flags.tlsEngineType()); + /** + * The {@link TlsProvider} which provides the {@link TlsKeyPair} to create the + * {@link SslContext} for TLS handshake. + */ + @UnstableApi + public static final ClientFactoryOption TLS_PROVIDER = + ClientFactoryOption.define("TLS_PROVIDER", NullTlsProvider.INSTANCE); + + /** + * Ths {@link ClientTlsConfig} which is used to configure the client-side TLS. + */ + @UnstableApi + public static final ClientFactoryOption TLS_CONFIG = + ClientFactoryOption.define("TLS_CONFIG", ClientTlsConfig.NOOP); + /** * The factory that creates an {@link AddressResolverGroup} which resolves remote addresses into * {@link InetSocketAddress}es. @@ -654,6 +672,23 @@ public TlsEngineType tlsEngineType() { return get(TLS_ENGINE_TYPE); } + /** + * Returns the {@link TlsProvider} which provides the {@link TlsKeyPair} that is used to create the + * {@link SslContext} for TLS handshake. + */ + @UnstableApi + public TlsProvider tlsProvider() { + return get(TLS_PROVIDER); + } + + /** + * Returns the {@link ClientTlsConfig} which is used to configure the client-side {@link SslContext}. + */ + @UnstableApi + public ClientTlsConfig tlsConfig() { + return get(TLS_CONFIG); + } + /** * The {@link Consumer} that customizes the Netty {@link ChannelPipeline}. * This customizer is run right before {@link ChannelPipeline#connect(SocketAddress)} diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java new file mode 100644 index 00000000000..dea42bafd1c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.SslContextBuilder; + +/** + * Provides client-side TLS configuration for {@link TlsProvider}. + */ +@UnstableApi +public final class ClientTlsConfig extends AbstractTlsConfig { + + static final ClientTlsConfig NOOP = builder().build(); + + /** + * Returns a new {@link ClientTlsConfigBuilder}. + */ + public static ClientTlsConfigBuilder builder() { + return new ClientTlsConfigBuilder(); + } + + private final boolean tlsNoVerifySet; + private final Set insecureHosts; + + ClientTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + Consumer tlsCustomizer, boolean tlsNoVerifySet, + Set insecureHosts) { + super(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + this.tlsNoVerifySet = tlsNoVerifySet; + this.insecureHosts = insecureHosts; + } + + /** + * Returns whether the verification of server's TLS certificate chain is disabled. + */ + public boolean tlsNoVerifySet() { + return tlsNoVerifySet; + } + + /** + * Returns the hosts for which the verification of server's TLS certificate chain is disabled. + */ + public Set insecureHosts() { + return insecureHosts; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ClientTlsConfig)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + final ClientTlsConfig that = (ClientTlsConfig) o; + return tlsNoVerifySet == that.tlsNoVerifySet && insecureHosts.equals(that.insecureHosts); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), tlsNoVerifySet, insecureHosts); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("allowsUnsafeCiphers", allowsUnsafeCiphers()) + .add("meterIdPrefix", meterIdPrefix()) + .add("tlsCustomizer", tlsCustomizer()) + .add("tlsNoVerifySet", tlsNoVerifySet) + .add("insecureHosts", insecureHosts) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java new file mode 100644 index 00000000000..06ec1fd8a88 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import com.linecorp.armeria.common.AbstractTlsConfigBuilder; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +/** + * A builder class for creating a {@link ClientTlsConfig}. + */ +@UnstableApi +public final class ClientTlsConfigBuilder extends AbstractTlsConfigBuilder { + + private boolean tlsNoVerifySet; + private final Set insecureHosts = new HashSet<>(); + + ClientTlsConfigBuilder() {} + + /** + * Disables the verification of server's TLS certificate chain. If you want to disable verification for + * only specific hosts, use {@link #tlsNoVerifyHosts(String...)}. + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see InsecureTrustManagerFactory + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerify() { + tlsNoVerifySet = true; + checkState(insecureHosts.isEmpty(), "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + return this; + } + + /** + * Disables the verification of server's TLS certificate chain for specific hosts. If you want to disable + * all verification, use {@link #tlsNoVerify()} . + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerifyHosts(String... insecureHosts) { + requireNonNull(insecureHosts, "insecureHosts"); + return tlsNoVerifyHosts(ImmutableList.copyOf(insecureHosts)); + } + + /** + * Disables the verification of server's TLS certificate chain for specific hosts. If you want to disable + * all verification, use {@link #tlsNoVerify()} . + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerifyHosts(Iterable insecureHosts) { + requireNonNull(insecureHosts, "insecureHosts"); + checkState(!tlsNoVerifySet, "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + insecureHosts.forEach(this.insecureHosts::add); + return this; + } + + /** + * Returns a newly-created {@link ClientTlsConfig} based on the properties of this builder. + */ + public ClientTlsConfig build() { + return new ClientTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), tlsCustomizer(), + tlsNoVerifySet, ImmutableSet.copyOf(insecureHosts)); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java index 57e9fa13261..2bd867e93f5 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java @@ -55,6 +55,7 @@ import com.linecorp.armeria.common.util.AsyncCloseableSupport; import com.linecorp.armeria.internal.client.HttpSession; import com.linecorp.armeria.internal.client.PooledChannel; +import com.linecorp.armeria.internal.common.SslContextFactory; import com.linecorp.armeria.internal.common.util.ChannelUtil; import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; @@ -101,6 +102,7 @@ final class HttpChannelPool implements AsyncCloseable { HttpChannelPool(HttpClientFactory clientFactory, EventLoop eventLoop, SslContext sslCtxHttp1Or2, SslContext sslCtxHttp1Only, + @Nullable SslContextFactory sslContextFactory, ConnectionPoolListener listener) { this.clientFactory = clientFactory; this.eventLoop = eventLoop; @@ -116,7 +118,8 @@ final class HttpChannelPool implements AsyncCloseable { .get(ChannelOption.CONNECT_TIMEOUT_MILLIS); assert connectTimeoutMillisBoxed != null; connectTimeoutMillis = connectTimeoutMillisBoxed; - bootstraps = new Bootstraps(clientFactory, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only); + bootstraps = new Bootstraps(clientFactory, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only, + sslContextFactory); } private void configureProxy(Channel ch, ProxyConfig proxyConfig, SessionProtocol desiredProtocol) { @@ -157,8 +160,11 @@ private void configureProxy(Channel ch, ProxyConfig proxyConfig, SessionProtocol ch.pipeline().addFirst(proxyHandler); if (proxyConfig instanceof ConnectProxyConfig && ((ConnectProxyConfig) proxyConfig).useTls()) { - final SslContext sslCtx = bootstraps.determineSslContext(desiredProtocol); + final SslContext sslCtx = bootstraps.getOrCreateSslContext(proxyAddress, desiredProtocol); ch.pipeline().addFirst(sslCtx.newHandler(ch.alloc())); + if (bootstraps.shouldReleaseSslContext(sslCtx)) { + ch.closeFuture().addListener(unused -> bootstraps.releaseSslContext(sslCtx)); + } } } @@ -382,7 +388,7 @@ void connect(SocketAddress remoteAddress, SessionProtocol desiredProtocol, @Nullable ClientConnectionTimingsBuilder timingsBuilder) { final Bootstrap bootstrap; try { - bootstrap = bootstraps.get(remoteAddress, desiredProtocol, serializationFormat); + bootstrap = bootstraps.getOrCreate(remoteAddress, desiredProtocol, serializationFormat); } catch (Exception e) { sessionPromise.tryFailure(e); return; diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java index ee65a69f054..d4d7aacb279 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java @@ -36,7 +36,6 @@ import org.slf4j.LoggerFactory; import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; import com.google.common.collect.MapMaker; import com.linecorp.armeria.client.endpoint.EndpointGroup; @@ -47,6 +46,7 @@ import com.linecorp.armeria.common.Scheme; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.metric.MeterIdPrefix; import com.linecorp.armeria.common.metric.MoreMeterBinders; @@ -56,6 +56,7 @@ import com.linecorp.armeria.common.util.TlsEngineType; import com.linecorp.armeria.common.util.TransportType; import com.linecorp.armeria.internal.common.RequestTargetCache; +import com.linecorp.armeria.internal.common.SslContextFactory; import com.linecorp.armeria.internal.common.util.ChannelUtil; import com.linecorp.armeria.internal.common.util.SslContextUtil; @@ -89,12 +90,12 @@ final class HttpClientFactory implements ClientFactory { private static void setupTlsMetrics(List certificates, MeterRegistry registry) { final MeterIdPrefix meterIdPrefix = new MeterIdPrefix("armeria.client"); - try { - MoreMeterBinders.certificateMetrics(certificates, meterIdPrefix) - .bindTo(registry); - } catch (Exception ex) { - logger.warn("Failed to set up TLS certificate metrics: {}", certificates, ex); - } + try { + MoreMeterBinders.certificateMetrics(certificates, meterIdPrefix) + .bindTo(registry); + } catch (Exception ex) { + logger.warn("Failed to set up TLS certificate metrics: {}", certificates, ex); + } } private final EventLoopGroup workerGroup; @@ -104,6 +105,8 @@ private static void setupTlsMetrics(List certificates, MeterReg private final Bootstrap unixBaseBootstrap; private final SslContext sslCtxHttp1Or2; private final SslContext sslCtxHttp1Only; + @Nullable + private final SslContextFactory sslContextFactory; private final AddressResolverGroup addressResolverGroup; private final int http2InitialConnectionWindowSize; private final int http2InitialStreamWindowSize; @@ -176,19 +179,31 @@ private static void setupTlsMetrics(List certificates, MeterReg unixBaseBootstrap = null; } - final ImmutableList> tlsCustomizers = - ImmutableList.of(options.tlsCustomizer()); + final Consumer tlsCustomizer = + options.tlsCustomizer(); final boolean tlsAllowUnsafeCiphers = options.tlsAllowUnsafeCiphers(); final List keyCertChainCaptor = new ArrayList<>(); final TlsEngineType tlsEngineType = options.tlsEngineType(); sslCtxHttp1Or2 = SslContextUtil .createSslContext(SslContextBuilder::forClient, false, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, keyCertChainCaptor); + tlsAllowUnsafeCiphers, tlsCustomizer, keyCertChainCaptor); sslCtxHttp1Only = SslContextUtil .createSslContext(SslContextBuilder::forClient, true, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, keyCertChainCaptor); + tlsAllowUnsafeCiphers, tlsCustomizer, keyCertChainCaptor); setupTlsMetrics(keyCertChainCaptor, options.meterRegistry()); + final TlsProvider tlsProvider = options.tlsProvider(); + if (tlsProvider != NullTlsProvider.INSTANCE) { + ClientTlsConfig clientTlsConfig = options.tlsConfig(); + if (clientTlsConfig == ClientTlsConfig.NOOP) { + clientTlsConfig = null; + } + sslContextFactory = new SslContextFactory(tlsProvider, options.tlsEngineType(), clientTlsConfig, + options.meterRegistry()); + } else { + sslContextFactory = null; + } + http2InitialConnectionWindowSize = options.http2InitialConnectionWindowSize(); http2InitialStreamWindowSize = options.http2InitialStreamWindowSize(); http2MaxFrameSize = options.http2MaxFrameSize(); @@ -495,6 +510,13 @@ HttpChannelPool pool(EventLoop eventLoop) { return pools.computeIfAbsent(eventLoop, e -> new HttpChannelPool(this, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only, + sslContextFactory, connectionPoolListener())); } + + @VisibleForTesting + @Nullable + SslContextFactory sslContextFactory() { + return sslContextFactory; + } } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java index 4d742e06a24..7f119e28da4 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java @@ -159,7 +159,7 @@ private enum HttpPreference { HttpClientPipelineConfigurator(HttpClientFactory clientFactory, boolean webSocket, SessionProtocol sessionProtocol, - @Nullable SslContext sslCtx) { + SslContext sslCtx) { this.clientFactory = clientFactory; this.webSocket = webSocket; diff --git a/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java b/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java new file mode 100644 index 00000000000..90519034de4 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; + +enum NullTlsProvider implements TlsProvider { + INSTANCE; + + @Override + public @Nullable TlsKeyPair keyPair(String hostname) { + return null; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfig.java b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfig.java new file mode 100644 index 00000000000..ff5dd754fd3 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfig.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import java.util.Objects; +import java.util.function.Consumer; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.SslContextBuilder; + +/** + * Provides common configuration for TLS. + */ +@UnstableApi +public abstract class AbstractTlsConfig { + + private final boolean allowsUnsafeCiphers; + + @Nullable + private final MeterIdPrefix meterIdPrefix; + + private final Consumer tlsCustomizer; + + /** + * Creates a new instance. + */ + protected AbstractTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + Consumer tlsCustomizer) { + this.allowsUnsafeCiphers = allowsUnsafeCiphers; + this.meterIdPrefix = meterIdPrefix; + this.tlsCustomizer = tlsCustomizer; + } + + /** + * Returns whether to allow the bad cipher suites listed in + * RFC7540 for TLS handshake. + */ + public final boolean allowsUnsafeCiphers() { + return allowsUnsafeCiphers; + } + + /** + * Sets the {@link MeterIdPrefix} for the TLS metrics. + */ + @Nullable + public final MeterIdPrefix meterIdPrefix() { + return meterIdPrefix; + } + + /** + * Returns the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder}. + */ + public final Consumer tlsCustomizer() { + return tlsCustomizer; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AbstractTlsConfig)) { + return false; + } + final AbstractTlsConfig that = (AbstractTlsConfig) o; + return allowsUnsafeCiphers == that.allowsUnsafeCiphers && + Objects.equals(meterIdPrefix, that.meterIdPrefix) && + tlsCustomizer.equals(that.tlsCustomizer); + } + + @Override + public int hashCode() { + return Objects.hash(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java new file mode 100644 index 00000000000..77e51eb2df0 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java @@ -0,0 +1,123 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.util.function.Consumer; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.SslContextBuilder; + +/** + * A skeletal builder implementation for {@link TlsProvider}. + */ +@UnstableApi +public abstract class AbstractTlsConfigBuilder> { + + private static final Consumer NOOP = b -> {}; + + private boolean allowsUnsafeCiphers; + private Consumer tlsCustomizer = NOOP; + @Nullable + private MeterIdPrefix meterIdPrefix; + + /** + * Creates a new instance. + */ + protected AbstractTlsConfigBuilder() {} + + /** + * Allows the bad cipher suites listed in + * RFC7540 for TLS handshake. + * + *

Note that enabling this option increases the security risk of your connection. + * Use it only when you must communicate with a legacy system that does not support + * secure cipher suites. + * See Section 9.2.2, RFC7540 for + * more information. This option is disabled by default. + * + * @param allowsUnsafeCiphers Whether to allow the unsafe ciphers + * + * @deprecated It's not recommended to enable this option. Use it only when you have no other way to + * communicate with an insecure peer than this. + */ + @Deprecated + public SELF allowsUnsafeCiphers(boolean allowsUnsafeCiphers) { + this.allowsUnsafeCiphers = allowsUnsafeCiphers; + return self(); + } + + /** + * Returns whether to allow the bad cipher suites listed in + * RFC7540 for TLS handshake. + */ + protected final boolean allowsUnsafeCiphers() { + return allowsUnsafeCiphers; + } + + /** + * Adds the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder} that will be + * applied to the SSL session. For example, use {@link SslContextBuilder#trustManager(TrustManagerFactory)} + * to configure a custom server CA or {@link SslContextBuilder#keyManager(KeyManagerFactory)} to configure + * a client certificate for SSL authorization. + */ + public SELF tlsCustomizer(Consumer tlsCustomizer) { + requireNonNull(tlsCustomizer, "tlsCustomizer"); + if (this.tlsCustomizer == NOOP) { + //noinspection unchecked + this.tlsCustomizer = (Consumer) tlsCustomizer; + } else { + this.tlsCustomizer = this.tlsCustomizer.andThen(tlsCustomizer); + } + return self(); + } + + /** + * Returns the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder}. + */ + protected final Consumer tlsCustomizer() { + return tlsCustomizer; + } + + /** + * Sets the {@link MeterIdPrefix} for the TLS metrics. + */ + public SELF meterIdPrefix(MeterIdPrefix meterIdPrefix) { + this.meterIdPrefix = requireNonNull(meterIdPrefix, "meterIdPrefix"); + return self(); + } + + /** + * Returns the {@link MeterIdPrefix} for TLS metrics. + */ + @Nullable + protected final MeterIdPrefix meterIdPrefix() { + return meterIdPrefix; + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/Flags.java b/core/src/main/java/com/linecorp/armeria/common/Flags.java index b3327c7d06d..3fc9c0acbcd 100644 --- a/core/src/main/java/com/linecorp/armeria/common/Flags.java +++ b/core/src/main/java/com/linecorp/armeria/common/Flags.java @@ -641,7 +641,7 @@ private static void detectTlsEngineAndDumpOpenSslInfo() { /* forceHttp1 */ false, tlsEngineType, /* tlsAllowUnsafeCiphers */ false, - ImmutableList.of(), null).newEngine(ByteBufAllocator.DEFAULT); + null, null).newEngine(ByteBufAllocator.DEFAULT); logger.info("All available SSL protocols: {}", ImmutableList.copyOf(engine.getSupportedProtocols())); logger.info("Default enabled SSL protocols: {}", SslContextUtil.DEFAULT_PROTOCOLS); diff --git a/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java new file mode 100644 index 00000000000..c350d228f19 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java @@ -0,0 +1,106 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.internal.common.TlsProviderUtil.normalizeHostname; +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class MappedTlsProvider implements TlsProvider { + + private final Map tlsKeyPairs; + private final Map> trustedCertificates; + + MappedTlsProvider(Map tlsKeyPairs, + Map> trustedCertificates) { + this.tlsKeyPairs = tlsKeyPairs; + this.trustedCertificates = trustedCertificates; + } + + @Nullable + @Override + public TlsKeyPair keyPair(String hostname) { + requireNonNull(hostname, "hostname"); + return find(hostname, tlsKeyPairs); + } + + @Override + public List trustedCertificates(String hostname) { + final List certs = find(hostname, trustedCertificates); + return firstNonNull(certs, ImmutableList.of()); + } + + @Nullable + private static T find(String hostname, Map map) { + if ("*".equals(hostname)) { + return map.get("*"); + } + hostname = normalizeHostname(hostname); + + T value = map.get(hostname); + if (value != null) { + return value; + } + + // No exact match, let's try a wildcard match. + final int idx = hostname.indexOf('.'); + if (idx != -1) { + value = map.get(hostname.substring(idx)); + if (value != null) { + return value; + } + } + // Try to find the default one. + return map.get("*"); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MappedTlsProvider)) { + return false; + } + final MappedTlsProvider that = (MappedTlsProvider) o; + return tlsKeyPairs.equals(that.tlsKeyPairs) && + trustedCertificates.equals(that.trustedCertificates); + } + + @Override + public int hashCode() { + return Objects.hash(tlsKeyPairs, trustedCertificates); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tlsKeyPairs", tlsKeyPairs) + .add("trustedCertificates", trustedCertificates) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java new file mode 100644 index 00000000000..b254a85e1f9 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import com.google.common.base.MoreObjects; + +final class StaticTlsProvider implements TlsProvider { + + private final TlsKeyPair tlsKeyPair; + + StaticTlsProvider(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + this.tlsKeyPair = tlsKeyPair; + } + + @Override + public TlsKeyPair keyPair(String hostname) { + return tlsKeyPair; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StaticTlsProvider)) { + return false; + } + final StaticTlsProvider that = (StaticTlsProvider) o; + return tlsKeyPair.equals(that.tlsKeyPair); + } + + @Override + public int hashCode() { + return tlsKeyPair.hashCode(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("tlsKeyPair", tlsKeyPair) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java b/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java new file mode 100644 index 00000000000..51f5f2a8092 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java @@ -0,0 +1,178 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static com.linecorp.armeria.internal.common.util.CertificateUtil.toPrivateKey; +import static com.linecorp.armeria.internal.common.util.CertificateUtil.toX509Certificates; +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.InputStream; +import java.security.KeyException; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SystemInfo; +import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; + +/** + * A pair of a {@link PrivateKey} and a {@link X509Certificate} chain. + */ +@UnstableApi +public final class TlsKeyPair { + + /** + * Creates a new {@link TlsKeyPair} from the specified key {@link InputStream}, and certificate chain + * {@link InputStream}. + */ + public static TlsKeyPair of(InputStream keyInputStream, InputStream certificateChainInputStream) { + return of(keyInputStream, null, certificateChainInputStream); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key {@link InputStream}, key password + * {@link InputStream} and certificate chain {@link InputStream}. + */ + public static TlsKeyPair of(InputStream keyInputStream, @Nullable String keyPassword, + InputStream certificateChainInputStream) { + requireNonNull(keyInputStream, "keyInputStream"); + requireNonNull(certificateChainInputStream, "certificateChainInputStream"); + try { + final List certs = toX509Certificates(certificateChainInputStream); + final PrivateKey key = toPrivateKey(keyInputStream, keyPassword); + return of(key, certs); + } catch (CertificateException | KeyException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key file and certificate chain file. + */ + public static TlsKeyPair of(File keyFile, File certificateChainFile) { + return of(keyFile, null, certificateChainFile); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key file, key password and certificate chain + * file. + */ + public static TlsKeyPair of(File keyFile, @Nullable String keyPassword, File certificateChainFile) { + requireNonNull(keyFile, "keyFile"); + requireNonNull(certificateChainFile, "certificateChainFile"); + try { + final List certs = toX509Certificates(certificateChainFile); + final PrivateKey key = toPrivateKey(keyFile, keyPassword); + return of(key, certs); + } catch (CertificateException | KeyException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Creates a new {@link TlsKeyPair} from the specified {@link PrivateKey} and {@link X509Certificate}s. + */ + public static TlsKeyPair of(PrivateKey key, X509Certificate... certificateChain) { + requireNonNull(certificateChain, "certificateChain"); + return of(key, ImmutableList.copyOf(certificateChain)); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified {@link PrivateKey} and {@link X509Certificate}s. + */ + public static TlsKeyPair of(PrivateKey key, Iterable certificateChain) { + requireNonNull(key, "key"); + requireNonNull(certificateChain, "certificateChain"); + return new TlsKeyPair(key, ImmutableList.copyOf(certificateChain)); + } + + /** + * Generates a self-signed certificate for the specified {@code hostname}. + */ + public static TlsKeyPair ofSelfSigned(String hostname) { + requireNonNull(hostname, "hostname"); + try { + final SelfSignedCertificate ssc = new SelfSignedCertificate(hostname); + return of(ssc.key(), ssc.cert()); + } catch (CertificateException e) { + throw new IllegalStateException("Failed to create a self-signed certificate for " + hostname, e); + } + } + + /** + * Generates a self-signed certificate for the local hostname. + */ + public static TlsKeyPair ofSelfSigned() { + return ofSelfSigned(SystemInfo.hostname()); + } + + private final PrivateKey privateKey; + private final List certificateChain; + + private TlsKeyPair(PrivateKey privateKey, List certificateChain) { + this.privateKey = privateKey; + this.certificateChain = certificateChain; + } + + /** + * Returns the private key. + */ + public PrivateKey privateKey() { + return privateKey; + } + + /** + * Returns the certificate chain. + */ + public List certificateChain() { + return certificateChain; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof TlsKeyPair)) { + return false; + } + + final TlsKeyPair that = (TlsKeyPair) o; + return privateKey.equals(that.privateKey) && certificateChain.equals(that.certificateChain); + } + + @Override + public int hashCode() { + return privateKey.hashCode() * 31 + certificateChain.hashCode(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("privateKey", "****") + .add("certificateChain", certificateChain) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java new file mode 100644 index 00000000000..d7256fcd217 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java @@ -0,0 +1,87 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Provides {@link TlsKeyPair}s for TLS handshakes. + */ +@UnstableApi +@FunctionalInterface +public interface TlsProvider { + + /** + * Returns a {@link TlsProvider} which always returns the specified {@link TlsKeyPair}. + */ + static TlsProvider of(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return builder().keyPair(tlsKeyPair).build(); + } + + /** + * Returns a newly created {@link TlsProviderBuilder}. + * + *

Example usage: + *

{@code
+     * TlsProvider
+     *   .builder()
+     *   // Set the default key pair.
+     *   .keyPair(TlsKeyPair.of(...))
+     *   // Set the key pair for "api.example.com".
+     *   .keyPair("api.example.com", TlsKeyPair.of(...))
+     *   // Set the key pair for "web.example.com".
+     *   .keyPair("web.example.com", TlsKeyPair.of(...))
+     *   .build();
+     * }
+ */ + static TlsProviderBuilder builder() { + return new TlsProviderBuilder(); + } + + /** + * Finds a {@link TlsKeyPair} for the specified {@code hostname}. + * + *

If no matching {@link TlsKeyPair} is found for a hostname, {@code "*"} will be specified to get the + * default {@link TlsKeyPair}. + * If no default {@link TlsKeyPair} is found, {@code null} will be returned. + * + *

Note that this operation is executed in an event loop thread, so it should not be blocked. + */ + @Nullable + TlsKeyPair keyPair(String hostname); + + /** + * Returns trusted certificates for verifying the remote endpoint's certificate. + * + *

If no matching {@link X509Certificate}s are found for a hostname, {@code "*"} will be specified to get + * the default {@link X509Certificate}s. + * The system default will be used if this method returns null. + * + *

Note that this operation is executed in an event loop thread, so it should not be blocked. + */ + @Nullable + default List trustedCertificates(String hostname) { + return null; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java b/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java new file mode 100644 index 00000000000..39af95aa721 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.client.ClientFactoryBuilder; +import com.linecorp.armeria.internal.common.TlsProviderUtil; +import com.linecorp.armeria.server.ServerBuilder; + +/** + * A builder for {@link TlsProvider}. + * + * @see ClientFactoryBuilder#tlsProvider(TlsProvider) + * @see ServerBuilder#tlsProvider(TlsProvider) + */ +public final class TlsProviderBuilder { + + private final ImmutableMap.Builder tlsKeyPairsBuilder = ImmutableMap.builder(); + private final ImmutableMap.Builder> x509CertificateBuilder = + ImmutableMap.builder(); + + /** + * Creates a new instance. + */ + TlsProviderBuilder() {} + + /** + * Sets the {@link TlsKeyPair} for the specified (optionally wildcard) {@code hostname}. + * + *

DNS wildcard is supported as hostname. + * The wildcard will only match one sub-domain deep and only when wildcard is used as the most-left label. + * For example, *.armeria.dev will match foo.armeria.dev but NOT bar.foo.armeria.dev + * + *

Note that {@code "*"} is a special hostname which matches any hostname which may be used to find the + * {@link TlsKeyPair} for the {@linkplain ServerBuilder#defaultVirtualHost() default virtual host}. + * + *

The {@link TlsKeyPair} will be used for + * client certificate authentication + * when it is used for a client. + */ + public TlsProviderBuilder keyPair(String hostname, TlsKeyPair tlsKeyPair) { + requireNonNull(hostname, "hostname"); + requireNonNull(tlsKeyPair, "tlsKeyPair"); + tlsKeyPairsBuilder.put(normalize(hostname), tlsKeyPair); + return this; + } + + /** + * Sets the default {@link TlsKeyPair} which is used when no {@link TlsKeyPair} is specified for a hostname. + * + *

The {@link TlsKeyPair} will be used for + * client certificate authentication + * when it is used for a client. + */ + public TlsProviderBuilder keyPair(TlsKeyPair tlsKeyPair) { + return keyPair("*", tlsKeyPair); + } + + /** + * Sets the specified {@link X509Certificate}s to the trusted certificates that will be used for verifying + * the remote endpoint's certificate. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(String hostname, X509Certificate... trustedCertificates) { + requireNonNull(trustedCertificates, "trustedCertificates"); + return trustedCertificates(hostname, ImmutableList.copyOf(trustedCertificates)); + } + + /** + * Sets the specified {@link X509Certificate}s to the trusted certificates that will be used for verifying + * the specified {@code hostname}'s certificate. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(String hostname, + Iterable trustedCertificates) { + requireNonNull(hostname, "hostname"); + requireNonNull(trustedCertificates, "trustedCertificates"); + x509CertificateBuilder.put(normalize(hostname), ImmutableList.copyOf(trustedCertificates)); + return this; + } + + /** + * Sets the default {@link X509Certificate}s to the trusted certificates that is used for verifying + * the remote endpoint's certificate if no specific trusted certificates are set for a hostname. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(X509Certificate... trustedCertificates) { + requireNonNull(trustedCertificates, "trustedCertificates"); + return trustedCertificates(ImmutableList.copyOf(trustedCertificates)); + } + + /** + * Sets the default {@link X509Certificate}s to the trusted certificates that is used for verifying + * the remote endpoint's certificate if no specific trusted certificates are set for a hostname. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(Iterable trustedCertificates) { + return trustedCertificates("*", trustedCertificates); + } + + private static String normalize(String hostname) { + if ("*".equals(hostname)) { + return "*"; + } else { + return TlsProviderUtil.normalizeHostname(hostname); + } + } + + /** + * Returns a newly-created {@link TlsProvider} instance. + */ + public TlsProvider build() { + final Map keyPairMappings = tlsKeyPairsBuilder.build(); + if (keyPairMappings.isEmpty()) { + throw new IllegalStateException("No TLS key pair is set."); + } + + final Map> trustedCerts = x509CertificateBuilder.build(); + if (keyPairMappings.size() == 1 && keyPairMappings.containsKey("*") && trustedCerts.isEmpty()) { + return new StaticTlsProvider(keyPairMappings.get("*")); + } + + return new MappedTlsProvider(keyPairMappings, trustedCerts); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java b/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java index f8691b12e0e..cf42957a4f6 100644 --- a/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java +++ b/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java @@ -16,8 +16,6 @@ package com.linecorp.armeria.common; -import static java.util.Objects.requireNonNull; - import java.io.File; import java.io.InputStream; import java.security.PrivateKey; @@ -26,8 +24,6 @@ import javax.net.ssl.KeyManagerFactory; -import com.google.common.collect.ImmutableList; - import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -42,62 +38,100 @@ public interface TlsSetters { /** * Configures SSL or TLS with the specified {@code keyCertChainFile} * and cleartext {@code keyFile}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(File keyCertChainFile, File keyFile) { - return tls(keyCertChainFile, keyFile, null); + return tls(TlsKeyPair.of(keyFile, keyCertChainFile)); } /** * Configures SSL or TLS with the specified {@code keyCertChainFile}, * {@code keyFile} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ - TlsSetters tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword); + @Deprecated + default TlsSetters tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { + return tls(TlsKeyPair.of(keyFile, keyPassword, keyCertChainFile)); + } /** * Configures SSL or TLS with the specified {@code keyCertChainInputStream} and * cleartext {@code keyInputStream}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { - return tls(keyCertChainInputStream, keyInputStream, null); + return tls(TlsKeyPair.of(keyInputStream, null, keyCertChainInputStream)); } /** * Configures SSL or TLS of this with the specified {@code keyCertChainInputStream}, * {@code keyInputStream} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ - TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream, - @Nullable String keyPassword); + @Deprecated + default TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream, + @Nullable String keyPassword) { + return tls(TlsKeyPair.of(keyInputStream, keyPassword, keyCertChainInputStream)); + } /** * Configures SSL or TLS with the specified cleartext {@link PrivateKey} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, X509Certificate... keyCertChain) { - return tls(key, null, keyCertChain); + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified cleartext {@link PrivateKey} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, Iterable keyCertChain) { - return tls(key, null, keyCertChain); + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified {@link PrivateKey}, {@code keyPassword} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { - return tls(key, keyPassword, ImmutableList.copyOf(requireNonNull(keyCertChain, "keyCertChain"))); + // keyPassword is not required for PrivateKey since it is not encrypted. + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified {@link PrivateKey}, {@code keyPassword} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. + */ + @Deprecated + default TlsSetters tls(PrivateKey key, @Nullable String keyPassword, + Iterable keyCertChain) { + // keyPassword is not required for PrivateKey since it is not encrypted. + return tls(TlsKeyPair.of(key, keyCertChain)); + } + + /** + * Configures SSL or TLS with the specified {@link TlsKeyPair}. */ - TlsSetters tls(PrivateKey key, @Nullable String keyPassword, - Iterable keyCertChain); + @UnstableApi + TlsSetters tls(TlsKeyPair tlsKeyPair); /** * Configures SSL or TLS with the specified {@link KeyManagerFactory}. diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java b/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java new file mode 100644 index 00000000000..5a7659ec70e --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.metric; + +import java.util.ArrayList; +import java.util.List; + +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; + +abstract class AbstractCloseableMeterBinder implements CloseableMeterBinder { + + private final List closingTasks = new ArrayList<>(); + private final ReentrantShortLock lock = new ReentrantShortLock(); + + protected final void addClosingTask(Runnable closingTask) { + lock.lock(); + try { + closingTasks.add(closingTask); + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + lock.lock(); + try { + for (Runnable task : closingTasks) { + task.run(); + } + closingTasks.clear(); + } finally { + lock.unlock(); + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java b/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java index ed0bc1a6e1c..212526e9eff 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java @@ -23,15 +23,29 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import com.google.common.base.MoreObjects; + import com.linecorp.armeria.internal.common.util.CertificateUtil; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.MeterBinder; -final class CertificateMetrics implements MeterBinder { +/** + * A {@link MeterBinder} that provides metrics for TLS certificates. + * The following stats are currently exported per registered {@link MeterIdPrefix}. + * + *

    + *
  • "tls.certificate.validity" (gauge) - 1 if TLS certificate is in validity period, 0 if certificate + * is not in validity period
  • + *
  • "tls.certificate.validity.days" (gauge) - Duration in days before TLS certificate expires, which + * becomes -1 if certificate is expired
  • + *
+ */ +public final class CertificateMetrics extends AbstractCloseableMeterBinder { private final List certificates; private final MeterIdPrefix meterIdPrefix; @@ -43,33 +57,55 @@ final class CertificateMetrics implements MeterBinder { @Override public void bindTo(MeterRegistry registry) { + final List meters = new ArrayList<>(certificates.size() * 2); for (X509Certificate certificate : certificates) { final String commonName = firstNonNull(CertificateUtil.getCommonName(certificate), ""); - Gauge.builder(meterIdPrefix.name("tls.certificate.validity"), certificate, x509Cert -> { - try { - x509Cert.checkValidity(); - } catch (CertificateExpiredException | CertificateNotYetValidException e) { - return 0; - } - return 1; - }) - .description("1 if TLS certificate is in validity period, 0 if certificate is not in " + - "validity period") - .tags("common.name", commonName) - .tags(meterIdPrefix.tags()) - .register(registry); + final Gauge validityMeter = + Gauge.builder(meterIdPrefix.name("tls.certificate.validity"), certificate, x509Cert -> { + try { + x509Cert.checkValidity(); + } catch (CertificateExpiredException | CertificateNotYetValidException e) { + return 0; + } + return 1; + }) + .description( + "1 if TLS certificate is in validity period, 0 if certificate is not in " + + "validity period") + .tags("common.name", commonName) + .tags(meterIdPrefix.tags()) + .register(registry); + meters.add(validityMeter); - Gauge.builder(meterIdPrefix.name("tls.certificate.validity.days"), certificate, x509Cert -> { - final Duration diff = Duration.between(Instant.now(), - x509Cert.getNotAfter().toInstant()); - return diff.isNegative() ? -1 : diff.toDays(); - }) - .description("Duration in days before TLS certificate expires, which becomes -1 " + - "if certificate is expired") - .tags("common.name", commonName) - .tags(meterIdPrefix.tags()) - .register(registry); + final Gauge validityDaysMeter = + Gauge.builder(meterIdPrefix.name("tls.certificate.validity.days"), certificate, + x509Cert -> { + final Instant notAfter = x509Cert.getNotAfter().toInstant(); + final Duration diff = + Duration.between(Instant.now(), notAfter); + return diff.toDays(); + }) + .description("Duration in days before TLS certificate expires, which becomes -1 " + + "if certificate is expired") + .tags("common.name", commonName) + .tags(meterIdPrefix.tags()) + .register(registry); + meters.add(validityDaysMeter); } + + addClosingTask(() -> { + for (Gauge meter : meters) { + registry.remove(meter); + } + }); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("certificates", certificates) + .add("meterIdPrefix", meterIdPrefix) + .toString(); } } diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java b/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java new file mode 100644 index 00000000000..ac0f7fb5943 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java @@ -0,0 +1,31 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.metric; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SafeCloseable; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; + +/** + * A {@link MeterBinder} that cleans up the registered metrics by + * {@link MeterBinder#bindTo(MeterRegistry)} via {@link SafeCloseable#close()}. + */ +@UnstableApi +public interface CloseableMeterBinder extends MeterBinder, SafeCloseable { +} diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java b/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java index 35eac7a8b64..760486b7b81 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java @@ -41,7 +41,7 @@ * - the total number of IO tasks waiting to be run on event loops * **/ -final class EventLoopMetrics implements MeterBinder { +public final class EventLoopMetrics extends AbstractCloseableMeterBinder { private final EventLoopGroup eventLoopGroup; private final MeterIdPrefix idPrefix; @@ -58,6 +58,8 @@ final class EventLoopMetrics implements MeterBinder { public void bindTo(MeterRegistry registry) { final Self metrics = MicrometerUtil.register(registry, idPrefix, Self.class, Self::new); metrics.add(eventLoopGroup); + + addClosingTask(() -> metrics.remove(eventLoopGroup)); } /** @@ -79,6 +81,10 @@ void add(EventLoopGroup eventLoopGroup) { registry.add(eventLoopGroup); } + void remove(EventLoopGroup eventLoopGroup) { + registry.remove(eventLoopGroup); + } + double numWorkers() { int result = 0; for (EventLoopGroup group : registry) { @@ -97,7 +103,7 @@ void add(EventLoopGroup eventLoopGroup) { for (EventLoopGroup group : registry) { // Purge event loop groups that were shutdown. if (group.isShutdown()) { - registry.remove(group); + remove(group); continue; } for (EventExecutor eventLoop : group) { diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java b/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java index f687b914efc..1e569e408cc 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java @@ -32,7 +32,7 @@ import io.netty.channel.EventLoopGroup; /** - * Provides useful {@link MeterBinder}s to monitor various Armeria components. + * Provides useful {@link MeterBinder}s to monitor various Armeria components. */ public final class MoreMeterBinders { @@ -47,7 +47,7 @@ public final class MoreMeterBinders { * */ @UnstableApi - public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String name) { + public static CloseableMeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String name) { requireNonNull(name, "name"); return eventLoopMetrics(eventLoopGroup, new MeterIdPrefix("armeria.netty." + name)); } @@ -63,7 +63,8 @@ public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String * */ @UnstableApi - public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, + MeterIdPrefix meterIdPrefix) { return new EventLoopMetrics(eventLoopGroup, meterIdPrefix); } @@ -82,7 +83,8 @@ public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, MeterI * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(X509Certificate certificate, MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder certificateMetrics(X509Certificate certificate, + MeterIdPrefix meterIdPrefix) { requireNonNull(certificate, "certificate"); return certificateMetrics(ImmutableList.of(certificate), meterIdPrefix); } @@ -102,8 +104,8 @@ public static MeterBinder certificateMetrics(X509Certificate certificate, MeterI * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(Iterable certificates, - MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder certificateMetrics(Iterable certificates, + MeterIdPrefix meterIdPrefix) { requireNonNull(certificates, "certificates"); requireNonNull(meterIdPrefix, "meterIdPrefix"); return new CertificateMetrics(ImmutableList.copyOf(certificates), meterIdPrefix); @@ -124,7 +126,7 @@ public static MeterBinder certificateMetrics(Iterable * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefix meterIdPrefix) + public static CloseableMeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefix meterIdPrefix) throws CertificateException { requireNonNull(keyCertChainFile, "keyCertChainFile"); return certificateMetrics(CertificateUtil.toX509Certificates(keyCertChainFile), meterIdPrefix); @@ -145,7 +147,8 @@ public static MeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefi * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(InputStream keyCertChainFile, MeterIdPrefix meterIdPrefix) + public static CloseableMeterBinder certificateMetrics(InputStream keyCertChainFile, + MeterIdPrefix meterIdPrefix) throws CertificateException { requireNonNull(keyCertChainFile, "keyCertChainFile"); return certificateMetrics(CertificateUtil.toX509Certificates(keyCertChainFile), meterIdPrefix); diff --git a/core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java b/core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java similarity index 70% rename from core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java rename to core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java index 276ac650db7..bb3943a409d 100644 --- a/core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java @@ -1,35 +1,20 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2024 LINE Corporation * - * LINE Corporation 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: + * LINE Corporation 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: * - * https://www.apache.org/licenses/LICENSE-2.0 + * https://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. - */ -/* - * Copyright (C) 2020 Square, Inc. - * - * Licensed 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 - * - * https://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. + * 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 com.linecorp.armeria.client; +package com.linecorp.armeria.internal.common; import static java.util.Objects.requireNonNull; @@ -51,7 +36,7 @@ /** * An implementation of {@link X509ExtendedTrustManager} that skips verification on the list of allowed hosts. */ -final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { +public final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { // Forked from okhttp-4.9.0 // https://github.com/square/okhttp/blob/1364ea44ae1f1c4b5a1cc32e4e7b51d23cb78517/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/InsecureExtendedTrustManager.kt @@ -59,7 +44,7 @@ final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { /** * Returns new {@link IgnoreHostsTrustManager} instance. */ - static IgnoreHostsTrustManager of(Set insecureHosts) { + public static IgnoreHostsTrustManager of(Set insecureHosts) { X509ExtendedTrustManager delegate = null; try { final TrustManagerFactory trustManagerFactory = TrustManagerFactory @@ -82,7 +67,7 @@ static IgnoreHostsTrustManager of(Set insecureHosts) { private final X509ExtendedTrustManager delegate; private final Set insecureHosts; - IgnoreHostsTrustManager(X509ExtendedTrustManager delegate, Set insecureHosts) { + public IgnoreHostsTrustManager(X509ExtendedTrustManager delegate, Set insecureHosts) { this.delegate = delegate; this.insecureHosts = insecureHosts; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java b/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java new file mode 100644 index 00000000000..e68ddbfaf07 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java @@ -0,0 +1,337 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.internal.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.internal.common.util.SslContextUtil.createSslContext; + +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientTlsConfig; +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.metric.CloseableMeterBinder; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.common.metric.MoreMeterBinders; +import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; +import com.linecorp.armeria.server.ServerTlsConfig; + +import io.micrometer.core.instrument.MeterRegistry; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.ReferenceCountUtil; + +public final class SslContextFactory { + + private static final MeterIdPrefix SERVER_METER_ID_PREFIX = + new MeterIdPrefix("armeria.server", "hostname.pattern", "UNKNOWN"); + private static final MeterIdPrefix CLIENT_METER_ID_PREFIX = + new MeterIdPrefix("armeria.client"); + + private final Map cache = new HashMap<>(); + private final Map reverseCache = new HashMap<>(); + + private final TlsProvider tlsProvider; + private final TlsEngineType engineType; + private final MeterRegistry meterRegistry; + @Nullable + private final AbstractTlsConfig tlsConfig; + @Nullable + private final MeterIdPrefix meterIdPrefix; + private final boolean allowsUnsafeCiphers; + + private final ReentrantShortLock lock = new ReentrantShortLock(); + + public SslContextFactory(TlsProvider tlsProvider, TlsEngineType engineType, + @Nullable AbstractTlsConfig tlsConfig, MeterRegistry meterRegistry) { + // TODO(ikhoon): Support OPENSSL_REFCNT engine type. + assert engineType.sslProvider() != SslProvider.OPENSSL_REFCNT; + + this.tlsProvider = tlsProvider; + this.engineType = engineType; + this.meterRegistry = meterRegistry; + if (tlsConfig != null) { + this.tlsConfig = tlsConfig; + meterIdPrefix = tlsConfig.meterIdPrefix(); + allowsUnsafeCiphers = tlsConfig.allowsUnsafeCiphers(); + } else { + this.tlsConfig = null; + meterIdPrefix = null; + allowsUnsafeCiphers = false; + } + } + + /** + * Returns an {@link SslContext} for the specified {@link SslContextMode} and {@link TlsKeyPair}. + * Note that the returned {@link SslContext} should be released via + * {@link ReferenceCountUtil#release(Object)} when it is no longer used. + */ + public SslContext getOrCreate(SslContextMode mode, String hostname) { + lock.lock(); + try { + final TlsKeyPair tlsKeyPair = findTlsKeyPair(mode, hostname); + final List trustedCertificates = findTrustedCertificates(hostname); + final CacheKey cacheKey = new CacheKey(mode, tlsKeyPair, trustedCertificates); + final SslContextHolder contextHolder = cache.computeIfAbsent(cacheKey, this::create); + contextHolder.retain(); + reverseCache.putIfAbsent(contextHolder.sslContext(), cacheKey); + return contextHolder.sslContext(); + } finally { + lock.unlock(); + } + } + + public void release(SslContext sslContext) { + lock.lock(); + try { + final CacheKey cacheKey = reverseCache.get(sslContext); + final SslContextHolder contextHolder = cache.get(cacheKey); + assert contextHolder != null : "sslContext not found in the cache: " + sslContext; + + if (contextHolder.release()) { + final SslContextHolder removed = cache.remove(cacheKey); + assert removed == contextHolder; + reverseCache.remove(sslContext); + contextHolder.destroy(); + } + } finally { + lock.unlock(); + } + } + + @Nullable + private TlsKeyPair findTlsKeyPair(SslContextMode mode, String hostname) { + TlsKeyPair tlsKeyPair = tlsProvider.keyPair(hostname); + if (tlsKeyPair == null) { + // Try to find the default TLS key pair. + tlsKeyPair = tlsProvider.keyPair("*"); + } + if (mode == SslContextMode.SERVER && tlsKeyPair == null) { + // A TlsKeyPair must exist for a server. + throw new IllegalStateException("No TLS key pair found for " + hostname); + } + return tlsKeyPair; + } + + private List findTrustedCertificates(String hostname) { + List certs = tlsProvider.trustedCertificates(hostname); + if (certs == null) { + certs = tlsProvider.trustedCertificates("*"); + } + return firstNonNull(certs, ImmutableList.of()); + } + + private SslContextHolder create(CacheKey key) { + final MeterIdPrefix meterIdPrefix = meterIdPrefix(key.mode); + final SslContext sslContext = newSslContext(key); + final ImmutableList.Builder builder = ImmutableList.builder(); + if (key.tlsKeyPair != null) { + builder.addAll(key.tlsKeyPair.certificateChain()); + } + if (!key.trustedCertificates.isEmpty()) { + builder.addAll(key.trustedCertificates); + } + final List certs = builder.build(); + CloseableMeterBinder meterBinder = null; + if (!certs.isEmpty()) { + meterBinder = MoreMeterBinders.certificateMetrics(certs, meterIdPrefix); + meterBinder.bindTo(meterRegistry); + } + return new SslContextHolder(sslContext, meterBinder); + } + + private SslContext newSslContext(CacheKey key) { + final SslContextMode mode = key.mode(); + final TlsKeyPair tlsKeyPair = key.tlsKeyPair(); + final List trustedCerts = key.trustedCertificates(); + if (mode == SslContextMode.SERVER) { + assert tlsKeyPair != null; + return createSslContext( + () -> { + final SslContextBuilder contextBuilder = SslContextBuilder.forServer( + tlsKeyPair.privateKey(), + tlsKeyPair.certificateChain()); + if (!trustedCerts.isEmpty()) { + contextBuilder.trustManager(trustedCerts); + } + applyTlsConfig(contextBuilder); + return contextBuilder; + }, + false, engineType, allowsUnsafeCiphers, + null, null); + } else { + final boolean forceHttp1 = mode == SslContextMode.CLIENT_HTTP1_ONLY; + return createSslContext( + () -> { + final SslContextBuilder contextBuilder = SslContextBuilder.forClient(); + if (tlsKeyPair != null) { + contextBuilder.keyManager(tlsKeyPair.privateKey(), tlsKeyPair.certificateChain()); + } + if (!trustedCerts.isEmpty()) { + contextBuilder.trustManager(trustedCerts); + } + applyTlsConfig(contextBuilder); + return contextBuilder; + }, + forceHttp1, engineType, allowsUnsafeCiphers, null, null); + } + } + + private void applyTlsConfig(SslContextBuilder contextBuilder) { + if (tlsConfig == null) { + return; + } + + if (tlsConfig instanceof ServerTlsConfig) { + final ServerTlsConfig serverTlsConfig = (ServerTlsConfig) tlsConfig; + contextBuilder.clientAuth(serverTlsConfig.clientAuth()); + } else { + final ClientTlsConfig clientTlsConfig = (ClientTlsConfig) tlsConfig; + if (clientTlsConfig.tlsNoVerifySet()) { + contextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); + } else if (!clientTlsConfig.insecureHosts().isEmpty()) { + contextBuilder.trustManager(IgnoreHostsTrustManager.of(clientTlsConfig.insecureHosts())); + } + } + tlsConfig.tlsCustomizer().accept(contextBuilder); + } + + private MeterIdPrefix meterIdPrefix(SslContextMode mode) { + MeterIdPrefix meterIdPrefix = this.meterIdPrefix; + if (meterIdPrefix == null) { + if (mode == SslContextMode.SERVER) { + meterIdPrefix = SERVER_METER_ID_PREFIX; + } else { + meterIdPrefix = CLIENT_METER_ID_PREFIX; + } + } + return meterIdPrefix; + } + + @VisibleForTesting + public int numCachedContexts() { + return cache.size(); + } + + public enum SslContextMode { + SERVER, + CLIENT_HTTP1_ONLY, + CLIENT + } + + private static final class CacheKey { + private final SslContextMode mode; + @Nullable + private final TlsKeyPair tlsKeyPair; + + private final List trustedCertificates; + + private CacheKey(SslContextMode mode, @Nullable TlsKeyPair tlsKeyPair, + List trustedCertificates) { + this.mode = mode; + this.tlsKeyPair = tlsKeyPair; + this.trustedCertificates = trustedCertificates; + } + + SslContextMode mode() { + return mode; + } + + @Nullable + TlsKeyPair tlsKeyPair() { + return tlsKeyPair; + } + + public List trustedCertificates() { + return trustedCertificates; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CacheKey)) { + return false; + } + final CacheKey that = (CacheKey) o; + return mode == that.mode && + Objects.equals(tlsKeyPair, that.tlsKeyPair) && + trustedCertificates.equals(that.trustedCertificates); + } + + @Override + public int hashCode() { + return Objects.hash(mode, tlsKeyPair, trustedCertificates); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("mode", mode) + .add("tlsKeyPair", tlsKeyPair) + .add("trustedCertificates", trustedCertificates) + .toString(); + } + } + + private static final class SslContextHolder { + private final SslContext sslContext; + @Nullable + private final CloseableMeterBinder meterBinder; + private long refCnt; + + SslContextHolder(SslContext sslContext, @Nullable CloseableMeterBinder meterBinder) { + this.sslContext = sslContext; + this.meterBinder = meterBinder; + } + + SslContext sslContext() { + return sslContext; + } + + void retain() { + refCnt++; + } + + boolean release() { + refCnt--; + assert refCnt >= 0 : "refCount: " + refCnt; + return refCnt == 0; + } + + void destroy() { + if (meterBinder != null) { + meterBinder.close(); + } + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java new file mode 100644 index 00000000000..0940ce5711c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.internal.common; + +import java.net.IDN; +import java.util.Locale; + +public final class TlsProviderUtil { + + // Forked from https://github.com/netty/netty/blob/60430c80e7f8718ecd07ac31e01297b42a176b87/common/src/main/java/io/netty/util/DomainWildcardMappingBuilder.java#L78 + + /** + * IDNA ASCII conversion and case normalization. + */ + public static String normalizeHostname(String hostname) { + if (hostname.isEmpty() || hostname.charAt(0) == '.') { + throw new IllegalArgumentException("Hostname '" + hostname + "' not valid"); + } + if (needsNormalization(hostname)) { + hostname = IDN.toASCII(hostname, IDN.ALLOW_UNASSIGNED); + } + hostname = hostname.toLowerCase(Locale.US); + + if (hostname.charAt(0) == '*') { + if (hostname.length() < 3 || hostname.charAt(1) != '.') { + throw new IllegalArgumentException("Wildcard Hostname '" + hostname + "'not valid"); + } + return hostname.substring(1); + } + return hostname; + } + + private static boolean needsNormalization(String hostname) { + final int length = hostname.length(); + for (int i = 0; i < length; i++) { + final int c = hostname.charAt(i); + if (c > 0x7F) { + return true; + } + } + return false; + } + + private TlsProviderUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java index f66d8002835..b90dce5d441 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java @@ -19,6 +19,8 @@ import java.io.File; import java.io.InputStream; +import java.security.KeyException; +import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; @@ -41,6 +43,7 @@ import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.Exceptions; import io.netty.buffer.ByteBufAllocator; import io.netty.handler.ssl.ApplicationProtocolNegotiator; @@ -91,6 +94,29 @@ public static List toX509Certificates(InputStream in) throws Ce return ImmutableList.copyOf(SslContextProtectedAccessHack.toX509CertificateList(in)); } + public static PrivateKey toPrivateKey(File file, @Nullable String keyPassword) throws KeyException { + requireNonNull(file, "file"); + return MinifiedBouncyCastleProvider.call(() -> { + try { + return SslContextProtectedAccessHack.privateKey(file, keyPassword); + } catch (KeyException e) { + return Exceptions.throwUnsafely(e); + } + }); + } + + public static PrivateKey toPrivateKey(InputStream keyInputStream, @Nullable String keyPassword) + throws KeyException { + requireNonNull(keyInputStream, "keyInputStream"); + return MinifiedBouncyCastleProvider.call(() -> { + try { + return SslContextProtectedAccessHack.privateKey(keyInputStream, keyPassword); + } catch (KeyException e) { + return Exceptions.throwUnsafely(e); + } + }); + } + private static final class SslContextProtectedAccessHack extends SslContext { static X509Certificate[] toX509CertificateList(File file) throws CertificateException { @@ -101,6 +127,29 @@ static X509Certificate[] toX509CertificateList(InputStream in) throws Certificat return SslContext.toX509Certificates(in); } + static PrivateKey privateKey(File file, @Nullable String keyPassword) throws KeyException { + try { + return SslContext.toPrivateKey(file, keyPassword); + } catch (Exception e) { + if (e instanceof KeyException) { + throw (KeyException) e; + } + throw new KeyException("Fail to read a private key file: " + file.getName(), e); + } + } + + static PrivateKey privateKey(InputStream keyInputStream, @Nullable String keyPassword) + throws KeyException { + try { + return SslContext.toPrivateKey(keyInputStream, keyPassword); + } catch (Exception e) { + if (e instanceof KeyException) { + throw (KeyException) e; + } + throw new KeyException("Fail to parse a private key", e); + } + } + @Override public boolean isClient() { throw new UnsupportedOperationException(); diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java index 5826eb9bc25..9e50999dc78 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java @@ -33,32 +33,31 @@ import com.google.common.collect.ImmutableList; import com.google.common.io.ByteStreams; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; -import io.netty.util.internal.EmptyArrays; - public final class KeyStoreUtil { - public static KeyPair load(File keyStoreFile, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias) throws IOException, GeneralSecurityException { + public static TlsKeyPair load(File keyStoreFile, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias) throws IOException, GeneralSecurityException { try (InputStream in = new FileInputStream(keyStoreFile)) { return load(in, keyStorePassword, keyPassword, alias, keyStoreFile); } } - public static KeyPair load(InputStream keyStoreStream, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias) throws IOException, GeneralSecurityException { + public static TlsKeyPair load(InputStream keyStoreStream, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias) throws IOException, GeneralSecurityException { return load(keyStoreStream, keyStorePassword, keyPassword, alias, null); } - private static KeyPair load(InputStream keyStoreStream, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias, - @Nullable File keyStoreFile) + private static TlsKeyPair load(InputStream keyStoreStream, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias, + @Nullable File keyStoreFile) throws IOException, GeneralSecurityException { try (InputStream in = new BufferedInputStream(keyStoreStream, 8192)) { @@ -117,7 +116,7 @@ private static KeyPair load(InputStream keyStoreStream, assert certificateChain != null; - return new KeyPair(privateKey, certificateChain); + return TlsKeyPair.of(privateKey, certificateChain); } } @@ -165,22 +164,4 @@ private static IllegalArgumentException newException(String message, @Nullable F } private KeyStoreUtil() {} - - public static final class KeyPair { - private final PrivateKey privateKey; - private final List certificateChain; - - private KeyPair(PrivateKey privateKey, Iterable certificateChain) { - this.privateKey = privateKey; - this.certificateChain = ImmutableList.copyOf(certificateChain); - } - - public PrivateKey privateKey() { - return privateKey; - } - - public X509Certificate[] certificateChain() { - return certificateChain.toArray(EmptyArrays.EMPTY_X509_CERTIFICATES); - } - } } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java index 2a67d63f5e4..3e41a171115 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java @@ -98,7 +98,7 @@ public final class SslContextUtil { public static SslContext createSslContext( Supplier builderSupplier, boolean forceHttp1, TlsEngineType tlsEngineType, boolean tlsAllowUnsafeCiphers, - Iterable> userCustomizers, + @Nullable Consumer userCustomizer, @Nullable List keyCertChainCaptor) { return MinifiedBouncyCastleProvider.call(() -> { @@ -127,7 +127,9 @@ public static SslContext createSslContext( builder.protocols(protocols.toArray(EmptyArrays.EMPTY_STRINGS)) .ciphers(DEFAULT_CIPHERS, SupportedCipherSuiteFilter.INSTANCE); - userCustomizers.forEach(customizer -> customizer.accept(builder)); + if (userCustomizer != null) { + userCustomizer.accept(builder); + } // We called user customization logic before setting ALPN to make sure they don't break // compatibility with HTTP/2. diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java index 2c23dc5f357..284eff8ca1f 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java @@ -230,13 +230,27 @@ private Timer newKeepAliveTimer(SessionProtocol protocol) { } private void configureHttps(ChannelPipeline p, @Nullable ProxiedAddresses proxiedAddresses) { - final Mapping sslContexts = - requireNonNull(config.sslContextMapping(), "config.sslContextMapping() returned null"); - p.addLast(new SniHandler(sslContexts, Flags.defaultMaxClientHelloLength(), config.idleTimeoutMillis())); + p.addLast(newSniHandler(p)); p.addLast(TrafficLoggingHandler.SERVER); p.addLast(new Http2OrHttpHandler(proxiedAddresses)); } + private SniHandler newSniHandler(ChannelPipeline p) { + final Mapping sslContexts = + requireNonNull(config.sslContextMapping(), "config.sslContextMapping() returned null"); + final SniHandler sniHandler = new SniHandler(sslContexts, Flags.defaultMaxClientHelloLength(), + config.idleTimeoutMillis()); + if (sslContexts instanceof TlsProviderMapping) { + p.channel().closeFuture().addListener(future -> { + final SslContext sslContext = sniHandler.sslContext(); + if (sslContext != null) { + ((TlsProviderMapping) sslContexts).release(sslContext); + } + }); + } + return sniHandler; + } + private Http2ConnectionHandler newHttp2ConnectionHandler(ChannelPipeline pipeline, AsciiString scheme) { final Timer keepAliveTimer = newKeepAliveTimer(scheme == SCHEME_HTTP ? H2C : H2); diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java index 5674d209f67..9988ddb6976 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java @@ -83,6 +83,8 @@ import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -237,6 +239,10 @@ public final class ServerBuilder implements TlsSetters, ServiceConfigsBuilder shutdownSupports = new ArrayList<>(); private int http2MaxResetFramesPerWindow = Flags.defaultServerHttp2MaxResetFramesPerMinute(); private int http2MaxResetFramesWindowSeconds = 60; + @Nullable + private TlsProvider tlsProvider; + @Nullable + private ServerTlsConfig tlsConfig; ServerBuilder() { // Set the default host-level properties. @@ -1074,49 +1080,65 @@ public ServerBuilder proxyProtocolMaxTlvSize(int proxyProtocolMaxTlvSize) { return this; } + @Deprecated @Override public ServerBuilder tls(File keyCertChainFile, File keyFile) { return (ServerBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); } + @Deprecated @Override public ServerBuilder tls( File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - virtualHostTemplate.tls(keyCertChainFile, keyFile, keyPassword); - return this; + return (ServerBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } + @Deprecated @Override public ServerBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (ServerBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); } + @Deprecated @Override public ServerBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { - virtualHostTemplate.tls(keyCertChainInputStream, keyInputStream, keyPassword); - return this; + return (ServerBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, keyPassword); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, Iterable keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, @Nullable String keyPassword, Iterable keyCertChain) { - virtualHostTemplate.tls(key, keyPassword, keyCertChain); + return (ServerBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } + + /** + * Configures SSL or TLS with the specified {@link TlsKeyPair}. + * + *

Note that this method mutually exclusive with {@link #tlsProvider(TlsProvider)}. + */ + @Override + public ServerBuilder tls(TlsKeyPair tlsKeyPair) { + virtualHostTemplate.tls(tlsKeyPair); return this; } @@ -1126,9 +1148,69 @@ public ServerBuilder tls(KeyManagerFactory keyManagerFactory) { return this; } + /** + * Sets the specified {@link TlsProvider} which will be used for building an {@link SslContext} of + * a hostname. + * + *

{@code
+     * Server
+     *   .builder()
+     *   .tlsProvider(
+     *     TlsProvider.builder()
+     *                // Set the default key pair.
+     *                .keyPair(TlsKeyPair.of(...))
+     *                // Set the key pair for "example.com".
+     *                .keyPair("example.com", TlsKeyPair.of(...))
+     *                .build())
+     * }
+ * + *

Note that this method mutually exclusive with {@link #tls(TlsKeyPair)} and other static TLS settings. + */ + @UnstableApi + public ServerBuilder tlsProvider(TlsProvider tlsProvider) { + requireNonNull(tlsProvider, "tlsProvider"); + this.tlsProvider = tlsProvider; + tlsConfig = null; + return this; + } + + /** + * Sets the specified {@link TlsProvider} and {@link ServerTlsConfig} which will be used for building an + * {@link SslContext} of a hostname. + * + *

{@code
+     * TlsProvider tlsProvider =
+     *   TlsProvider
+     *     .builder()
+     *     // Set the default key pair.
+     *     .keyPair(TlsKeyPair.of(...))
+     *     // Set the key pair for "example.com".
+     *     .keyPair("example.com", TlsKeyPair.of(...))
+     *     .build();
+     *
+     * ServerTlsConfig tlsConfig =
+     *   ServerTlsConfig
+     *     .builder()
+     *     .clientAuth(ClientAuth.REQUIRED)
+     *     .meterIdPrefix(...)
+     *     .build();
+     *
+     * Server
+     *   .builder()
+     *   .tlsProvider(tlsProvider, tlsConfig)
+     * }
+ */ + @UnstableApi + public ServerBuilder tlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsConfig) { + tlsProvider(tlsProvider); + this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig"); + return this; + } + /** * Configures SSL or TLS of the {@link Server} with an auto-generated self-signed certificate. - * Note: You should never use this in production but only for a testing purpose. + * + *

Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) */ @@ -1139,7 +1221,8 @@ public ServerBuilder tlsSelfSigned() { /** * Configures SSL or TLS of the {@link Server} with an auto-generated self-signed certificate. - * Note: You should never use this in production but only for a testing purpose. + * + *

Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) */ @@ -2222,11 +2305,11 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { : this.errorHandler.orElse(ServerErrorHandler.ofDefault())); final VirtualHost defaultVirtualHost = defaultVirtualHostBuilder.build(virtualHostTemplate, dependencyInjector, - unloggedExceptionsReporter, errorHandler); + unloggedExceptionsReporter, errorHandler, tlsProvider); final List virtualHosts = virtualHostBuilders.stream() .map(vhb -> vhb.build(virtualHostTemplate, dependencyInjector, - unloggedExceptionsReporter, errorHandler)) + unloggedExceptionsReporter, errorHandler, tlsProvider)) .collect(toImmutableList()); // Pre-populate the domain name mapping for later matching. final Mapping sslContexts; @@ -2254,7 +2337,9 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { virtualHostPort, portNumbers); } - if (defaultSslContext == null) { + checkState(defaultSslContext == null || tlsProvider == null, + "Can't set %s with a static TLS setting", TlsProvider.class.getSimpleName()); + if (defaultSslContext == null && tlsProvider == null) { sslContexts = null; if (!serverPorts.isEmpty()) { ports = resolveDistinctPorts(serverPorts); @@ -2282,21 +2367,28 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { ports = ImmutableList.of(new ServerPort(0, HTTPS)); } - final DomainMappingBuilder - mappingBuilder = new DomainMappingBuilder<>(defaultSslContext); - for (VirtualHost h : virtualHosts) { - final SslContext sslCtx = h.sslContext(); - if (sslCtx != null) { - final String originalHostnamePattern = h.originalHostnamePattern(); - // The SslContext for the default virtual host was added when creating DomainMappingBuilder. - if (!"*".equals(originalHostnamePattern)) { - mappingBuilder.add(originalHostnamePattern, sslCtx); + if (defaultSslContext != null) { + final DomainMappingBuilder + mappingBuilder = new DomainMappingBuilder<>(defaultSslContext); + for (VirtualHost h : virtualHosts) { + final SslContext sslCtx = h.sslContext(); + if (sslCtx != null) { + final String originalHostnamePattern = h.originalHostnamePattern(); + // The SslContext for the default virtual host was added when creating + // DomainMappingBuilder. + if (!"*".equals(originalHostnamePattern)) { + mappingBuilder.add(originalHostnamePattern, sslCtx); + } } } + sslContexts = mappingBuilder.build(); + } else { + final TlsEngineType tlsEngineType = defaultVirtualHost.tlsEngineType(); + assert tlsEngineType != null; + assert tlsProvider != null; + sslContexts = new TlsProviderMapping(tlsProvider, tlsEngineType, tlsConfig, meterRegistry); } - sslContexts = mappingBuilder.build(); } - if (pingIntervalMillis > 0) { pingIntervalMillis = Math.max(pingIntervalMillis, MIN_PING_INTERVAL_MILLIS); if (idleTimeoutMillis > 0 && pingIntervalMillis >= idleTimeoutMillis) { diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java b/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java index 0d3c080b5dc..d73dc0e62fc 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java @@ -26,8 +26,7 @@ import javax.net.ssl.SSLException; import javax.net.ssl.SSLSession; -import com.google.common.collect.ImmutableList; - +import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.TlsEngineType; import com.linecorp.armeria.internal.common.util.SslContextUtil; @@ -65,7 +64,7 @@ static SSLSession validateSslContext(SslContext sslContext, TlsEngineType tlsEng final SslContext sslContextClient = buildSslContext(() -> SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE), - tlsEngineType, true, ImmutableList.of()); + tlsEngineType, true, null); clientEngine = sslContextClient.newEngine(ByteBufAllocator.DEFAULT); clientEngine.setUseClientMode(true); clientEngine.setEnabledProtocols(clientEngine.getSupportedProtocols()); @@ -99,10 +98,10 @@ static SslContext buildSslContext( Supplier sslContextBuilderSupplier, TlsEngineType tlsEngineType, boolean tlsAllowUnsafeCiphers, - Iterable> tlsCustomizers) { + @Nullable Consumer tlsCustomizer) { return SslContextUtil .createSslContext(sslContextBuilderSupplier,/* forceHttp1 */ false, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, null); + tlsAllowUnsafeCiphers, tlsCustomizer, null); } private static void unwrap(SSLEngine engine, ByteBuffer packetBuf) throws SSLException { diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java new file mode 100644 index 00000000000..d1c63db70e8 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.server; + +import java.util.function.Consumer; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; + +/** + * Provides server-side TLS configuration for {@link TlsProvider}. + */ +@UnstableApi +public final class ServerTlsConfig extends AbstractTlsConfig { + + /** + * Returns a new {@link ServerTlsConfigBuilder}. + */ + public static ServerTlsConfigBuilder builder() { + return new ServerTlsConfigBuilder(); + } + + private final ClientAuth clientAuth; + + ServerTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + ClientAuth clientAuth, Consumer tlsCustomizer) { + super(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + this.clientAuth = clientAuth; + } + + /** + * Returns the client authentication mode. + */ + public ClientAuth clientAuth() { + return clientAuth; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("allowsUnsafeCiphers", allowsUnsafeCiphers()) + .add("meterIdPrefix", meterIdPrefix()) + .add("clientAuth", clientAuth) + .add("tlsCustomizer", tlsCustomizer()) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java new file mode 100644 index 00000000000..97c53e828aa --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.server; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.common.AbstractTlsConfigBuilder; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.netty.handler.ssl.ClientAuth; + +/** + * A builder class for creating a {@link TlsProvider} that provides server-side TLS. + */ +@UnstableApi +public final class ServerTlsConfigBuilder extends AbstractTlsConfigBuilder { + + private ClientAuth clientAuth = ClientAuth.NONE; + + ServerTlsConfigBuilder() {} + + /** + * Sets the client authentication mode. + */ + public ServerTlsConfigBuilder clientAuth(ClientAuth clientAuth) { + this.clientAuth = requireNonNull(clientAuth, "clientAuth"); + return this; + } + + /** + * Returns a newly-created {@link ServerTlsConfig} based on the properties of this builder. + */ + public ServerTlsConfig build() { + return new ServerTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), clientAuth, tlsCustomizer()); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java b/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java new file mode 100644 index 00000000000..a36b9065be2 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.server; + +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.common.TlsProviderUtil; + +import io.micrometer.core.instrument.MeterRegistry; +import io.netty.handler.ssl.SslContext; +import io.netty.util.Mapping; + +final class TlsProviderMapping implements Mapping { + + private final SslContextFactory sslContextFactory; + + TlsProviderMapping(TlsProvider tlsProvider, TlsEngineType tlsEngineType, + @Nullable ServerTlsConfig tlsConfig, MeterRegistry meterRegistry) { + sslContextFactory = new SslContextFactory(tlsProvider, tlsEngineType, tlsConfig, meterRegistry); + } + + @Override + public SslContext map(@Nullable String hostname) { + if (hostname == null) { + hostname = "*"; + } else { + hostname = TlsProviderUtil.normalizeHostname(hostname); + } + return sslContextFactory.getOrCreate(SslContextFactory.SslContextMode.SERVER, hostname); + } + + void release(SslContext sslContext) { + sslContextFactory.release(sslContext); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java index 89ddb678a29..297f1a55507 100644 --- a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java +++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java @@ -39,6 +39,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.RequestId; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.logging.RequestLog; @@ -52,6 +53,7 @@ import io.netty.channel.EventLoopGroup; import io.netty.handler.ssl.SslContext; import io.netty.util.Mapping; +import io.netty.util.ReferenceCountUtil; /** * A name-based virtual host. @@ -84,6 +86,8 @@ public final class VirtualHost { @Nullable private final SslContext sslContext; @Nullable + private final TlsProvider tlsProvider; + @Nullable private final TlsEngineType tlsEngineType; private final Router router; private final List serviceConfigs; @@ -109,6 +113,7 @@ public final class VirtualHost { VirtualHost(String defaultHostname, String hostnamePattern, int port, @Nullable SslContext sslContext, + @Nullable TlsProvider tlsProvider, @Nullable TlsEngineType tlsEngineType, Iterable serviceConfigs, ServiceConfig fallbackServiceConfig, @@ -138,6 +143,7 @@ public final class VirtualHost { } this.port = port; this.sslContext = sslContext; + this.tlsProvider = tlsProvider; this.tlsEngineType = tlsEngineType; this.defaultServiceNaming = defaultServiceNaming; this.defaultLogName = defaultLogName; @@ -172,7 +178,11 @@ public final class VirtualHost { } VirtualHost withNewSslContext(SslContext sslContext) { - return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, sslContext, + if (tlsProvider != null) { + ReferenceCountUtil.release(sslContext); + throw new IllegalStateException("Cannot set a new SslContext when TlsProvider is set."); + } + return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, sslContext, null, tlsEngineType, serviceConfigs, fallbackServiceConfig, RejectedRouteHandler.DISABLED, host -> accessLogger, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, @@ -590,7 +600,7 @@ VirtualHost decorate(@Nullable Function accessLogger, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java index 323f7e04300..0868d04e9df 100644 --- a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java @@ -32,10 +32,7 @@ import static io.netty.handler.codec.http2.Http2Headers.PseudoHeaderName.isPseudoHeader; import static java.util.Objects.requireNonNull; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.IOError; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; import java.security.PrivateKey; @@ -60,7 +57,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.google.common.io.ByteStreams; import com.google.common.net.HostAndPort; import com.linecorp.armeria.common.CommonPools; @@ -76,6 +72,8 @@ import com.linecorp.armeria.common.RequestId; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -134,7 +132,8 @@ public final class VirtualHostBuilder implements TlsSetters, ServiceConfigsBuild private Boolean tlsSelfSigned; @Nullable private SelfSignedCertificate selfSignedCertificate; - private final List> tlsCustomizers = new ArrayList<>(); + @Nullable + private Consumer tlsCustomizer; @Nullable private Boolean tlsAllowUnsafeCiphers; @Nullable @@ -276,70 +275,61 @@ VirtualHostBuilder hostnamePattern(String hostnamePattern, int port) { return this; } + @Deprecated @Override public VirtualHostBuilder tls(File keyCertChainFile, File keyFile) { return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); } + @Deprecated @Override public VirtualHostBuilder tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - requireNonNull(keyCertChainFile, "keyCertChainFile"); - requireNonNull(keyFile, "keyFile"); - return tls(() -> SslContextBuilder.forServer(keyCertChainFile, keyFile, keyPassword)); + return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } + @Deprecated @Override public VirtualHostBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); } + @Deprecated @Override public VirtualHostBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { - requireNonNull(keyCertChainInputStream, "keyCertChainInputStream"); - requireNonNull(keyInputStream, "keyInputStream"); - - // Retrieve the content of the given streams so that they can be consumed more than once. - final byte[] keyCertChain; - final byte[] key; - try { - keyCertChain = ByteStreams.toByteArray(keyCertChainInputStream); - key = ByteStreams.toByteArray(keyInputStream); - } catch (IOException e) { - throw new IOError(e); - } - - return tls(() -> SslContextBuilder.forServer(new ByteArrayInputStream(keyCertChain), - new ByteArrayInputStream(key), - keyPassword)); + return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, keyPassword); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, Iterable keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, @Nullable String keyPassword, Iterable keyCertChain) { - requireNonNull(key, "key"); - requireNonNull(keyCertChain, "keyCertChain"); - for (X509Certificate keyCert : keyCertChain) { - requireNonNull(keyCert, "keyCertChain contains null."); - } + return (VirtualHostBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } - return tls(() -> SslContextBuilder.forServer(key, keyPassword, keyCertChain)); + @Override + public VirtualHostBuilder tls(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return tls(() -> SslContextBuilder.forServer(tlsKeyPair.privateKey(), tlsKeyPair.certificateChain())); } @Override @@ -363,7 +353,9 @@ private VirtualHostBuilder tls(Supplier sslContextBuilderSupp * Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#ofSelfSigned()}. */ + @Deprecated public VirtualHostBuilder tlsSelfSigned() { return tlsSelfSigned(true); } @@ -373,7 +365,9 @@ public VirtualHostBuilder tlsSelfSigned() { * Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#ofSelfSigned()}. */ + @Deprecated public VirtualHostBuilder tlsSelfSigned(boolean tlsSelfSigned) { checkState(!portBased, "Cannot configure self-signed to a port-based virtual host." + " Please configure to %s.tlsSelfSigned()", ServerBuilder.class.getSimpleName()); @@ -387,7 +381,12 @@ public VirtualHostBuilder tlsCustomizer(Consumer tlsC checkState(!portBased, "Cannot configure TLS to a port-based virtual host. Please configure to %s.tlsCustomizer()", ServerBuilder.class.getSimpleName()); - tlsCustomizers.add(tlsCustomizer); + if (this.tlsCustomizer == null) { + //noinspection unchecked + this.tlsCustomizer = (Consumer) tlsCustomizer; + } else { + this.tlsCustomizer = this.tlsCustomizer.andThen(tlsCustomizer); + } return this; } @@ -1306,7 +1305,7 @@ public VirtualHostBuilder contextHook(Supplier contextH */ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInjector, @Nullable UnloggedExceptionsReporter unloggedExceptionsReporter, - ServerErrorHandler serverErrorHandler) { + ServerErrorHandler serverErrorHandler, @Nullable TlsProvider tlsProvider) { requireNonNull(template, "template"); if (defaultHostname == null) { @@ -1464,9 +1463,17 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje final TlsEngineType tlsEngineType = this.tlsEngineType != null ? this.tlsEngineType : template.tlsEngineType; assert tlsEngineType != null; + + final SslContext sslContext = sslContext(template, tlsEngineType); + if (sslContext != null && tlsProvider != null) { + ReferenceCountUtil.release(sslContext); + throw new IllegalStateException("Cannot configure TLS settings with a TlsProvider"); + } + final VirtualHost virtualHost = - new VirtualHost(defaultHostname, hostnamePattern, port, sslContext(template, tlsEngineType), - tlsEngineType, serviceConfigs, fallbackServiceConfig, rejectedRouteHandler, + new VirtualHost(defaultHostname, hostnamePattern, port, + sslContext, tlsProvider, tlsEngineType, + serviceConfigs, fallbackServiceConfig, rejectedRouteHandler, accessLoggerMapper, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, accessLogWriter, blockingTaskExecutor, requestAutoAbortDelayMillis, successFunction, multipartUploadsLocation, @@ -1516,27 +1523,27 @@ private SslContext sslContext(VirtualHostBuilder template, TlsEngineType tlsEngi // Build a new SslContext or use a user-specified one for backward compatibility. if (sslContextBuilderSupplier != null) { sslContext = buildSslContext(sslContextBuilderSupplier, tlsEngineType, tlsAllowUnsafeCiphers, - tlsCustomizers); + tlsCustomizer); sslContextFromThis = true; releaseSslContextOnFailure = true; } else if (template.sslContextBuilderSupplier != null) { sslContext = buildSslContext(template.sslContextBuilderSupplier, tlsEngineType, - tlsAllowUnsafeCiphers, template.tlsCustomizers); + tlsAllowUnsafeCiphers, template.tlsCustomizer); releaseSslContextOnFailure = true; } // Generate a self-signed certificate if necessary. if (sslContext == null) { final boolean tlsSelfSigned; - final List> tlsCustomizers; + final Consumer tlsCustomizer; if (this.tlsSelfSigned != null) { tlsSelfSigned = this.tlsSelfSigned; - tlsCustomizers = this.tlsCustomizers; + tlsCustomizer = this.tlsCustomizer; sslContextFromThis = true; } else { assert template.tlsSelfSigned != null; tlsSelfSigned = template.tlsSelfSigned; - tlsCustomizers = template.tlsCustomizers; + tlsCustomizer = template.tlsCustomizer; } if (tlsSelfSigned) { @@ -1551,13 +1558,13 @@ private SslContext sslContext(VirtualHostBuilder template, TlsEngineType tlsEngi ssc.privateKey()), tlsEngineType, tlsAllowUnsafeCiphers, - tlsCustomizers); + tlsCustomizer); releaseSslContextOnFailure = true; } } // Reject if a user called `tlsCustomizer()` without `tls()` or `tlsSelfSigned()`. - checkState(sslContextFromThis || tlsCustomizers.isEmpty(), + checkState(sslContextFromThis || tlsCustomizer == null, "Cannot call tlsCustomizer() without tls() or tlsSelfSigned()"); // Validate the built `SslContext`. diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java new file mode 100644 index 00000000000..3763819d982 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; + +class ClientTlsProviderBuilderTest { + + @Test + void testBuild() { + assertThatThrownBy(() -> { + TlsProvider.builder() + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair is set."); + } + + @Test + void testMapping() { + final TlsKeyPair exactKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair wildcardKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair defaultKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair barKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair barWildKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(defaultKeyPair) + .keyPair("example.com", exactKeyPair) + .keyPair("*.foo.com", wildcardKeyPair) + .keyPair("*.bar.com", barWildKeyPair) + .keyPair("bar.com", barKeyPair) + .build(); + assertThat(tlsProvider.keyPair("any.com")).isEqualTo(defaultKeyPair); + // Exact match + assertThat(tlsProvider.keyPair("example.com")).isEqualTo(exactKeyPair); + // Wildcard match + assertThat(tlsProvider.keyPair("bar.foo.com")).isEqualTo(wildcardKeyPair); + + // Not a wildcard match + assertThat(tlsProvider.keyPair("foo.com")).isEqualTo(defaultKeyPair); + // No nested wildcard support + assertThat(tlsProvider.keyPair("baz.bar.foo.com")).isEqualTo(defaultKeyPair); + + assertThat(tlsProvider.keyPair("bar.com")).isEqualTo(barKeyPair); + assertThat(tlsProvider.keyPair("foo.bar.com")).isEqualTo(barWildKeyPair); + assertThat(tlsProvider.keyPair("foo.foo.bar.com")).isEqualTo(defaultKeyPair); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java new file mode 100644 index 00000000000..922e764488b --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java @@ -0,0 +1,311 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.metric.MoreMeters; +import com.linecorp.armeria.internal.common.util.CertificateUtil; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.netty.handler.ssl.ClientAuth; + +class ClientTlsProviderTest { + + @RegisterExtension + static final SelfSignedCertificateExtension server0DefaultCert = new SelfSignedCertificateExtension(); + @RegisterExtension + static final SelfSignedCertificateExtension server0FooCert = new SelfSignedCertificateExtension( + "foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension server0SubFooCert = new SelfSignedCertificateExtension( + "sub.foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension server1DefaultCert = new SelfSignedCertificateExtension(); + @RegisterExtension + static final SelfSignedCertificateExtension server1BarCert = new SelfSignedCertificateExtension("bar.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientFooCert = new SelfSignedCertificateExtension("foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientSubFooCert = + new SelfSignedCertificateExtension("sub.foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientBarCert = new SelfSignedCertificateExtension("bar.com"); + + @RegisterExtension + static final ServerExtension server0 = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server0DefaultCert.tlsKeyPair()) + .keyPair("foo.com", server0FooCert.tlsKeyPair()) + .keyPair("*.foo.com", server0SubFooCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate(), clientSubFooCert.certificate()) + .build(); + + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0) + .tlsProvider(tlsProvider, tlsConfig) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("foo.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("foo:" + commonName); + }) + .and() + .virtualHost("sub.foo.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("sub.foo:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension server1 = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server1DefaultCert.tlsKeyPair()) + .keyPair("bar.com", server1BarCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + + sb.https(0) + .tlsProvider(tlsProvider, tlsConfig) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("bar.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("virtual:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension serverNoMtls = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server0DefaultCert.tlsKeyPair()) + .keyPair("bar.com", server1BarCert.tlsKeyPair()) + .build(); + + sb.https(0) + .tlsProvider(tlsProvider) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("bar.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("virtual:" + commonName); + }); + } + }; + + @Test + void testExactMatch() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("*.foo.com", clientFooCert.tlsKeyPair()) + .keyPair("bar.com", clientBarCert.tlsKeyPair()) + .keyPair(TlsKeyPair.of(clientFooCert.privateKey(), + clientFooCert.certificate())) + .trustedCertificates("foo.com", server0FooCert.certificate()) + .trustedCertificates("bar.com", server1BarCert.certificate()) + .trustedCertificates("sub.foo.com", server0SubFooCert.certificate()) + .trustedCertificates(server0DefaultCert.certificate()) + .build(); + + final MeterRegistry meterRegistry = new SimpleMeterRegistry(); + try (ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .meterRegistry(meterRegistry) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("foo:foo.com"); + client = WebClient.builder("https://sub.foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("sub.foo:sub.foo.com"); + client = WebClient.builder("https://127.0.0.1:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:localhost"); + + await().untilAsserted(() -> { + final Map metrics = MoreMeters.measureAll(meterRegistry); + // Make sure that the metrics for the certificates generated from TlsProvider are exported. + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=foo.com}")) + .isEqualTo(1.0); + assertThat( + metrics.get("armeria.client.tls.certificate.validity#value{common.name=sub.foo.com}")) + .isEqualTo(1.0); + }); + } + + await().untilAsserted(() -> { + final Map metrics = MoreMeters.measureAll(meterRegistry); + // The metrics for the certificates should be closed when the associated connections are closed. + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=foo.com}")) + .isNull(); + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=sub.foo.com}")) + .isNull(); + }); + } + + @Test + void testWildcardMatch() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .keyPair("*.foo.com", clientFooCert.tlsKeyPair()) + .trustedCertificates(server0FooCert.certificate(), + server0SubFooCert.certificate()) + .build(); + + try ( + ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("foo:foo.com"); + client = WebClient.builder("https://sub.foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("sub.foo:sub.foo.com"); + } + } + + @Test + void testNoMtls() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .trustedCertificates(server0DefaultCert.certificate(), + server1BarCert.certificate()) + .build(); + + try (ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://bar.com:" + serverNoMtls.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("virtual:bar.com"); + + client = WebClient.builder("https://127.0.0.1:" + serverNoMtls.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:localhost"); + } + } + + @Test + void disallowTlsProviderWhenTlsSettingsIsSet() { + final TlsProvider tlsProvider = + TlsProvider.of(TlsKeyPair.ofSelfSigned()); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tls(TlsKeyPair.ofSelfSigned()); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsCustomizer(b -> {}); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsNoVerify(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsNoVerifyHosts("example.com"); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tls(TlsKeyPair.ofSelfSigned()) + .tlsProvider(tlsProvider); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "Cannot configure the TlsProvider because static TLS settings have been set already."); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java b/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java index 6966d8a580a..15f97dba438 100644 --- a/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java @@ -37,6 +37,7 @@ import com.google.common.collect.ImmutableSet; import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.internal.common.IgnoreHostsTrustManager; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.testing.junit5.server.ServerExtension; diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java new file mode 100644 index 00000000000..5ee0e319ce5 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; +import com.spotify.futures.CompletableFutures; + +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.logging.RequestLogProperty; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.channel.Channel; +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderCacheTest { + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension clientFooCert = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension clientBarCert = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension serverFooCert = new SelfSignedCertificateExtension("foo.com"); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension serverBarCert = new SelfSignedCertificateExtension("bar.com"); + + static CompletableFuture startFuture; + + @Order(1) + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(serverFooCert.tlsKeyPair()) + .keyPair("bar.com", serverBarCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate(), + clientBarCert.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.tlsProvider(tlsProvider, tlsConfig); + + sb.virtualHost("bar.com") + .service("/", (ctx, req) -> { + final CompletableFuture future = + startFuture.thenApply(unused -> HttpResponse.of("Hello, Bar!")); + return HttpResponse.of(future); + }); + + sb.service("/", (ctx, req) -> { + final CompletableFuture future = + startFuture.thenApply(unused -> HttpResponse.of("Hello!")); + return HttpResponse.of(future); + }); + } + }; + + @BeforeEach + void setUp() { + startFuture = new CompletableFuture<>(); + } + + @Test + void shouldCacheSslContext() { + // This test could be broken if multiple tests are running in parallel. + final CountingConnectionPoolListener poolListener = new CountingConnectionPoolListener(); + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .keyPair("bar.com", clientBarCert.tlsKeyPair()) + .trustedCertificates(serverFooCert.certificate(), serverBarCert.certificate()) + .build(); + + final List channels = new ArrayList<>(); + final List> responses = new ArrayList<>(); + try ( + ClientFactory factory = ClientFactory + .builder() + .addressResolverGroupFactory(eventLoopGroup -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .connectionPoolListener(poolListener) + .build()) { + for (String host : ImmutableList.of("foo.com", "bar.com")) { + final WebClient client = + // Use HTTP/1 to create multiple connections. + WebClient.builder("h1://" + host + ':' + server.httpsPort()) + .factory(factory) + .build(); + + for (int i = 0; i < 3; i++) { + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + final CompletableFuture future = + client.prepare() + .get("/") + .header(HttpHeaderNames.CONNECTION, "close") + .execute() + .aggregate(); + responses.add(future); + channels.add(captor.get().log() + .whenAvailable(RequestLogProperty.REQUEST_HEADERS).join() + .channel()); + } + } + } + + await().untilAsserted(() -> { + assertThat(poolListener.opened()).isEqualTo(6); + }); + + final HttpClientFactory clientFactory = (HttpClientFactory) factory.unwrap(); + final SslContextFactory sslContextFactory = clientFactory.sslContextFactory(); + assertThat(sslContextFactory).isNotNull(); + // Make sure the SslContext is reused + assertThat(sslContextFactory.numCachedContexts()).isEqualTo(2); + + startFuture.complete(null); + final List responses0 = CompletableFutures.allAsList(responses).join(); + for (int i = 0; i < responses0.size(); i++) { + final AggregatedHttpResponse response = responses0.get(i); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + if (i < 3) { + assertThat(response.contentUtf8()).isEqualTo("Hello!"); + } else { + assertThat(response.contentUtf8()).isEqualTo("Hello, Bar!"); + } + } + + await().untilAsserted(() -> { + assertThat(poolListener.closed()).isEqualTo(6); + }); + // Make sure a cached SslContext is released when all referenced channels are closed. + assertThat(sslContextFactory.numCachedContexts()).isEqualTo(0); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java new file mode 100644 index 00000000000..b3fda22fc8d --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderMTlsTest { + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension sscServer = new SelfSignedCertificateExtension(); + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension sscClient = new SelfSignedCertificateExtension(); + + @Order(1) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair(sscServer.tlsKeyPair()) + .trustedCertificates(sscClient.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.tlsProvider(tlsProvider, tlsConfig); + + sb.service("/", (ctx, req) -> { + return HttpResponse.of(HttpStatus.OK); + }); + } + }; + + @Test + void testMTls() { + final TlsProvider tlsProvider = TlsProvider + .builder() + .keyPair(sscClient.tlsKeyPair()) + .trustedCertificates(sscServer.certificate()) + .build(); + try (ClientFactory factory = ClientFactory + .builder() + .tlsProvider(tlsProvider) + .connectTimeoutMillis(Long.MAX_VALUE) + .build()) { + final BlockingWebClient client = WebClient.builder(server.httpsUri()) + .factory(factory) + .build() + .blocking(); + final AggregatedHttpResponse res = client.get("/"); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java new file mode 100644 index 00000000000..50e999cd1d0 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.cert.X509Certificate; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderTrustedCertificatesTest { + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension serverCertFoo = new SelfSignedCertificateExtension("foo.com"); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension serverCertBar = new SelfSignedCertificateExtension("bar.com"); + + @Order(0) + + @RegisterExtension + static SelfSignedCertificateExtension serverCertDefault = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertFoo = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertBar = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertDefault = new SelfSignedCertificateExtension(); + + @Order(1) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", serverCertFoo.tlsKeyPair()) + .keyPair("bar.com", serverCertBar.tlsKeyPair()) + .keyPair(serverCertDefault.tlsKeyPair()) + .trustedCertificates("foo.com", clientCertFoo.certificate()) + .trustedCertificates("bar.com", clientCertBar.certificate()) + .trustedCertificates(clientCertDefault.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @RegisterExtension + static ServerExtension fooServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair("foo.com", serverCertFoo.tlsKeyPair()) + .trustedCertificates("foo.com", + clientCertFoo.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @RegisterExtension + static ServerExtension barServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("bar.com", serverCertBar.tlsKeyPair()) + .trustedCertificates("bar.com", clientCertBar.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @Test + void complexUsage() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientCertFoo.tlsKeyPair()) + .keyPair("bar.com", clientCertBar.tlsKeyPair()) + .keyPair(clientCertDefault.tlsKeyPair()) + .trustedCertificates(serverCertDefault.certificate()) + .trustedCertificates("foo.com", serverCertFoo.certificate()) + .trustedCertificates("bar.com", serverCertBar.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + for (String hostname : ImmutableList.of("foo.com", "bar.com", "127.0.0.1")) { + final BlockingWebClient client = + WebClient.builder("https://" + hostname + ':' + server.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + } + + @MethodSource("simpleParameters") + @ParameterizedTest + void simpleUsage(String hostname, int port, TlsKeyPair keyPair, X509Certificate trustedCertificate) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(hostname, keyPair) + .trustedCertificates(hostname, trustedCertificate) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://" + hostname + ':' + port) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + + @Test + void defaultTrustedCertificates() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientCertFoo.tlsKeyPair()) + .trustedCertificates(serverCertFoo.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://foo.com:" + fooServer.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + + static Stream simpleParameters() { + return Stream.of( + Arguments.of("foo.com", fooServer.httpsPort(), + clientCertFoo.tlsKeyPair(), serverCertFoo.certificate()), + Arguments.of("bar.com", barServer.httpsPort(), + clientCertBar.tlsKeyPair(), serverCertBar.certificate())); + } + + @Test + void simpleUsage_bar() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("bar.com", clientCertBar.tlsKeyPair()) + .trustedCertificates("bar.com", serverCertBar.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://bar.com:" + barServer.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java b/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java index 932c15197a1..f1fee902870 100644 --- a/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java @@ -63,6 +63,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.testing.BlockingUtils; import com.linecorp.armeria.internal.testing.NettyServerExtension; @@ -92,6 +93,7 @@ import io.netty.handler.codec.socksx.v4.Socks4CommandStatus; import io.netty.handler.logging.LoggingHandler; import io.netty.handler.proxy.ProxyConnectException; +import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.traffic.ChannelTrafficShapingHandler; @@ -111,7 +113,15 @@ class ProxyClientIntegrationTest { @RegisterExtension @Order(0) - static final SelfSignedCertificateExtension ssc = new SelfSignedCertificateExtension(); + static final SelfSignedCertificateExtension proxySsc = new SelfSignedCertificateExtension(); + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension backendSsc = new SelfSignedCertificateExtension(); + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension clientSsc = new SelfSignedCertificateExtension(); @RegisterExtension @Order(1) @@ -120,7 +130,7 @@ class ProxyClientIntegrationTest { protected void configure(ServerBuilder sb) throws Exception { sb.port(0, SessionProtocol.HTTP); sb.port(0, SessionProtocol.HTTPS); - sb.tlsSelfSigned(); + sb.tls(backendSsc.tlsKeyPair()); sb.service(PROXY_PATH, (ctx, req) -> HttpResponse.of(SUCCESS_RESPONSE)); } }; @@ -172,7 +182,27 @@ protected void configure(Channel ch) throws Exception { protected void configure(Channel ch) throws Exception { assertThat(sslContext).isNotNull(); final SslContext sslContext = SslContextBuilder - .forServer(ssc.privateKey(), ssc.certificate()).build(); + .forServer(proxySsc.privateKey(), proxySsc.certificate()).build(); + ch.pipeline().addLast(sslContext.newHandler(ch.alloc())); + ch.pipeline().addLast(new HttpServerCodec()); + ch.pipeline().addLast(new HttpObjectAggregator(1024)); + ch.pipeline().addLast(new HttpProxyServerHandler()); + ch.pipeline().addLast(new SleepHandler()); + ch.pipeline().addLast(new IntermediaryProxyServerHandler("http", PROXY_CALLBACK)); + } + }; + + @RegisterExtension + @Order(4) + static NettyServerExtension mTlsHttpsProxyServer = new NettyServerExtension() { + @Override + protected void configure(Channel ch) throws Exception { + assertThat(sslContext).isNotNull(); + final SslContext sslContext = SslContextBuilder + .forServer(proxySsc.privateKey(), proxySsc.certificate()) + .clientAuth(ClientAuth.REQUIRE) + .trustManager(clientSsc.certificate()) + .build(); ch.pipeline().addLast(sslContext.newHandler(ch.alloc())); ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new HttpObjectAggregator(1024)); @@ -205,7 +235,7 @@ protected void configure(Channel ch) throws Exception { @BeforeAll static void beforeAll() throws Exception { sslContext = SslContextBuilder - .forServer(ssc.privateKey(), ssc.certificate()).build(); + .forServer(proxySsc.privateKey(), proxySsc.certificate()).build(); } @BeforeEach @@ -507,6 +537,33 @@ void testHttpsProxy(SessionProtocol protocol, Endpoint endpoint) throws Exceptio clientFactory.closeAsync(); } + @ParameterizedTest + @MethodSource("sessionAndEndpointProvider") + void testMTlsHttpsProxyWithTlsProvider(SessionProtocol protocol, Endpoint endpoint) throws Exception { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(clientSsc.tlsKeyPair()) + .trustedCertificates(proxySsc.certificate(), backendSsc.certificate()) + .build(); + + final ClientFactory clientFactory = + ClientFactory.builder() + .tlsProvider(tlsProvider) + .proxyConfig( + ProxyConfig.connect(mTlsHttpsProxyServer.address(), true)).build(); + final WebClient webClient = WebClient.builder(protocol, endpoint) + .factory(clientFactory) + .decorator(LoggingClient.newDecorator()) + .build(); + final CompletableFuture responseFuture = + webClient.get(PROXY_PATH).aggregate(); + final AggregatedHttpResponse response = responseFuture.join(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo(SUCCESS_RESPONSE); + assertThat(numSuccessfulProxyRequests).isEqualTo(1); + clientFactory.closeAsync(); + } + @Test void testProxyWithH2C() throws Exception { final int numRequests = 5; diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java index 3202a4f649a..0f4d44f9d81 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java @@ -25,8 +25,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.internal.common.util.KeyStoreUtil.KeyPair; class KeyStoreUtilTest { // The key store files used in this test case were generated with the following commands: @@ -73,10 +73,10 @@ class KeyStoreUtilTest { void shouldLoadKeyStoreWithOneKeyPair(String filename, @Nullable String keyStorePassword, @Nullable String keyPassword) throws Exception { - final KeyPair keyPair = KeyStoreUtil.load(getFile(filename), - underscoreToNull(keyStorePassword), - underscoreToNull(keyPassword), - null /* no alias */); + final TlsKeyPair keyPair = KeyStoreUtil.load(getFile(filename), + underscoreToNull(keyStorePassword), + underscoreToNull(keyPassword), + null /* no alias */); assertThat(keyPair.certificateChain()).hasSize(1).allSatisfy(cert -> { assertThat(cert.getSubjectX500Principal().getName()).isEqualTo("CN=foo.com"); }); @@ -85,7 +85,7 @@ void shouldLoadKeyStoreWithOneKeyPair(String filename, @ParameterizedTest @CsvSource({"first, foo.com", "second, bar.com"}) void shouldLoadKeyStoreWithTwoKeyPairsIfAliasIsGiven(String alias, String expectedCN) throws Exception { - final KeyPair keyPair = KeyStoreUtil.load(getFile("keystore-two-keys.p12"), + final TlsKeyPair keyPair = KeyStoreUtil.load(getFile("keystore-two-keys.p12"), "my-second-password", null, alias); diff --git a/core/src/test/java/com/linecorp/armeria/server/ServerTlsCertificateMetricsTest.java b/core/src/test/java/com/linecorp/armeria/server/ServerTlsCertificateMetricsTest.java index d78ab0e4002..7a2371976af 100644 --- a/core/src/test/java/com/linecorp/armeria/server/ServerTlsCertificateMetricsTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/ServerTlsCertificateMetricsTest.java @@ -181,7 +181,7 @@ void tlsMetricGivenCertificateChainExpired() { .build(); assertThatGauge(meterRegistry, CERT_VALIDITY_GAUGE_NAME, "localhost").isZero(); - assertThatGauge(meterRegistry, CERT_VALIDITY_DAYS_GAUGE_NAME, "localhost").isEqualTo(-1); + assertThatGauge(meterRegistry, CERT_VALIDITY_DAYS_GAUGE_NAME, "localhost").isLessThanOrEqualTo(-1); assertThatGauge(meterRegistry, CERT_VALIDITY_GAUGE_NAME, "test.root.armeria").isOne(); assertThatGauge(meterRegistry, CERT_VALIDITY_DAYS_GAUGE_NAME, "test.root.armeria").isPositive(); } diff --git a/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java b/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java new file mode 100644 index 00000000000..e8a5ec1d308 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java @@ -0,0 +1,191 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.util.CertificateUtil; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class ServerTlsProviderTest { + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("*", TlsKeyPair.ofSelfSigned("default")) + .keyPair("example.com", TlsKeyPair.ofSelfSigned("example.com")) + .keyPair("api.example.com", TlsKeyPair.ofSelfSigned("api.example.com")) + .keyPair("*.example.com", TlsKeyPair.ofSelfSigned("*.example.com")) + .build(); + + sb.https(0) + .tlsProvider(tlsProvider) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("api.example.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("nested:" + commonName); + }) + .and() + .virtualHost("*.example.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("wild:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension certRenewableServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.tlsProvider(settableTlsProvider); + sb.service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of(commonName); + }); + } + }; + + private static final SettableTlsProvider settableTlsProvider = new SettableTlsProvider(); + + @BeforeEach + void setUp() { + settableTlsProvider.set(null); + } + + @Test + void testDefault() { + final BlockingWebClient client = WebClient.builder(server.uri(SessionProtocol.HTTPS)) + .factory(ClientFactory.insecure()) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:default"); + } + + @CsvSource({ + "example.com, wild:example.com", + "api.example.com, nested:api.example.com", + "foo.example.com, wild:*.example.com", + "example.org, default:default", + "api.example.org, default:default", + "foo.example.org, default:default", + "bar.example.org, default:default", + "baz.bar.example.org, default:default" + }) + @ParameterizedTest + void wildcardMatch(String host, String expected) { + try (ClientFactory factory = ClientFactory.builder() + .tlsNoVerify() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + assertThat(WebClient.builder("https://" + host + ':' + server.httpsPort()) + .factory(factory) + .build() + .blocking() + .get("/") + .contentUtf8()).isEqualTo(expected); + } + } + + @Test + void shouldUseNewTlsKeyPair() { + for (String host : ImmutableList.of("foo.com", "bar.com")) { + settableTlsProvider.set(TlsKeyPair.ofSelfSigned(host)); + try (ClientFactory factory = ClientFactory.builder() + .tlsNoVerify() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + final BlockingWebClient client = WebClient.builder(certRenewableServer.httpsUri()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo(host); + } + } + } + + @Test + void disallowTlsProviderWhenTlsSettingsIsSet() { + assertThatThrownBy(() -> { + Server.builder() + .tls(TlsKeyPair.ofSelfSigned()) + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + + assertThatThrownBy(() -> { + Server.builder() + .tlsSelfSigned() + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + + assertThatThrownBy(() -> { + Server.builder() + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .virtualHost("example.com") + .tls(TlsKeyPair.ofSelfSigned()) + .and() + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + } + + private static class SettableTlsProvider implements TlsProvider { + + @Nullable + private volatile TlsKeyPair keyPair; + + @Override + public TlsKeyPair keyPair(String hostname) { + return keyPair; + } + + public void set(@Nullable TlsKeyPair keyPair) { + this.keyPair = keyPair; + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java b/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java new file mode 100644 index 00000000000..5c5ceacaefb --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.util.TlsEngineType; + +class TlsProviderMappingTest { + + @Test + void testNoDefault() { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair("example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("api.example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("foo.com", TlsKeyPair.ofSelfSigned()) + .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned()) + .build(); + final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider, + TlsEngineType.OPENSSL, + ServerTlsConfig.builder().build(), + Flags.meterRegistry()); + assertThat(mapping.map("example.com")).isNotNull(); + assertThat(mapping.map("api.example.com")).isNotNull(); + assertThatThrownBy(() -> mapping.map("web.example.com")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair found for web.example.com"); + assertThat(mapping.map("foo.com")).isNotNull(); + assertThat(mapping.map("bar.foo.com")).isNotNull(); + assertThatThrownBy(() -> mapping.map("baz.bar.foo.com")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair found for baz.bar.foo.com"); + } + + @Test + void testWithDefault() { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair(TlsKeyPair.ofSelfSigned()) + .keyPair("example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("api.example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("foo.com", TlsKeyPair.ofSelfSigned()) + .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned()) + .build(); + final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider, + TlsEngineType.OPENSSL, + ServerTlsConfig.builder().build(), + Flags.meterRegistry()); + assertThat(mapping.map("example.com")).isNotNull(); + assertThat(mapping.map("api.example.com")).isNotNull(); + assertThat(mapping.map("web.example.com")).isNotNull(); + assertThat(mapping.map("foo.com")).isNotNull(); + assertThat(mapping.map("bar.foo.com")).isNotNull(); + assertThat(mapping.map("baz.bar.foo.com")).isNotNull(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java index 5d8e4a8cfdf..8cc6e3f8b1d 100644 --- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java @@ -107,7 +107,7 @@ void testAllConfigsAreSet() { .multipartUploadsLocation(multipartUploadsLocation) .requestIdGenerator(serviceRequestIdGenerator) .build(new TestService()) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(virtualHost.serviceConfigs()).hasSize(2); final ServiceConfig pathBar = virtualHost.serviceConfigs().get(0); diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java index c211ad6a886..055b51b8bbc 100644 --- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java @@ -168,7 +168,7 @@ void virtualHostWithoutPattern() { Server.builder() .virtualHost("foo.com") .defaultHostname("foo.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h.hostnamePattern()).isEqualTo("foo.com"); assertThat(h.defaultHostname()).isEqualTo("foo.com"); } @@ -178,7 +178,7 @@ void virtualHostWithPattern() { final VirtualHost h = Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h.hostnamePattern()).isEqualTo("*.foo.com"); assertThat(h.defaultHostname()).isEqualTo("bar.foo.com"); } @@ -189,14 +189,14 @@ void accessLoggerCustomization() { Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") .accessLogger(host -> LoggerFactory.getLogger("customize.test")) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.accessLogger().getName()).isEqualTo("customize.test"); final VirtualHost h2 = Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") .accessLogger(LoggerFactory.getLogger("com.foo.test")) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.accessLogger().getName()).isEqualTo("com.foo.test"); } @@ -258,13 +258,13 @@ void tlsAllowUnsafeCiphersCustomization(String templateTlsAllowUnsafeCiphers, switch (expectedOutcome) { case "success": virtualHostBuilder.build(serverBuilder.virtualHostTemplate, noopDependencyInjector, - null, ServerErrorHandler.ofDefault()); + null, ServerErrorHandler.ofDefault(), null); break; case "failure": assertThatThrownBy(() -> virtualHostBuilder.build(serverBuilder.virtualHostTemplate, noopDependencyInjector, null, - ServerErrorHandler.ofDefault())) + ServerErrorHandler.ofDefault(), null)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("TLS with a bad cipher suite"); break; @@ -304,7 +304,7 @@ void virtualHostWithMismatch() { assertThatThrownBy(() -> { Server.builder().virtualHost("foo.com") .defaultHostname("bar.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); }).isInstanceOf(IllegalArgumentException.class); } @@ -313,7 +313,7 @@ void virtualHostWithMismatch2() { assertThatThrownBy(() -> { Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); }).isInstanceOf(IllegalArgumentException.class); } @@ -327,7 +327,7 @@ void precedenceOfDuplicateRoute() throws Exception { final VirtualHost virtualHost = new VirtualHostBuilder(Server.builder(), true) .service(routeA, (ctx, req) -> HttpResponse.of(200)) .service(routeB, (ctx, req) -> HttpResponse.of(201)) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(virtualHost.serviceConfigs().size()).isEqualTo(2); final RoutingContext routingContext = new DefaultRoutingContext(virtualHost(), "example.com", RequestHeaders.of(HttpMethod.GET, "/"), @@ -343,11 +343,11 @@ void multipartUploadsLocationCustomization() { final Path multipartUploadsLocation = FileSystems.getDefault().getPath("logs", "access.log"); final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .multipartUploadsLocation(multipartUploadsLocation) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.multipartUploadsLocation()).isEqualTo(multipartUploadsLocation); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.multipartUploadsLocation()).isEqualTo(template.multipartUploadsLocation()); } @@ -356,11 +356,11 @@ void defaultLogNameCustomization() { final String defaultLogName = "test"; final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .defaultLogName(defaultLogName) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.defaultLogName()).isEqualTo(defaultLogName); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.defaultLogName()).isEqualTo(template.defaultLogName()); } @@ -369,11 +369,11 @@ void successFunctionCustomization() { final SuccessFunction successFunction = (ctx, log) -> false; final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .successFunction(successFunction) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.successFunction()).isEqualTo(successFunction); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.successFunction()).isEqualTo(template.successFunction()); } } diff --git a/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java b/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java index 2c2f76a95e4..5307cf68669 100644 --- a/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java +++ b/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java @@ -45,56 +45,59 @@ class OverriddenBuilderMethodsReturnTypeTest { @Test void methodChaining() { - final Set excludedClasses = ImmutableSet.of("JsonLogFormatterBuilder", - "TextLogFormatterBuilder", - "PathStreamMessageBuilder", - "InputStreamStreamMessageBuilder", - "ContextPathAnnotatedServiceConfigSetters", - "ContextPathDecoratingBindingBuilder", - "ContextPathServiceBindingBuilder", - "ContextPathServicesBuilder", - "DecoratingServiceBindingBuilder", - "ServerBuilder", - "ServiceBindingBuilder", - "AnnotatedServiceBindingBuilder", - "VirtualHostAnnotatedServiceBindingBuilder", - "VirtualHostBuilder", - "VirtualHostContextPathDecoratingBindingBuilder", - "VirtualHostContextPathServiceBindingBuilder", - "VirtualHostContextPathServicesBuilder", - "VirtualHostDecoratingServiceBindingBuilder", - "VirtualHostServiceBindingBuilder", - "ChainedCorsPolicyBuilder", - "CorsPolicyBuilder", - "ConsulEndpointGroupBuilde", - "AbstractDnsResolverBuilder", - "AbstractRuleBuilder", - "AbstractRuleWithContentBuilder", - "DnsResolverGroupBuilder", - "AbstractCircuitBreakerMappingBuilder", - "CircuitBreakerMappingBuilder", - "CircuitBreakerRuleBuilder", - "CircuitBreakerRuleWithContentBuilder", - "AbstractDynamicEndpointGroupBuilder", - "DynamicEndpointGroupBuilder", - "DynamicEndpointGroupSetters", - "DnsAddressEndpointGroupBuilder", - "DnsEndpointGroupBuilder", - "DnsServiceEndpointGroupBuilder", - "DnsTextEndpointGroupBuilder", - "AbstractHealthCheckedEndpointGroupBuilder", - "HealthCheckedEndpointGroupBuilder", - "RetryRuleBuilder", - "RetryRuleWithContentBuilder", - "AbstractHeadersSanitizerBuilder", - "JsonHeadersSanitizerBuilder", - "TextHeadersSanitizerBuilder", - "EurekaEndpointGroupBuilder", - "KubernetesEndpointGroupBuilder", - "Resilience4jCircuitBreakerMappingBuilder", - "ZooKeeperEndpointGroupBuilder", - "AbstractCuratorFrameworkBuilder", - "ZooKeeperUpdatingListenerBuilder"); + final Set excludedClasses = ImmutableSet.of( + "AbstractCircuitBreakerMappingBuilder", + "AbstractCuratorFrameworkBuilder", + "AbstractDnsResolverBuilder", + "AbstractDynamicEndpointGroupBuilder", + "AbstractHeadersSanitizerBuilder", + "AbstractHealthCheckedEndpointGroupBuilder", + "AbstractRuleBuilder", + "AbstractRuleWithContentBuilder", + "AnnotatedServiceBindingBuilder", + "ChainedCorsPolicyBuilder", + "CircuitBreakerMappingBuilder", + "CircuitBreakerRuleBuilder", + "CircuitBreakerRuleWithContentBuilder", + "ClientTlsConfigBuilder", + "ConsulEndpointGroupBuilder", + "ContextPathAnnotatedServiceConfigSetters", + "ContextPathDecoratingBindingBuilder", + "ContextPathServiceBindingBuilder", + "ContextPathServicesBuilder", + "CorsPolicyBuilder", + "DecoratingServiceBindingBuilder", + "DnsAddressEndpointGroupBuilder", + "DnsEndpointGroupBuilder", + "DnsResolverGroupBuilder", + "DnsServiceEndpointGroupBuilder", + "DnsTextEndpointGroupBuilder", + "DynamicEndpointGroupBuilder", + "DynamicEndpointGroupSetters", + "EurekaEndpointGroupBuilder", + "HealthCheckedEndpointGroupBuilder", + "InputStreamStreamMessageBuilder", + "JsonHeadersSanitizerBuilder", + "JsonLogFormatterBuilder", + "KubernetesEndpointGroupBuilder", + "PathStreamMessageBuilder", + "Resilience4jCircuitBreakerMappingBuilder", + "RetryRuleBuilder", + "RetryRuleWithContentBuilder", + "ServerBuilder", + "ServerTlsConfigBuilder", + "ServiceBindingBuilder", + "TextHeadersSanitizerBuilder", + "TextLogFormatterBuilder", + "VirtualHostAnnotatedServiceBindingBuilder", + "VirtualHostBuilder", + "VirtualHostContextPathDecoratingBindingBuilder", + "VirtualHostContextPathServiceBindingBuilder", + "VirtualHostContextPathServicesBuilder", + "VirtualHostDecoratingServiceBindingBuilder", + "VirtualHostServiceBindingBuilder", + "ZooKeeperEndpointGroupBuilder", + "ZooKeeperUpdatingListenerBuilder"); final String packageName = "com.linecorp.armeria"; findAllClasses(packageName).stream() .map(ReflectionUtils::forName) diff --git a/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java b/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java index 56ecf114893..9393d208f18 100644 --- a/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java +++ b/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java @@ -28,6 +28,7 @@ import java.time.temporal.TemporalAccessor; import java.util.Date; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; @@ -50,6 +51,9 @@ public final class SelfSignedCertificateRuleDelegate { @Nullable private SelfSignedCertificate certificate; + @Nullable + private TlsKeyPair tlsKeyPair; + /** * Creates a new instance. */ @@ -205,6 +209,16 @@ public File privateKeyFile() { return ensureCertificate().privateKey(); } + /** + * Returns the {@link TlsKeyPair} of the self-signed certificate. + */ + public TlsKeyPair tlsKeyPair() { + if (tlsKeyPair == null) { + tlsKeyPair = TlsKeyPair.of(privateKey(), certificate()); + } + return tlsKeyPair; + } + private SelfSignedCertificate ensureCertificate() { checkState(certificate != null, "certificate not created"); return certificate; diff --git a/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java b/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java index e155fd62e82..d64c3e94f70 100644 --- a/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java +++ b/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.internal.testing.SelfSignedCertificateRuleDelegate; import com.linecorp.armeria.testing.junit5.common.AbstractAllOrEachExtension; @@ -144,4 +146,12 @@ public PrivateKey privateKey() { public File privateKeyFile() { return delegate.privateKeyFile(); } + + /** + * Returns the {@link TlsKeyPair} of the self-signed certificate. + */ + @UnstableApi + public TlsKeyPair tlsKeyPair() { + return delegate.tlsKeyPair(); + } } From 93088a30891ce0c1ce2503d51bc7e3f73158d30a Mon Sep 17 00:00:00 2001 From: Jiwon Date: Thu, 7 Nov 2024 17:49:51 +0900 Subject: [PATCH 06/12] Avoid redundant fromObject(value) calls in addObject methods (#5962) Motivation: In the addObject and addObjectAndNotify methods, the fromObject(value) function is being called redundantly. This results in unnecessary computations, which could affect performance and reduce code readability. By removing the redundant calls to fromObject(value), we can improve the efficiency and clarity of the code. Modifications: - Modified the addObject method to pass the original value directly to the addObjectAndNotify method without calling fromObject(value). - Updated the addObjectAndNotify method to perform the fromObject(value) call once, ensuring it is not called multiple times for the same value. Result: - Improved code readability and maintainability. - Eliminated the redundant fromObject(value) calls, enhancing performance. --- .../main/java/com/linecorp/armeria/common/StringMultimap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java b/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java index 0d12d8def59..5e64edf5711 100644 --- a/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java +++ b/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java @@ -722,7 +722,7 @@ final void addObjectWithoutNotifying(IN_NAME name, Iterable values) { final void addObject(IN_NAME name, Object value) { final NAME normalizedName = normalizeName(name); requireNonNull(value, "value"); - addObjectAndNotify(normalizedName, fromObject(value), true); + addObjectAndNotify(normalizedName, value, true); } final void addObject(IN_NAME name, Iterable values) { From 90258fef076ed7d853e5895c1329fdbc58bcf1ea Mon Sep 17 00:00:00 2001 From: Ikhun Um Date: Thu, 7 Nov 2024 17:58:28 +0900 Subject: [PATCH 07/12] Fix a bug where CORS headers are not injected when a `ServerErrorHandler` is set (#5939) Motivation: `ServerErrorHandler.renderStatus()` is used in `DefaultServerErrorHandler` and may not be called in a custom `ServerErrorHandler`. Attempting to inject CORS headers via `CorsServerErrorHandler.renderStatus()` may not work. Modifications: - Use `ctx.mutateAdditionalResponseHeaders()` to set CORS headers instead of mutating the response headers. - Store whether a CORS is set by `CorsService` to ctx.attr(). - The value is used in `CorsServerErrorHandler` to set if missing Result: - CORS headers for failed requests are now correctly set even when a custom `ServerErrorHandler` is configured. - Fixes #5493 --- .../internal/server/CorsHeaderUtil.java | 49 ++++---- .../server/CorsServerErrorHandler.java | 75 ++++++------- .../armeria/server/cors/CorsService.java | 11 +- .../cors/CorsServerErrorHandlerTest.java | 106 ++++++++++++++++-- .../ArmeriaSettingsConfigurationTest.java | 8 +- 5 files changed, 170 insertions(+), 79 deletions(-) diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java b/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java index db0d19b7d58..7aad575adc6 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java @@ -25,16 +25,17 @@ import com.google.common.base.Strings; import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpHeadersBuilder; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.ResponseHeaders; -import com.linecorp.armeria.common.ResponseHeadersBuilder; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.cors.CorsConfig; import com.linecorp.armeria.server.cors.CorsPolicy; +import com.linecorp.armeria.server.cors.CorsService; import io.netty.util.AsciiString; +import io.netty.util.AttributeKey; /** * A utility class related to CORS headers. @@ -47,15 +48,8 @@ public final class CorsHeaderUtil { public static final String DELIMITER = ","; private static final Joiner HEADER_JOINER = Joiner.on(DELIMITER); - public static ResponseHeaders addCorsHeaders(ServiceRequestContext ctx, CorsConfig corsConfig, - ResponseHeaders responseHeaders) { - final HttpRequest httpRequest = ctx.request(); - final ResponseHeadersBuilder responseHeadersBuilder = responseHeaders.toBuilder(); - - setCorsResponseHeaders(ctx, httpRequest, responseHeadersBuilder, corsConfig); - - return responseHeadersBuilder.build(); - } + private static final AttributeKey IS_CORS_SET = + AttributeKey.valueOf(CorsService.class, "IS_CORS_SET"); /** * Emit CORS headers if origin was found. @@ -64,7 +58,7 @@ public static ResponseHeaders addCorsHeaders(ServiceRequestContext ctx, CorsConf * @param headers the headers to modify */ public static void setCorsResponseHeaders(ServiceRequestContext ctx, HttpRequest req, - ResponseHeadersBuilder headers, CorsConfig config) { + HttpHeadersBuilder headers, CorsConfig config) { final CorsPolicy policy = setCorsOrigin(ctx, req, headers, config); if (policy != null) { setCorsAllowCredentials(headers, policy); @@ -73,7 +67,7 @@ public static void setCorsResponseHeaders(ServiceRequestContext ctx, HttpRequest } } - public static void setCorsAllowCredentials(ResponseHeadersBuilder headers, CorsPolicy policy) { + public static void setCorsAllowCredentials(HttpHeadersBuilder headers, CorsPolicy policy) { // The string "*" cannot be used for a resource that supports credentials. // https://www.w3.org/TR/cors/#resource-requests if (policy.isCredentialsAllowed() && @@ -82,7 +76,7 @@ public static void setCorsAllowCredentials(ResponseHeadersBuilder headers, CorsP } } - private static void setCorsExposeHeaders(ResponseHeadersBuilder headers, CorsPolicy corsPolicy) { + private static void setCorsExposeHeaders(HttpHeadersBuilder headers, CorsPolicy corsPolicy) { if (corsPolicy.exposedHeaders().isEmpty()) { return; } @@ -90,7 +84,7 @@ private static void setCorsExposeHeaders(ResponseHeadersBuilder headers, CorsPol headers.set(HttpHeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, joinExposedHeaders(corsPolicy)); } - public static void setCorsAllowHeaders(RequestHeaders requestHeaders, ResponseHeadersBuilder headers, + public static void setCorsAllowHeaders(RequestHeaders requestHeaders, HttpHeadersBuilder headers, CorsPolicy corsPolicy) { if (corsPolicy.shouldAllowAllRequestHeaders()) { final String header = requestHeaders.get(HttpHeaderNames.ACCESS_CONTROL_REQUEST_HEADERS); @@ -118,7 +112,7 @@ public static void setCorsAllowHeaders(RequestHeaders requestHeaders, ResponseHe */ @Nullable public static CorsPolicy setCorsOrigin(ServiceRequestContext ctx, HttpRequest request, - ResponseHeadersBuilder headers, CorsConfig config) { + HttpHeadersBuilder headers, CorsConfig config) { final String origin = request.headers().get(HttpHeaderNames.ORIGIN); if (origin != null) { @@ -149,26 +143,26 @@ public static CorsPolicy setCorsOrigin(ServiceRequestContext ctx, HttpRequest re return null; } - private static void setCorsOrigin(ResponseHeadersBuilder headers, String origin) { + private static void setCorsOrigin(HttpHeadersBuilder headers, String origin) { headers.set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, origin); } - private static void echoCorsRequestOrigin(HttpRequest request, ResponseHeadersBuilder headers) { + private static void echoCorsRequestOrigin(HttpRequest request, HttpHeadersBuilder headers) { final String origin = request.headers().get(HttpHeaderNames.ORIGIN); if (origin != null) { setCorsOrigin(headers, origin); } } - private static void addCorsVaryHeader(ResponseHeadersBuilder headers) { + private static void addCorsVaryHeader(HttpHeadersBuilder headers) { headers.add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN.toString()); } - private static void setCorsAnyOrigin(ResponseHeadersBuilder headers) { + private static void setCorsAnyOrigin(HttpHeadersBuilder headers) { setCorsOrigin(headers, ANY_ORIGIN); } - private static void setCorsNullOrigin(ResponseHeadersBuilder headers) { + private static void setCorsNullOrigin(HttpHeadersBuilder headers) { setCorsOrigin(headers, NULL_ORIGIN); } @@ -204,6 +198,19 @@ private static String joinAllowedRequestHeaders(CorsPolicy corsPolicy) { return joinHeaders(corsPolicy.allowedRequestHeaders()); } + public static boolean isForbiddenOrigin(CorsConfig config, ServiceRequestContext ctx, RequestHeaders req) { + return config.isShortCircuit() && + config.getPolicy(req.get(HttpHeaderNames.ORIGIN), ctx.routingContext()) == null; + } + + public static boolean isCorsHeadersSet(ServiceRequestContext ctx) { + return ctx.hasAttr(IS_CORS_SET); + } + + public static void corsHeadersSet(ServiceRequestContext ctx) { + ctx.setAttr(IS_CORS_SET, true); + } + private CorsHeaderUtil() { } } diff --git a/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java b/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java index d8ea993a6f1..7cf222863f0 100644 --- a/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java @@ -16,15 +16,15 @@ package com.linecorp.armeria.server; -import static com.linecorp.armeria.internal.server.CorsHeaderUtil.addCorsHeaders; +import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.isCorsPreflightRequest; +import static com.linecorp.armeria.internal.server.CorsHeaderUtil.isForbiddenOrigin; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.server.cors.CorsConfig; +import com.linecorp.armeria.internal.server.CorsHeaderUtil; import com.linecorp.armeria.server.cors.CorsService; /** @@ -50,49 +50,46 @@ public AggregatedHttpResponse renderStatus(@Nullable ServiceRequestContext ctx, @Nullable RequestHeaders headers, HttpStatus status, @Nullable String description, @Nullable Throwable cause) { - - if (ctx == null) { - return serverErrorHandler.renderStatus(null, serviceConfig, headers, status, description, cause); - } - - final CorsService corsService = ctx.findService(CorsService.class); - if (corsService == null) { - return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); - } - - final AggregatedHttpResponse res = serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, - description, cause); - - if (res == null) { - return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); - } - - final CorsConfig corsConfig = corsService.config(); - final ResponseHeaders updatedResponseHeaders = addCorsHeaders(ctx, corsConfig, - res.headers()); - - return AggregatedHttpResponse.of(updatedResponseHeaders, res.content()); + return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); } @Nullable @Override public HttpResponse onServiceException(ServiceRequestContext ctx, Throwable cause) { - if (cause instanceof HttpResponseException) { - final HttpResponse oldRes = serverErrorHandler.onServiceException(ctx, cause); - if (oldRes == null) { - return null; - } - final CorsService corsService = ctx.findService(CorsService.class); - if (corsService == null) { - return oldRes; - } - return oldRes.recover(HttpResponseException.class, ex -> { - return ex.httpResponse() - .mapHeaders(oldHeaders -> addCorsHeaders(ctx, corsService.config(), oldHeaders)); + final CorsService corsService = ctx.findService(CorsService.class); + if (shouldSetCorsHeaders(corsService, ctx)) { + assert corsService != null; + ctx.mutateAdditionalResponseHeaders(builder -> { + CorsHeaderUtil.setCorsResponseHeaders(ctx, ctx.request(), builder, corsService.config()); }); - } else { - return serverErrorHandler.onServiceException(ctx, cause); } + return serverErrorHandler.onServiceException(ctx, cause); + } + + /** + * Sets CORS headers for + * simple CORS requests or main requests. + * Preflight requests is unsupported because we don't know if it is safe to perform the main request. + */ + private static boolean shouldSetCorsHeaders(@Nullable CorsService corsService, ServiceRequestContext ctx) { + if (corsService == null) { + // No CorsService is configured. + return false; + } + if (CorsHeaderUtil.isCorsHeadersSet(ctx)) { + // CORS headers were set by CorsService. + return false; + } + final RequestHeaders headers = ctx.request().headers(); + if (isCorsPreflightRequest(headers)) { + return false; + } + //noinspection RedundantIfStatement + if (isForbiddenOrigin(corsService.config(), ctx, headers)) { + return false; + } + + return true; } @Nullable diff --git a/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java b/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java index ef0c7cdcd31..4c3f58863ec 100644 --- a/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java +++ b/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java @@ -17,6 +17,7 @@ package com.linecorp.armeria.server.cors; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.isCorsPreflightRequest; +import static com.linecorp.armeria.internal.server.CorsHeaderUtil.isForbiddenOrigin; import static java.util.Objects.requireNonNull; import java.util.List; @@ -29,7 +30,6 @@ import com.google.common.collect.ImmutableList; -import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; @@ -127,21 +127,20 @@ public CorsConfig config() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { + CorsHeaderUtil.corsHeadersSet(ctx); // check if CORS preflight must be returned, or if // we need to forbid access because origin could not be validated if (isCorsPreflightRequest(req.headers())) { return handleCorsPreflight(ctx, req); } - if (config.isShortCircuit() && - config.getPolicy(req.headers().get(HttpHeaderNames.ORIGIN), ctx.routingContext()) == null) { + if (isForbiddenOrigin(config, ctx, req.headers())) { return forbidden(); } - return unwrap().serve(ctx, req).mapHeaders(headers -> { - final ResponseHeadersBuilder builder = headers.toBuilder(); + ctx.mutateAdditionalResponseHeaders(builder -> { CorsHeaderUtil.setCorsResponseHeaders(ctx, req, builder, config); - return builder.build(); }); + return unwrap().serve(ctx, req); } /** diff --git a/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java b/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java index 947510695ba..58b449533cc 100644 --- a/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java @@ -20,10 +20,13 @@ import java.util.function.Function; +import javax.annotation.Nonnull; + import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpHeaderNames; @@ -31,6 +34,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.internal.testing.AnticipatedException; import com.linecorp.armeria.server.HttpResponseException; import com.linecorp.armeria.server.HttpService; import com.linecorp.armeria.server.HttpStatusException; @@ -51,23 +55,50 @@ protected void configure(ServerBuilder sb) throws Exception { HttpResponseException.of( HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)), false); - addCorsServiceWithException(sb, myService, "/cors_status_exception-route", + addCorsServiceWithException(sb, myService, "/cors_status_exception_route", HttpStatusException.of(HttpStatus.INTERNAL_SERVER_ERROR), true); - addCorsServiceWithException(sb, myService, "/cors_response_exception-route", + addCorsServiceWithException(sb, myService, "/cors_response_exception_route", HttpResponseException.of( HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)), true); + + sb.service("/cors_service_exception_throw", ((HttpService) (ctx, req) -> { + throw new RuntimeException("Service exception"); + }).decorate(corsDecorator())); + + sb.service("/cors_service_exception_return", ((HttpService) (ctx, req) -> { + return HttpResponse.ofFailure(new RuntimeException("Service exception")); + }).decorate(corsDecorator())); + } + }; + + @RegisterExtension + static ServerExtension serverWithErrorHandler = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.decorator(corsDecorator()); + sb.service("/cors_service_exception_throw", (ctx, req) -> { + throw new AnticipatedException("Service exception"); + }); + + sb.service("/cors_service_exception_return", (ctx, req) -> { + return HttpResponse.ofFailure(new AnticipatedException("Service exception")); + }); + sb.decorator("/cors_decorator_exception_throw", (delegate, ctx, req) -> { + throw new AnticipatedException("Service exception"); + }); + + sb.errorHandler((ctx, cause) -> { + if (cause instanceof AnticipatedException) { + return HttpResponse.of(HttpStatus.SERVICE_UNAVAILABLE); + } + return null; + }); } }; private static void addCorsServiceWithException(ServerBuilder sb, HttpService myService, String pathPattern, Exception exception, boolean useRouteDecorator) { - final Function corsService = - CorsService.builder("http://example.com") - .allowRequestMethods(HttpMethod.POST, HttpMethod.GET) - .allowRequestHeaders("allow_request_header") - .exposeHeaders("expose_header_1", "expose_header_2") - .preflightResponseHeader("x-preflight-cors", "Hello CORS") - .newDecorator(); + final Function corsService = corsDecorator(); if (useRouteDecorator) { sb.decorator(pathPattern, corsService); sb.decorator(pathPattern, (delegate, ctx, req) -> { @@ -82,6 +113,18 @@ private static void addCorsServiceWithException(ServerBuilder sb, HttpService my sb.service(pathPattern, myService); } + @Nonnull + private static Function corsDecorator() { + final Function corsService = + CorsService.builder("http://example.com") + .allowRequestMethods(HttpMethod.POST, HttpMethod.GET) + .allowRequestHeaders("allow_request_header") + .exposeHeaders("expose_header_1", "expose_header_2") + .preflightResponseHeader("x-preflight-cors", "Hello CORS") + .newDecorator(); + return corsService; + } + private static AggregatedHttpResponse request(WebClient client, HttpMethod method, String path, String origin, String requestMethod) { return client.execute(RequestHeaders.of( @@ -97,15 +140,54 @@ private static AggregatedHttpResponse preflightRequest(WebClient client, String @ParameterizedTest @CsvSource({ "/cors_status_exception", - "/cors_status_exception-route", + "/cors_status_exception_route", "/cors_response_exception", - "/cors_response_exception-route" + "/cors_response_exception_route", }) - void testCorsHeaderWithException(String path) { + void shouldNotHandlePreflightRequest(String path) { final WebClient client = server.webClient(); final AggregatedHttpResponse response = preflightRequest(client, path, "http://example.com", "GET"); assertThat(response.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isNull(); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull(); + } + + /** + * Test a simple request + * that does not trigger a CORS preflight. + */ + @ParameterizedTest + @CsvSource({ + "/cors_service_exception_throw", + "/cors_service_exception_return", + }) + void testSimpleRequest(String path) { + final BlockingWebClient client = server.blockingWebClient(cb -> { + cb.addHeader(HttpHeaderNames.ORIGIN, "http://example.com"); + }); + final AggregatedHttpResponse response = client.get(path); + + assertThat(response.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isEqualTo( + "allow_request_header"); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo( + "http://example.com"); + } + + @ParameterizedTest + @CsvSource({ + "/cors_service_exception_throw", + "/cors_service_exception_return", + "/cors_decorator_exception_throw" + }) + void testCorsHeaderWithExceptionErrorHandler(String path) { + final BlockingWebClient client = serverWithErrorHandler.blockingWebClient(cb -> { + cb.addHeader(HttpHeaderNames.ORIGIN, "http://example.com"); + }); + final AggregatedHttpResponse response = client.get(path); + + assertThat(response.status()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isEqualTo( "allow_request_header"); assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo( diff --git a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java index b111becec47..6caae8d592b 100644 --- a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java +++ b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java @@ -28,11 +28,14 @@ import org.springframework.test.context.ActiveProfiles; import com.linecorp.armeria.common.DependencyInjector; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.Server; import com.linecorp.armeria.server.ServerConfig; import com.linecorp.armeria.server.ServerErrorHandler; +import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.VirtualHost; import com.linecorp.armeria.spring.ArmeriaSettingsConfigurationTest.TestConfiguration; @@ -119,6 +122,9 @@ void buildServerBasedOnProperties() { assertThat(config.gracefulShutdownQuietPeriod().toMillis()).isEqualTo(1000); assertThat(config.dependencyInjector().getInstance(Object.class)).isSameAs(dummyObject); - assertThat(config.errorHandler().onServiceException(null, null)).isSameAs(dummyResponse); + final ServiceRequestContext ctx = ServiceRequestContext.of( + HttpRequest.of(HttpMethod.GET, "/")); + assertThat(config.errorHandler().onServiceException(ctx, new RuntimeException())) + .isSameAs(dummyResponse); } } From 011741dc51cf328b78488655e78ceca1cdc0a1b3 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 7 Nov 2024 18:05:51 +0900 Subject: [PATCH 08/12] Allow `XdsEndpointGroup` to disable health checking per `Endpoint` (#5879) Motivation: While working on documentation, I realized that `disable_active_health_check` is not supported. ref: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/endpoint/v3/endpoint_components.proto#config-endpoint-v3-endpoint-healthcheckconfig This can be useful when users would like to force certain endpoints to be healthy without health checking. Modifications: - Renamed `HttpHealthChecker` to `DefaultHttpHealthChecker` - Introduced the `HttpHealthChecker` interface - Introduced `StaticHttpHealthChecker` which sets the health status immediately - Refer to `HealthCheckConfig#getDisableActiveHealthCheck` and decide whether to return a `StaticHttpHealthChecker` Result: - `disable_active_health_check` is now supported --- .../DefaultHealthCheckerContext.java | 2 +- .../HealthCheckedEndpointGroupBuilder.java | 6 +- .../healthcheck/HealthCheckerContext.java | 2 +- .../healthcheck/DefaultHttpHealthChecker.java | 353 ++++++++++++++++++ .../healthcheck/HttpHealthChecker.java | 336 +---------------- .../endpoint/StaticHttpHealthChecker.java | 43 +++ .../XdsHealthCheckedEndpointGroupBuilder.java | 14 +- .../armeria/xds/XdsTestResources.java | 7 + .../client/endpoint/HealthCheckedTest.java | 65 ++++ 9 files changed, 484 insertions(+), 344 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/DefaultHttpHealthChecker.java create mode 100644 xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java index 3cfc522b2ae..7c7e9ea5ff2 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java @@ -186,7 +186,7 @@ public void updateHealth(double health) { } @Override - public void updateHealth(double health, ClientRequestContext ctx, + public void updateHealth(double health, @Nullable ClientRequestContext ctx, @Nullable ResponseHeaders headers, @Nullable Throwable cause) { final boolean isHealthy = health > 0; if (headers != null && headers.contains("x-envoy-degraded")) { diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java index 5e950cc3ca7..e055377cbfb 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java @@ -23,7 +23,7 @@ import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.DefaultHttpHealthChecker; /** * A builder for creating a new {@link HealthCheckedEndpointGroup} that sends HTTP health check requests. @@ -73,8 +73,8 @@ private static class HttpHealthCheckerFactory implements Function 0) { + headers = builder.add(HttpHeaderNames.IF_NONE_MATCH, + wasHealthy ? "\"healthy\"" : "\"unhealthy\"") + .add(HttpHeaderNames.PREFER, "wait=" + maxLongPollingSeconds) + .build(); + } else { + headers = builder.build(); + } + + try (ClientRequestContextCaptor reqCtxCaptor = Clients.newContextCaptor()) { + lastResponse = webClient.execute(headers); + final ClientRequestContext reqCtx = reqCtxCaptor.get(); + lastResponse.subscribe(new HealthCheckResponseSubscriber(reqCtx, lastResponse), + reqCtx.eventLoop().withoutContext(), + SubscriptionOption.WITH_POOLED_OBJECTS); + } + } finally { + unlock(); + } + } + + @Override + public CompletableFuture closeAsync() { + return closeable.closeAsync(); + } + + private synchronized void closeAsync(CompletableFuture future) { + lock(); + try { + if (lastResponse == null) { + // Called even before the first request is sent. + future.complete(null); + } else { + lastResponse.abort(); + lastResponse.whenComplete().handle((unused1, unused2) -> future.complete(null)); + } + } finally { + unlock(); + } + } + + @Override + public void close() { + closeable.close(); + } + + private final class ResponseTimeoutUpdater extends SimpleDecoratingHttpClient { + ResponseTimeoutUpdater(HttpClient delegate) { + super(delegate); + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { + if (maxLongPollingSeconds > 0) { + ctx.setResponseTimeoutMillis(TimeoutMode.EXTEND, + TimeUnit.SECONDS.toMillis(maxLongPollingSeconds)); + } + return unwrap().execute(ctx, req); + } + } + + private class HealthCheckResponseSubscriber implements Subscriber { + + private final ClientRequestContext reqCtx; + private final HttpResponse res; + @Nullable + private Subscription subscription; + @Nullable + private ResponseHeaders responseHeaders; + private boolean isHealthy; + private boolean receivedExpectedResponse; + private boolean updatedHealth; + + @Nullable + private ScheduledFuture pingCheckFuture; + private long lastPingTimeNanos; + + HealthCheckResponseSubscriber(ClientRequestContext reqCtx, HttpResponse res) { + this.reqCtx = reqCtx; + this.res = res; + } + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + maybeSchedulePingCheck(); + } + + @Override + public void onNext(HttpObject obj) { + assert subscription != null; + + if (closeable.isClosing()) { + subscription.cancel(); + return; + } + + try { + if (!(obj instanceof ResponseHeaders)) { + PooledObjects.close(obj); + return; + } + + final ResponseHeaders headers = (ResponseHeaders) obj; + responseHeaders = headers; + updateLongPollingSettings(headers); + + final HttpStatus status = headers.status(); + final HttpStatusClass statusClass = status.codeClass(); + switch (statusClass) { + case INFORMATIONAL: + maybeSchedulePingCheck(); + break; + case SERVER_ERROR: + receivedExpectedResponse = true; + break; + case SUCCESS: + isHealthy = true; + receivedExpectedResponse = true; + break; + default: + if (status == HttpStatus.NOT_MODIFIED) { + isHealthy = wasHealthy; + receivedExpectedResponse = true; + } else { + // Do not use long polling on an unexpected status for safety. + maxLongPollingSeconds = 0; + + if (statusClass == HttpStatusClass.CLIENT_ERROR) { + logger.warn("{} Unexpected 4xx health check response: {} A 4xx response " + + "generally indicates a misconfiguration of the client. " + + "Did you happen to forget to configure the {}'s client options?", + reqCtx, headers, HealthCheckedEndpointGroup.class.getSimpleName()); + } else { + logger.warn("{} Unexpected health check response: {}", reqCtx, headers); + } + } + } + } finally { + subscription.request(1); + } + } + + @Override + public void onError(Throwable t) { + updateHealth(t); + } + + @Override + public void onComplete() { + updateHealth(null); + } + + private void updateLongPollingSettings(ResponseHeaders headers) { + final String longPollingSettings = headers.get(ARMERIA_LPHC); + if (longPollingSettings == null) { + maxLongPollingSeconds = 0; + pingIntervalSeconds = 0; + return; + } + + final int commaPos = longPollingSettings.indexOf(','); + int maxLongPollingSeconds = 0; + int pingIntervalSeconds = 0; + try { + maxLongPollingSeconds = Integer.max( + 0, Integer.parseInt(longPollingSettings.substring(0, commaPos).trim())); + pingIntervalSeconds = Integer.max( + 0, Integer.parseInt(longPollingSettings.substring(commaPos + 1).trim())); + } catch (Exception e) { + // Ignore malformed settings. + } + + DefaultHttpHealthChecker.this.maxLongPollingSeconds = maxLongPollingSeconds; + if (maxLongPollingSeconds > 0 && pingIntervalSeconds < maxLongPollingSeconds) { + DefaultHttpHealthChecker.this.pingIntervalSeconds = pingIntervalSeconds; + } else { + DefaultHttpHealthChecker.this.pingIntervalSeconds = 0; + } + } + + // TODO(trustin): Remove once https://github.com/line/armeria/issues/1063 is fixed. + private void maybeSchedulePingCheck() { + lastPingTimeNanos = System.nanoTime(); + + if (pingCheckFuture != null) { + return; + } + + final int pingIntervalSeconds = DefaultHttpHealthChecker.this.pingIntervalSeconds; + if (pingIntervalSeconds <= 0) { + return; + } + + final long pingTimeoutNanos = TimeUnit.SECONDS.toNanos(pingIntervalSeconds) * 2; + pingCheckFuture = reqCtx.eventLoop().withoutContext().scheduleWithFixedDelay(() -> { + if (System.nanoTime() - lastPingTimeNanos >= pingTimeoutNanos) { + // Did not receive a ping on time. + final ResponseTimeoutException cause = ResponseTimeoutException.get(); + res.abort(cause); + isHealthy = false; + receivedExpectedResponse = false; + updateHealth(cause); + } + }, 1, 1, TimeUnit.SECONDS); + } + + private void updateHealth(@Nullable Throwable cause) { + if (pingCheckFuture != null) { + pingCheckFuture.cancel(false); + } + + if (closeable.isClosing() || updatedHealth) { + return; + } + + updatedHealth = true; + + ctx.updateHealth(isHealthy ? 1 : 0, reqCtx, responseHeaders, cause); + wasHealthy = isHealthy; + + final ScheduledExecutorService executor = ctx.executor(); + try { + // Send a long polling check immediately if: + // - Server has long polling enabled. + // - Server responded with 2xx or 5xx. + if (maxLongPollingSeconds > 0 && receivedExpectedResponse) { + executor.execute(DefaultHttpHealthChecker.this::check); + } else { + executor.schedule(DefaultHttpHealthChecker.this::check, + ctx.nextDelayMillis(), TimeUnit.MILLISECONDS); + } + } catch (RejectedExecutionException ignored) { + // Can happen if the Endpoint being checked has been disappeared from + // the delegate EndpointGroup. See HealthCheckedEndpointGroupTest.disappearedEndpoint(). + } + } + } + + private void lock() { + lock.lock(); + } + + private void unlock() { + lock.unlock(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java index 6c9f7a4c220..831a09810fd 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java @@ -13,342 +13,10 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.internal.client.endpoint.healthcheck; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +package com.linecorp.armeria.internal.client.endpoint.healthcheck; -import com.linecorp.armeria.client.ClientRequestContext; -import com.linecorp.armeria.client.ClientRequestContextCaptor; -import com.linecorp.armeria.client.Clients; -import com.linecorp.armeria.client.Endpoint; -import com.linecorp.armeria.client.HttpClient; -import com.linecorp.armeria.client.ResponseTimeoutException; -import com.linecorp.armeria.client.SimpleDecoratingHttpClient; -import com.linecorp.armeria.client.WebClient; -import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckedEndpointGroup; -import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckerContext; -import com.linecorp.armeria.common.HttpHeaderNames; -import com.linecorp.armeria.common.HttpMethod; -import com.linecorp.armeria.common.HttpObject; -import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.HttpResponse; -import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.HttpStatusClass; -import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.RequestHeadersBuilder; -import com.linecorp.armeria.common.ResponseHeaders; -import com.linecorp.armeria.common.SessionProtocol; -import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.common.util.AsyncCloseableSupport; -import com.linecorp.armeria.common.util.TimeoutMode; -import com.linecorp.armeria.internal.common.util.ReentrantShortLock; -import com.linecorp.armeria.unsafe.PooledObjects; - -import io.netty.util.AsciiString; -import io.netty.util.concurrent.ScheduledFuture; - -public final class HttpHealthChecker implements AsyncCloseable { - - private static final Logger logger = LoggerFactory.getLogger(HttpHealthChecker.class); - - private static final AsciiString ARMERIA_LPHC = HttpHeaderNames.of("armeria-lphc"); - - private final ReentrantLock lock = new ReentrantShortLock(); - private final HealthCheckerContext ctx; - private final WebClient webClient; - private final String authority; - private final String path; - private final boolean useGet; - private boolean wasHealthy; - private int maxLongPollingSeconds; - private int pingIntervalSeconds; - @Nullable - private HttpResponse lastResponse; - private final AsyncCloseableSupport closeable = AsyncCloseableSupport.of(this::closeAsync); - - public HttpHealthChecker(HealthCheckerContext ctx, Endpoint endpoint, String path, boolean useGet, - SessionProtocol protocol, @Nullable String host) { - this.ctx = ctx; - webClient = WebClient.builder(protocol, endpoint) - .options(ctx.clientOptions()) - .decorator(ResponseTimeoutUpdater::new) - .build(); - authority = host != null ? host : endpoint.authority(); - this.path = path; - this.useGet = useGet; - } - - public void start() { - check(); - } - - private void check() { - lock(); - try { - if (closeable.isClosing()) { - return; - } - - final RequestHeaders headers; - final RequestHeadersBuilder builder = - RequestHeaders.builder(useGet ? HttpMethod.GET : HttpMethod.HEAD, path) - .authority(authority); - if (maxLongPollingSeconds > 0) { - headers = builder.add(HttpHeaderNames.IF_NONE_MATCH, - wasHealthy ? "\"healthy\"" : "\"unhealthy\"") - .add(HttpHeaderNames.PREFER, "wait=" + maxLongPollingSeconds) - .build(); - } else { - headers = builder.build(); - } - - try (ClientRequestContextCaptor reqCtxCaptor = Clients.newContextCaptor()) { - lastResponse = webClient.execute(headers); - final ClientRequestContext reqCtx = reqCtxCaptor.get(); - lastResponse.subscribe(new HealthCheckResponseSubscriber(reqCtx, lastResponse), - reqCtx.eventLoop().withoutContext(), - SubscriptionOption.WITH_POOLED_OBJECTS); - } - } finally { - unlock(); - } - } - - @Override - public CompletableFuture closeAsync() { - return closeable.closeAsync(); - } - - private synchronized void closeAsync(CompletableFuture future) { - lock(); - try { - if (lastResponse == null) { - // Called even before the first request is sent. - future.complete(null); - } else { - lastResponse.abort(); - lastResponse.whenComplete().handle((unused1, unused2) -> future.complete(null)); - } - } finally { - unlock(); - } - } - - @Override - public void close() { - closeable.close(); - } - - private final class ResponseTimeoutUpdater extends SimpleDecoratingHttpClient { - ResponseTimeoutUpdater(HttpClient delegate) { - super(delegate); - } - - @Override - public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { - if (maxLongPollingSeconds > 0) { - ctx.setResponseTimeoutMillis(TimeoutMode.EXTEND, - TimeUnit.SECONDS.toMillis(maxLongPollingSeconds)); - } - return unwrap().execute(ctx, req); - } - } - - private class HealthCheckResponseSubscriber implements Subscriber { - - private final ClientRequestContext reqCtx; - private final HttpResponse res; - @Nullable - private Subscription subscription; - @Nullable - private ResponseHeaders responseHeaders; - private boolean isHealthy; - private boolean receivedExpectedResponse; - private boolean updatedHealth; - - @Nullable - private ScheduledFuture pingCheckFuture; - private long lastPingTimeNanos; - - HealthCheckResponseSubscriber(ClientRequestContext reqCtx, HttpResponse res) { - this.reqCtx = reqCtx; - this.res = res; - } - - @Override - public void onSubscribe(Subscription subscription) { - this.subscription = subscription; - subscription.request(1); - maybeSchedulePingCheck(); - } - - @Override - public void onNext(HttpObject obj) { - assert subscription != null; - - if (closeable.isClosing()) { - subscription.cancel(); - return; - } - - try { - if (!(obj instanceof ResponseHeaders)) { - PooledObjects.close(obj); - return; - } - - final ResponseHeaders headers = (ResponseHeaders) obj; - responseHeaders = headers; - updateLongPollingSettings(headers); - - final HttpStatus status = headers.status(); - final HttpStatusClass statusClass = status.codeClass(); - switch (statusClass) { - case INFORMATIONAL: - maybeSchedulePingCheck(); - break; - case SERVER_ERROR: - receivedExpectedResponse = true; - break; - case SUCCESS: - isHealthy = true; - receivedExpectedResponse = true; - break; - default: - if (status == HttpStatus.NOT_MODIFIED) { - isHealthy = wasHealthy; - receivedExpectedResponse = true; - } else { - // Do not use long polling on an unexpected status for safety. - maxLongPollingSeconds = 0; - - if (statusClass == HttpStatusClass.CLIENT_ERROR) { - logger.warn("{} Unexpected 4xx health check response: {} A 4xx response " + - "generally indicates a misconfiguration of the client. " + - "Did you happen to forget to configure the {}'s client options?", - reqCtx, headers, HealthCheckedEndpointGroup.class.getSimpleName()); - } else { - logger.warn("{} Unexpected health check response: {}", reqCtx, headers); - } - } - } - } finally { - subscription.request(1); - } - } - - @Override - public void onError(Throwable t) { - updateHealth(t); - } - - @Override - public void onComplete() { - updateHealth(null); - } - - private void updateLongPollingSettings(ResponseHeaders headers) { - final String longPollingSettings = headers.get(ARMERIA_LPHC); - if (longPollingSettings == null) { - maxLongPollingSeconds = 0; - pingIntervalSeconds = 0; - return; - } - - final int commaPos = longPollingSettings.indexOf(','); - int maxLongPollingSeconds = 0; - int pingIntervalSeconds = 0; - try { - maxLongPollingSeconds = Integer.max( - 0, Integer.parseInt(longPollingSettings.substring(0, commaPos).trim())); - pingIntervalSeconds = Integer.max( - 0, Integer.parseInt(longPollingSettings.substring(commaPos + 1).trim())); - } catch (Exception e) { - // Ignore malformed settings. - } - - HttpHealthChecker.this.maxLongPollingSeconds = maxLongPollingSeconds; - if (maxLongPollingSeconds > 0 && pingIntervalSeconds < maxLongPollingSeconds) { - HttpHealthChecker.this.pingIntervalSeconds = pingIntervalSeconds; - } else { - HttpHealthChecker.this.pingIntervalSeconds = 0; - } - } - - // TODO(trustin): Remove once https://github.com/line/armeria/issues/1063 is fixed. - private void maybeSchedulePingCheck() { - lastPingTimeNanos = System.nanoTime(); - - if (pingCheckFuture != null) { - return; - } - - final int pingIntervalSeconds = HttpHealthChecker.this.pingIntervalSeconds; - if (pingIntervalSeconds <= 0) { - return; - } - - final long pingTimeoutNanos = TimeUnit.SECONDS.toNanos(pingIntervalSeconds) * 2; - pingCheckFuture = reqCtx.eventLoop().withoutContext().scheduleWithFixedDelay(() -> { - if (System.nanoTime() - lastPingTimeNanos >= pingTimeoutNanos) { - // Did not receive a ping on time. - final ResponseTimeoutException cause = ResponseTimeoutException.get(); - res.abort(cause); - isHealthy = false; - receivedExpectedResponse = false; - updateHealth(cause); - } - }, 1, 1, TimeUnit.SECONDS); - } - - private void updateHealth(@Nullable Throwable cause) { - if (pingCheckFuture != null) { - pingCheckFuture.cancel(false); - } - - if (closeable.isClosing() || updatedHealth) { - return; - } - - updatedHealth = true; - - ctx.updateHealth(isHealthy ? 1 : 0, reqCtx, responseHeaders, cause); - wasHealthy = isHealthy; - - final ScheduledExecutorService executor = ctx.executor(); - try { - // Send a long polling check immediately if: - // - Server has long polling enabled. - // - Server responded with 2xx or 5xx. - if (maxLongPollingSeconds > 0 && receivedExpectedResponse) { - executor.execute(HttpHealthChecker.this::check); - } else { - executor.schedule(HttpHealthChecker.this::check, - ctx.nextDelayMillis(), TimeUnit.MILLISECONDS); - } - } catch (RejectedExecutionException ignored) { - // Can happen if the Endpoint being checked has been disappeared from - // the delegate EndpointGroup. See HealthCheckedEndpointGroupTest.disappearedEndpoint(). - } - } - } - - private void lock() { - lock.lock(); - } - private void unlock() { - lock.unlock(); - } +public interface HttpHealthChecker extends AsyncCloseable { } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java new file mode 100644 index 00000000000..36d2753f3eb --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.xds.client.endpoint; + +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckerContext; +import com.linecorp.armeria.common.util.UnmodifiableFuture; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; + +final class StaticHttpHealthChecker implements HttpHealthChecker { + + public static HttpHealthChecker of(HealthCheckerContext ctx, double healthy) { + return new StaticHttpHealthChecker(ctx, healthy); + } + + private StaticHttpHealthChecker(HealthCheckerContext ctx, double healthy) { + ctx.updateHealth(healthy, null, null, null); + } + + @Override + public CompletableFuture closeAsync() { + return UnmodifiableFuture.completedFuture(null); + } + + @Override + public void close() { + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java index dadc53091dc..ef12afd7b00 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java @@ -30,7 +30,7 @@ import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.DefaultHttpHealthChecker; import io.envoyproxy.envoy.config.cluster.v3.Cluster; import io.envoyproxy.envoy.config.core.v3.HealthCheck.HttpHealthCheck; @@ -57,13 +57,17 @@ final class XdsHealthCheckedEndpointGroupBuilder return ctx -> { final LbEndpoint lbEndpoint = EndpointUtil.lbEndpoint(ctx.originalEndpoint()); final HealthCheckConfig healthCheckConfig = lbEndpoint.getEndpoint().getHealthCheckConfig(); + if (healthCheckConfig.getDisableActiveHealthCheck()) { + // health check is disabled, so assume the endpoint is healthy + return StaticHttpHealthChecker.of(ctx, 1.0); + } final String path = httpHealthCheck.getPath(); final String host = Strings.emptyToNull(httpHealthCheck.getHost()); - final HttpHealthChecker checker = - new HttpHealthChecker(ctx, endpoint(healthCheckConfig, ctx.originalEndpoint()), - path, httpMethod(httpHealthCheck) == HttpMethod.GET, - protocol(cluster), host); + final DefaultHttpHealthChecker checker = + new DefaultHttpHealthChecker(ctx, endpoint(healthCheckConfig, ctx.originalEndpoint()), + path, httpMethod(httpHealthCheck) == HttpMethod.GET, + protocol(cluster), host); checker.start(); return checker; }; diff --git a/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java b/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java index 4c319796d97..c5d395ef3a9 100644 --- a/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java +++ b/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java @@ -52,6 +52,7 @@ import io.envoyproxy.envoy.config.core.v3.TransportSocket; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; import io.envoyproxy.envoy.config.endpoint.v3.Endpoint; +import io.envoyproxy.envoy.config.endpoint.v3.Endpoint.HealthCheckConfig; import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint; import io.envoyproxy.envoy.config.endpoint.v3.LocalityLbEndpoints; import io.envoyproxy.envoy.config.listener.v3.ApiListener; @@ -105,6 +106,11 @@ public static LbEndpoint endpoint(String address, int port, Metadata metadata) { public static LbEndpoint endpoint(String address, int port, Metadata metadata, int weight, HealthStatus healthStatus) { + return endpoint(address, port, metadata, weight, healthStatus, HealthCheckConfig.getDefaultInstance()); + } + + public static LbEndpoint endpoint(String address, int port, Metadata metadata, int weight, + HealthStatus healthStatus, HealthCheckConfig healthCheckConfig) { return LbEndpoint .newBuilder() .setLoadBalancingWeight(UInt32Value.of(weight)) @@ -112,6 +118,7 @@ public static LbEndpoint endpoint(String address, int port, Metadata metadata, i .setHealthStatus(healthStatus) .setEndpoint(Endpoint.newBuilder() .setAddress(address(address, port)) + .setHealthCheckConfig(healthCheckConfig) .build()).build(); } diff --git a/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java b/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java index ece7115fd88..ddae44adc8f 100644 --- a/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java +++ b/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import com.google.common.collect.ImmutableList; @@ -59,8 +60,10 @@ import io.envoyproxy.envoy.config.core.v3.HealthCheck.HttpHealthCheck; import io.envoyproxy.envoy.config.core.v3.HealthStatus; import io.envoyproxy.envoy.config.core.v3.Locality; +import io.envoyproxy.envoy.config.core.v3.Metadata; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment.Policy; +import io.envoyproxy.envoy.config.endpoint.v3.Endpoint.HealthCheckConfig; import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint; import io.envoyproxy.envoy.config.listener.v3.Listener; import io.envoyproxy.envoy.type.v3.Percent; @@ -222,6 +225,68 @@ void panicCase(double panicThreshold, boolean panicMode) { } } + @ParameterizedTest + @EnumSource(value = HealthStatus.class, names = {"UNKNOWN", "UNHEALTHY", "HEALTHY"}) + void disabled(HealthStatus healthStatus) { + final Listener listener = staticResourceListener(); + final HealthCheckConfig disabledConfig = HealthCheckConfig.newBuilder() + .setDisableActiveHealthCheck(true).build(); + + final List healthyEndpoints = + server.server().activePorts().keySet() + .stream().map(addr -> testEndpoint(addr, healthStatus, disabledConfig)) + .collect(Collectors.toList()); + assertThat(healthyEndpoints).hasSize(3); + final List unhealthyEndpoints = + noHealthCheck.server().activePorts().keySet() + .stream().map(addr -> testEndpoint(addr, healthStatus, disabledConfig)) + .collect(Collectors.toList()); + assertThat(unhealthyEndpoints).hasSize(3); + final List allEndpoints = ImmutableList.builder() + .addAll(healthyEndpoints) + .addAll(unhealthyEndpoints).build(); + + final ClusterLoadAssignment loadAssignment = + ClusterLoadAssignment + .newBuilder() + .addEndpoints(localityLbEndpoints(Locality.getDefaultInstance(), allEndpoints)) + .setPolicy(Policy.newBuilder().setWeightedPriorityHealth(true)) + .build(); + final HttpHealthCheck httpHealthCheck = HttpHealthCheck.newBuilder() + .setPath("/monitor/healthcheck") + .build(); + final Cluster cluster = createStaticCluster("cluster", loadAssignment) + .toBuilder() + .addHealthChecks(HealthCheck.newBuilder().setHttpHealthCheck(httpHealthCheck)) + .setCommonLbConfig(CommonLbConfig.newBuilder() + .setHealthyPanicThreshold(Percent.newBuilder().setValue(0))) + .build(); + + final Bootstrap bootstrap = staticBootstrap(listener, cluster); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + EndpointGroup endpointGroup = XdsEndpointGroup.of("listener", xdsBootstrap)) { + await().untilAsserted(() -> assertThat(endpointGroup.whenReady()).isDone()); + + final ClientRequestContext ctx = + ClientRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); + final Endpoint endpoint = endpointGroup.selectNow(ctx); + + // The healthStatus set to the endpoint overrides + if (healthStatus == HealthStatus.HEALTHY || healthStatus == HealthStatus.UNKNOWN) { + assertThat(endpoint).isNotNull(); + } else { + assertThat(healthStatus).isEqualTo(HealthStatus.UNHEALTHY); + assertThat(endpoint).isNull(); + } + } + } + + private static LbEndpoint testEndpoint(InetSocketAddress address, HealthStatus healthStatus, + HealthCheckConfig config) { + return endpoint(address.getAddress().getHostAddress(), address.getPort(), + Metadata.getDefaultInstance(), 1, healthStatus, config); + } + private static List ports(ServerExtension server) { return server.server().activePorts().keySet().stream() .map(InetSocketAddress::getPort).collect(Collectors.toList()); From 0b36df3e8d2550088eabae21620df38c6cf4ab8c Mon Sep 17 00:00:00 2001 From: Kona <140197941+KonaEspresso94@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:18:52 +0900 Subject: [PATCH 09/12] Add Nacos Support (#5409) Motivation: Nacos is a service discovery application extensively used. By providing native support for Nacos, it will be beneficial to Armeria users. #5360 #5365 Modification: - Add a `nacos` module. - Add `NacosClient`. Its internal implementation includes `LoginClient` for obtaining an accessToken through basic Nacos authentication, `QueryInstancesClient` for querying service instances, and `RegisterInstanceClient` for handling the registration and deregistration of instances. - Implement `NacosEndpointGroup` and `NacosUpdatingListener`. - Configure unit tests using Testcontainers and the Nacos image to facilitate actual call testing. Result: Closes #5365 Nacos discovery and instance registration functionalities are now integrated into Armeria. --- In writing this code, I was strongly influenced by the implementation on the consul and eureka modules. I appreciate your review and feedback. Thank you. --------- Co-authored-by: jrhee17 Co-authored-by: Ikhun Um Co-authored-by: minwoox --- nacos/build.gradle.kts | 4 + .../client/nacos/NacosEndpointGroup.java | 133 +++++++++++ .../nacos/NacosEndpointGroupBuilder.java | 138 +++++++++++ .../armeria/client/nacos/package-info.java | 25 ++ .../common/nacos/NacosConfigSetters.java | 64 ++++++ .../armeria/common/nacos/package-info.java | 25 ++ .../armeria/internal/nacos/LoginClient.java | 137 +++++++++++ .../armeria/internal/nacos/NacosClient.java | 101 ++++++++ .../internal/nacos/NacosClientBuilder.java | 117 ++++++++++ .../internal/nacos/NacosClientUtil.java | 82 +++++++ .../internal/nacos/QueryInstancesClient.java | 216 ++++++++++++++++++ .../nacos/RegisterInstanceClient.java | 91 ++++++++ .../armeria/internal/nacos/package-info.java | 23 ++ .../server/nacos/NacosUpdatingListener.java | 144 ++++++++++++ .../nacos/NacosUpdatingListenerBuilder.java | 108 +++++++++ .../armeria/server/nacos/package-info.java | 25 ++ .../nacos/NacosEndpointGroupBuilderTest.java | 45 ++++ .../client/nacos/NacosEndpointGroupTest.java | 193 ++++++++++++++++ .../nacos/NacosClientBuilderTest.java | 47 ++++ .../armeria/internal/nacos/NacosTestBase.java | 151 ++++++++++++ .../nacos/NacosUpdatingListenerTest.java | 128 +++++++++++ settings.gradle | 1 + 22 files changed, 1998 insertions(+) create mode 100644 nacos/build.gradle.kts create mode 100644 nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java create mode 100644 nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java create mode 100644 nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java create mode 100644 nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java create mode 100644 nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java create mode 100644 nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java create mode 100644 nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java diff --git a/nacos/build.gradle.kts b/nacos/build.gradle.kts new file mode 100644 index 00000000000..aae19538681 --- /dev/null +++ b/nacos/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + implementation(libs.caffeine) + testImplementation(libs.testcontainers.junit.jupiter) +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java new file mode 100644 index 00000000000..3901503a2d3 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java @@ -0,0 +1,133 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.client.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.endpoint.DynamicEndpointGroup; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.nacos.NacosClient; + +import io.netty.util.concurrent.EventExecutor; + +/** + * A Nacos-based {@link EndpointGroup} implementation that retrieves the list of {@link Endpoint} from Nacos + * using Nacos's HTTP Open API + * and updates the {@link Endpoint}s periodically. + */ +@UnstableApi +public final class NacosEndpointGroup extends DynamicEndpointGroup { + + private static final Logger logger = LoggerFactory.getLogger(NacosEndpointGroup.class); + + /** + * Returns a {@link NacosEndpointGroup} with the specified {@code serviceName}. + */ + public static NacosEndpointGroup of(URI nacosUri, String serviceName) { + return builder(nacosUri, serviceName).build(); + } + + /** + * Returns a newly-created {@link NacosEndpointGroupBuilder} with the specified {@code nacosUri} + * and {@code serviceName} to build {@link NacosEndpointGroupBuilder}. + * + * @param nacosUri the URI of Nacos API service, including the path up to but not including API version. + * (example: http://localhost:8848/nacos) + */ + public static NacosEndpointGroupBuilder builder(URI nacosUri, String serviceName) { + return new NacosEndpointGroupBuilder(nacosUri, serviceName); + } + + private final NacosClient nacosClient; + + private final long registryFetchIntervalMillis; + + private final EventExecutor eventLoop; + + @Nullable + private ScheduledFuture scheduledFuture; + + NacosEndpointGroup(EndpointSelectionStrategy selectionStrategy, boolean allowEmptyEndpoints, + long selectionTimeoutMillis, NacosClient nacosClient, + long registryFetchIntervalMillis) { + super(selectionStrategy, allowEmptyEndpoints, selectionTimeoutMillis); + this.nacosClient = requireNonNull(nacosClient, "nacosClient"); + this.registryFetchIntervalMillis = registryFetchIntervalMillis; + eventLoop = CommonPools.workerGroup().next(); + + update(); + } + + private void update() { + if (isClosing()) { + return; + } + + nacosClient.endpoints() + .handleAsync((endpoints, cause) -> { + if (isClosing()) { + return null; + } + + if (cause != null) { + logger.warn("Unexpected exception while fetching the registry from: {}", + nacosClient.uri(), cause); + } else { + setEndpoints(endpoints); + } + + scheduledFuture = eventLoop.schedule(this::update, registryFetchIntervalMillis, + TimeUnit.MILLISECONDS); + return null; + }, eventLoop); + } + + @Override + protected void doCloseAsync(CompletableFuture future) { + if (!eventLoop.inEventLoop()) { + eventLoop.execute(() -> doCloseAsync(future)); + return; + } + + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + } + + future.complete(null); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("registryFetchIntervalMillis", registryFetchIntervalMillis) + .toString(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java new file mode 100644 index 00000000000..7f94536dd33 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java @@ -0,0 +1,138 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.client.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.time.Duration; + +import com.linecorp.armeria.client.endpoint.AbstractDynamicEndpointGroupBuilder; +import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy; +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; + +/** + * A builder class for {@link NacosEndpointGroup}. + *

Examples

+ *
{@code
+ * NacosEndpointGroup endpointGroup = NacosEndpointGroup.builder(nacosUri, "myService")
+ *                                                      .build();
+ * WebClient client = WebClient.of(SessionProtocol.HTTPS, endpointGroup);
+ * }
+ */ +@UnstableApi +public final class NacosEndpointGroupBuilder + extends AbstractDynamicEndpointGroupBuilder + implements NacosConfigSetters { + + private static final long DEFAULT_CHECK_INTERVAL_MILLIS = 10_000; + + private final NacosClientBuilder nacosClientBuilder; + private EndpointSelectionStrategy selectionStrategy = EndpointSelectionStrategy.weightedRoundRobin(); + private long registryFetchIntervalMillis = DEFAULT_CHECK_INTERVAL_MILLIS; + + NacosEndpointGroupBuilder(URI nacosUri, String serviceName) { + super(Flags.defaultResponseTimeoutMillis()); + nacosClientBuilder = NacosClient.builder(nacosUri, requireNonNull(serviceName, "serviceName")); + } + + /** + * Sets the {@link EndpointSelectionStrategy} of the {@link NacosEndpointGroup}. + */ + public NacosEndpointGroupBuilder selectionStrategy(EndpointSelectionStrategy selectionStrategy) { + this.selectionStrategy = requireNonNull(selectionStrategy, "selectionStrategy"); + return this; + } + + @Override + public NacosEndpointGroupBuilder namespaceId(String namespaceId) { + nacosClientBuilder.namespaceId(namespaceId); + return this; + } + + @Override + public NacosEndpointGroupBuilder groupName(String groupName) { + nacosClientBuilder.groupName(groupName); + return this; + } + + @Override + public NacosEndpointGroupBuilder clusterName(String clusterName) { + nacosClientBuilder.clusterName(clusterName); + return this; + } + + @Override + public NacosEndpointGroupBuilder app(String app) { + nacosClientBuilder.app(app); + return this; + } + + @Override + public NacosEndpointGroupBuilder nacosApiVersion(String nacosApiVersion) { + nacosClientBuilder.nacosApiVersion(nacosApiVersion); + return this; + } + + @Override + public NacosEndpointGroupBuilder authorization(String username, String password) { + nacosClientBuilder.authorization(username, password); + return this; + } + + /** + * Sets the healthy to retrieve only healthy instances from Nacos. + * Make sure that your target endpoints are health-checked by Nacos before enabling this feature. + * If not set, false is used by default. + */ + public NacosEndpointGroupBuilder useHealthyEndpoints(boolean useHealthyEndpoints) { + nacosClientBuilder.healthyOnly(useHealthyEndpoints); + return this; + } + + /** + * Sets the interval between fetching registry requests. + * If not set, {@value #DEFAULT_CHECK_INTERVAL_MILLIS} milliseconds is used by default. + */ + public NacosEndpointGroupBuilder registryFetchInterval(Duration registryFetchInterval) { + requireNonNull(registryFetchInterval, "registryFetchInterval"); + return registryFetchIntervalMillis(registryFetchInterval.toMillis()); + } + + /** + * Sets the interval between fetching registry requests. + * If not set, {@value #DEFAULT_CHECK_INTERVAL_MILLIS} milliseconds is used by default. + */ + public NacosEndpointGroupBuilder registryFetchIntervalMillis(long registryFetchIntervalMillis) { + checkArgument(registryFetchIntervalMillis > 0, "registryFetchIntervalMillis: %s (expected: > 0)", + registryFetchIntervalMillis); + this.registryFetchIntervalMillis = registryFetchIntervalMillis; + return this; + } + + /** + * Returns a newly-created {@link NacosEndpointGroup}. + */ + public NacosEndpointGroup build() { + return new NacosEndpointGroup(selectionStrategy, shouldAllowEmptyEndpoints(), selectionTimeoutMillis(), + nacosClientBuilder.build(), registryFetchIntervalMillis); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java new file mode 100644 index 00000000000..da9a43a38f7 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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. + */ + +/** + * Nacos-based {@link com.linecorp.armeria.client.endpoint.EndpointGroup} implementation. + */ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.client.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java b/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java new file mode 100644 index 00000000000..60650fd98d7 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.common.nacos; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; + +/** + * Sets properties for building a Nacos client. + */ +@UnstableApi +public interface NacosConfigSetters> { + + /** + * Sets the namespace ID to query or register instances. + */ + SELF namespaceId(String namespaceId); + + /** + * Sets the group name to query or register instances. + */ + SELF groupName(String groupName); + + /** + * Sets the cluster name to query or register instances. + */ + SELF clusterName(String clusterName); + + /** + * Sets the app name to query or register instances. + */ + SELF app(String app); + + /** + * Sets the specified Nacos's API version. + * @param nacosApiVersion the version of Nacos API service, default: {@value + * NacosClientBuilder#DEFAULT_NACOS_API_VERSION} + */ + SELF nacosApiVersion(String nacosApiVersion); + + /** + * Sets the username and password pair for Nacos's API. + * Please refer to the + * Nacos Authentication Document + * for more details. + * + * @param username the username for access Nacos API, default: {@code null} + * @param password the password for access Nacos API, default: {@code null} + */ + SELF authorization(String username, String password); +} diff --git a/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java new file mode 100644 index 00000000000..28027bc31fd --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.common.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java new file mode 100644 index 00000000000..af6fd80b408 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.SimpleDecoratingHttpClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpEntity; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.auth.AuthToken; +import com.linecorp.armeria.common.util.AsyncLoader; +import com.linecorp.armeria.common.util.Exceptions; + +/** + * A Nacos client that is responsible for + * Nacos Authentication. + */ +final class LoginClient extends SimpleDecoratingHttpClient { + + static Function newDecorator(WebClient webClient, + String username, String password) { + return delegate -> new LoginClient(delegate, webClient, username, password); + } + + private final WebClient webClient; + private final String queryParamsForLogin; + + private final AsyncLoader tokenLoader = + AsyncLoader.builder(cache -> loginInternal()) + .expireIf(LoginResult::isExpired) + .build(); + + LoginClient(HttpClient delegate, WebClient webClient, String username, String password) { + super(delegate); + this.webClient = requireNonNull(webClient, "webClient"); + queryParamsForLogin = QueryParams.builder() + .add("username", requireNonNull(username, "username")) + .add("password", requireNonNull(password, "password")) + .toQueryString(); + } + + private CompletableFuture login() { + return tokenLoader.load().thenApply(loginResult -> loginResult.accessToken); + } + + private CompletableFuture loginInternal() { + return webClient.prepare().post("/v1/auth/login") + .content(MediaType.FORM_DATA, queryParamsForLogin) + .asJson(LoginResult.class) + .as(HttpEntity::content) + .execute(); + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) { + final CompletableFuture future = login().thenApply(accessToken -> { + try { + final HttpRequest newReq = req.mapHeaders(headers -> { + return headers.toBuilder() + .set(HttpHeaderNames.AUTHORIZATION, accessToken.asHeaderValue()) + .build(); + }); + ctx.updateRequest(newReq); + return unwrap().execute(ctx, newReq); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + }); + + return HttpResponse.of(future); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class LoginResult { + private final AuthToken accessToken; + + private final long createdAtNanos; + private final long tokenTtlNanos; + + @Nullable + private final Boolean globalAdmin; + + @JsonCreator + LoginResult(@JsonProperty("accessToken") String accessToken, @JsonProperty("tokenTtl") int tokenTtl, + @JsonProperty("globalAdmin") @Nullable Boolean globalAdmin) { + this.accessToken = AuthToken.ofOAuth2(accessToken); + createdAtNanos = System.nanoTime(); + tokenTtlNanos = TimeUnit.SECONDS.toNanos(tokenTtl); + this.globalAdmin = globalAdmin; + } + + boolean isExpired() { + final long elapsedNanos = System.nanoTime() - createdAtNanos; + return elapsedNanos >= tokenTtlNanos; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("accessToken", accessToken) + .add("tokenTtl", TimeUnit.NANOSECONDS.toSeconds(tokenTtlNanos)) + .add("globalAdmin", globalAdmin) + .toString(); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java new file mode 100644 index 00000000000..4f004b34977 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.retry.RetryConfig; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.client.retry.RetryingClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.annotation.Nullable; + +public final class NacosClient { + + private static final Function retryingClientDecorator = + RetryingClient.newDecorator(RetryConfig.builder(RetryRule.onServerErrorStatus()) + .maxTotalAttempts(3) + .build()); + + public static NacosClientBuilder builder(URI nacosUri, String serviceName) { + return new NacosClientBuilder(nacosUri, serviceName); + } + + private final URI uri; + + private final QueryInstancesClient queryInstancesClient; + + private final RegisterInstanceClient registerInstanceClient; + + NacosClient(URI uri, String nacosApiVersion, @Nullable String username, @Nullable String password, + String serviceName, @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, @Nullable String app) { + this.uri = uri; + + final WebClientBuilder builder = WebClient.builder(uri) + .decorator(retryingClientDecorator); + if (username != null && password != null) { + builder.decorator(LoginClient.newDecorator(builder.build(), username, password)); + } + + final WebClient webClient = builder.build(); + + queryInstancesClient = QueryInstancesClient.of(webClient, nacosApiVersion, serviceName, namespaceId, + groupName, clusterName, healthyOnly, app); + registerInstanceClient = RegisterInstanceClient.of(webClient, nacosApiVersion, serviceName, namespaceId, + groupName, clusterName, app); + } + + public CompletableFuture> endpoints() { + return queryInstancesClient.endpoints(); + } + + /** + * Registers an instance to Nacos with service name. + * + * @return a {@link HttpResponse} indicating the result of the registration operation. + */ + public HttpResponse register(Endpoint endpoint) { + requireNonNull(endpoint, "endpoint"); + return registerInstanceClient.register(endpoint.host(), endpoint.port(), endpoint.weight()); + } + + /** + * De-registers an instance to Nacos with service name. + * + * @return a {@link HttpResponse} indicating the result of the de-registration operation. + */ + public HttpResponse deregister(Endpoint endpoint) { + requireNonNull(endpoint, "endpoint"); + return registerInstanceClient.deregister(endpoint.host(), endpoint.port(), endpoint.weight()); + } + + /** + * Returns the {@link URI} of Nacos uri. + */ + public URI uri() { + return uri; + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java new file mode 100644 index 00000000000..fba015b632d --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.regex.Pattern; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; + +public final class NacosClientBuilder implements NacosConfigSetters { + + public static final String DEFAULT_NACOS_API_VERSION = "v2"; + private static final Pattern NACOS_API_VERSION_PATTERN = Pattern.compile("^v[0-9][-._a-zA-Z0-9]*$"); + + private final URI nacosUri; + private final String serviceName; + + private String nacosApiVersion = DEFAULT_NACOS_API_VERSION; + + @Nullable + private String username; + + @Nullable + private String password; + + @Nullable + private String namespaceId; + + @Nullable + private String groupName; + + @Nullable + private String clusterName; + + @Nullable + private Boolean healthyOnly; + + @Nullable + private String app; + + NacosClientBuilder(URI nacosUri, String serviceName) { + this.nacosUri = requireNonNull(nacosUri, "nacosUri"); + this.serviceName = requireNonNull(serviceName, "serviceName"); + } + + @Override + public NacosClientBuilder namespaceId(String namespaceId) { + this.namespaceId = requireNonNull(namespaceId, "namespaceId"); + return this; + } + + @Override + public NacosClientBuilder groupName(String groupName) { + this.groupName = requireNonNull(groupName, "groupName"); + return this; + } + + @Override + public NacosClientBuilder clusterName(String clusterName) { + this.clusterName = requireNonNull(clusterName, "clusterName"); + return this; + } + + @Override + public NacosClientBuilder app(String app) { + this.app = requireNonNull(app, "app"); + return this; + } + + @Override + public NacosClientBuilder nacosApiVersion(String nacosApiVersion) { + requireNonNull(nacosApiVersion, "nacosApiVersion"); + checkArgument(NACOS_API_VERSION_PATTERN.matcher(nacosApiVersion).matches(), + "nacosApiVersion: %s (expected: a version string that starts with 'v', e.g. 'v1')", + nacosApiVersion); + this.nacosApiVersion = nacosApiVersion; + return this; + } + + @Override + public NacosClientBuilder authorization(String username, String password) { + requireNonNull(username, "username"); + requireNonNull(password, "password"); + + this.username = username; + this.password = password; + + return this; + } + + public NacosClientBuilder healthyOnly(boolean healthyOnly) { + this.healthyOnly = healthyOnly; + return this; + } + + public NacosClient build() { + return new NacosClient(nacosUri, nacosApiVersion, username, password, serviceName, namespaceId, + groupName, clusterName, healthyOnly, app); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java new file mode 100644 index 00000000000..0039f399aaf --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.QueryParamsBuilder; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * Utility methods related to Nacos clients. + */ +final class NacosClientUtil { + + private static final String NAMESPACE_ID_PARAM = "namespaceId"; + + private static final String GROUP_NAME_PARAM = "groupName"; + + private static final String SERVICE_NAME_PARAM = "serviceName"; + + private static final String CLUSTER_NAME_PARAM = "clusterName"; + + private static final String HEALTHY_ONLY_PARAM = "healthyOnly"; + + private static final String APP_PARAM = "app"; + + private static final String IP_PARAM = "ip"; + + private static final String PORT_PARAM = "port"; + + private static final String WEIGHT_PARAM = "weight"; + + private NacosClientUtil() {} + + /** + * Encodes common Nacos API parameters as {@code QueryParamsBuilder}. + */ + static QueryParams queryParams(String serviceName, @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, + @Nullable String app, @Nullable String ip, @Nullable Integer port, + @Nullable Integer weight) { + final QueryParamsBuilder paramsBuilder = QueryParams.builder(); + paramsBuilder.add(SERVICE_NAME_PARAM, serviceName); + if (namespaceId != null) { + paramsBuilder.add(NAMESPACE_ID_PARAM, namespaceId); + } + if (groupName != null) { + paramsBuilder.add(GROUP_NAME_PARAM, groupName); + } + if (clusterName != null) { + paramsBuilder.add(CLUSTER_NAME_PARAM, clusterName); + } + if (healthyOnly != null) { + paramsBuilder.add(HEALTHY_ONLY_PARAM, healthyOnly.toString()); + } + if (app != null) { + paramsBuilder.add(APP_PARAM, app); + } + if (ip != null) { + paramsBuilder.add(IP_PARAM, ip); + } + if (port != null) { + paramsBuilder.add(PORT_PARAM, port.toString()); + } + if (weight != null) { + paramsBuilder.add(WEIGHT_PARAM, weight.toString()); + } + return paramsBuilder.build(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java new file mode 100644 index 00000000000..0176925a101 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java @@ -0,0 +1,216 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpEntity; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * A Nacos client that is responsible for + * Nacos Open-Api - Query instances. + */ +final class QueryInstancesClient { + + private final WebClient webClient; + private final String pathForQuery; + + QueryInstancesClient(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, @Nullable String app) { + this.webClient = webClient; + + final StringBuilder pathBuilder = new StringBuilder("/") + .append(nacosApiVersion) + .append("/ns/instance/list?"); + final QueryParams params = NacosClientUtil + .queryParams(requireNonNull(serviceName, "serviceName"), namespaceId, groupName, + clusterName, healthyOnly, app, null, null, null); + pathBuilder.append(params.toQueryString()); + + pathForQuery = pathBuilder.toString(); + } + + static QueryInstancesClient of(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, + @Nullable String app) { + return new QueryInstancesClient(webClient, nacosApiVersion, serviceName, namespaceId, groupName, + clusterName, healthyOnly, app); + } + + @Nullable + private static Endpoint toEndpoint(Host host) { + if (Boolean.FALSE.equals(host.enabled)) { + return null; + } else if (host.weight != null && host.weight.intValue() >= 0) { + return Endpoint.of(host.ip, host.port).withWeight(host.weight.intValue()); + } else { + return Endpoint.of(host.ip, host.port); + } + } + + CompletableFuture> endpoints() { + return queryInstances() + .thenApply(response -> { + requireNonNull(response.data, "Response data cannot be null"); + requireNonNull(response.data.hosts, "Response data.hosts cannot be null"); + return response.data.hosts.stream() + .map(QueryInstancesClient::toEndpoint) + .filter(Objects::nonNull) + .collect(toImmutableList()); + }); + } + + CompletableFuture queryInstances() { + return webClient.prepare() + .get(pathForQuery) + .asJson(QueryInstancesResponse.class) + .as(HttpEntity::content) + .execute(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class QueryInstancesResponse { + @Nullable + private final Data data; + + @JsonCreator + QueryInstancesResponse(@JsonProperty("data") Data data) { + this.data = data; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Data { + @Nullable + private final List hosts; + + @JsonCreator + Data(@JsonProperty("hosts") List hosts) { + this.hosts = hosts; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Host { + @Nullable + private final String instanceId; + + private final String ip; + + private final Integer port; + + @Nullable + private final Double weight; + + @Nullable + private final Boolean healthy; + + @Nullable + private final Boolean enabled; + + @Nullable + private final Boolean ephemeral; + + @Nullable + private final String clusterName; + + @Nullable + private final String serviceName; + + @Nullable + private final Map metadata; + + @Nullable + private final Integer instanceHeartBeatInterval; + + @Nullable + private final String instanceIdGenerator; + + @Nullable + private final Integer instanceHeartBeatTimeOut; + + @Nullable + private final Integer ipDeleteTimeout; + + @JsonCreator + Host(@JsonProperty("instanceId") @Nullable String instanceId, @JsonProperty("ip") String ip, + @JsonProperty("port") Integer port, @JsonProperty("weight") @Nullable Double weight, + @JsonProperty("healthy") @Nullable Boolean healthy, + @JsonProperty("enabled") @Nullable Boolean enabled, + @JsonProperty("ephemeral") @Nullable Boolean ephemeral, + @JsonProperty("clusterName") @Nullable String clusterName, + @JsonProperty("serviceName") @Nullable String serviceName, + @JsonProperty("metadata") @Nullable Map metadata, + @JsonProperty("instanceHeartBeatInterval") @Nullable Integer instanceHeartBeatInterval, + @JsonProperty("instanceIdGenerator") @Nullable String instanceIdGenerator, + @JsonProperty("instanceHeartBeatTimeOut") @Nullable Integer instanceHeartBeatTimeOut, + @JsonProperty("ipDeleteTimeout") @Nullable Integer ipDeleteTimeout + ) { + this.instanceId = instanceId; + this.ip = ip; + this.port = port; + this.weight = weight; + this.healthy = healthy; + this.enabled = enabled; + this.ephemeral = ephemeral; + this.clusterName = clusterName; + this.serviceName = serviceName; + this.metadata = metadata; + this.instanceHeartBeatInterval = instanceHeartBeatInterval; + this.instanceIdGenerator = instanceIdGenerator; + this.instanceHeartBeatTimeOut = instanceHeartBeatTimeOut; + this.ipDeleteTimeout = ipDeleteTimeout; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("instanceId", instanceId) + .add("ip", ip) + .add("port", port) + .add("weight", weight) + .add("healthy", healthy) + .add("enabled", enabled) + .add("ephemeral", ephemeral) + .add("clusterName", clusterName) + .add("serviceName", serviceName) + .add("metaData", metadata) + .add("instanceHeartBeatInterval", instanceHeartBeatInterval) + .add("instanceIdGenerator", instanceIdGenerator) + .add("instanceHeartBeatTimeOut", instanceHeartBeatTimeOut) + .add("ipDeleteTimeout", ipDeleteTimeout) + .toString(); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java new file mode 100644 index 00000000000..49d76449fd5 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java @@ -0,0 +1,91 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * A Nacos client that is responsible for + * Nacos Open-Api - Register instance. + */ +final class RegisterInstanceClient { + + private final WebClient webClient; + private final String instanceApiPath; + private final String serviceName; + + @Nullable + private final String namespaceId; + + @Nullable + private final String groupName; + + @Nullable + private final String clusterName; + + @Nullable + private final String app; + + RegisterInstanceClient(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable String app) { + this.webClient = webClient; + instanceApiPath = new StringBuilder("/").append(nacosApiVersion).append("/ns/instance").toString(); + + this.serviceName = requireNonNull(serviceName, "serviceName"); + this.namespaceId = namespaceId; + this.groupName = groupName; + this.clusterName = clusterName; + this.app = app; + } + + static RegisterInstanceClient of(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable String app) { + return new RegisterInstanceClient(webClient, nacosApiVersion, serviceName, namespaceId, groupName, + clusterName, app); + } + + /** + * Registers a service into the Nacos. + */ + HttpResponse register(String ip, int port, int weight) { + final QueryParams params = NacosClientUtil.queryParams(serviceName, namespaceId, groupName, clusterName, + null, app, requireNonNull(ip, "ip"), port, + weight); + + return webClient.prepare().post(instanceApiPath).content(MediaType.FORM_DATA, params.toQueryString()) + .execute(); + } + + /** + * De-registers a service from the Nacos. + */ + HttpResponse deregister(String ip, int port, int weight) { + final QueryParams params = NacosClientUtil.queryParams(serviceName, namespaceId, groupName, clusterName, + null, app, requireNonNull(ip, "ip"), port, + weight); + + return webClient.prepare().delete(instanceApiPath).content(MediaType.FORM_DATA, params.toQueryString()) + .execute(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java new file mode 100644 index 00000000000..600661d3d7e --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +package com.linecorp.armeria.internal.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java new file mode 100644 index 00000000000..fef61696c9f --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java @@ -0,0 +1,144 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.server.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.Inet4Address; +import java.net.URI; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SystemInfo; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.server.ServerListenerAdapter; +import com.linecorp.armeria.server.ServerPort; + +/** + * A {@link ServerListener} which registers the current {@link Server} to + * Nacos. + */ +@UnstableApi +public final class NacosUpdatingListener extends ServerListenerAdapter { + + private static final Logger logger = LoggerFactory.getLogger(NacosUpdatingListener.class); + + /** + * Returns a newly-created {@link NacosUpdatingListenerBuilder} with the specified {@code nacosUri} + * and {@code serviceName} to build {@link NacosUpdatingListener}. + * + * @param nacosUri the URI of Nacos API service, including the path up to but not including API version. + * (example: http://localhost:8848/nacos) + */ + public static NacosUpdatingListenerBuilder builder(URI nacosUri, String serviceName) { + return new NacosUpdatingListenerBuilder(nacosUri, serviceName); + } + + private final NacosClient nacosClient; + + @Nullable + private final Endpoint endpoint; + + private volatile boolean isRegistered; + + NacosUpdatingListener(NacosClient nacosClient, @Nullable Endpoint endpoint) { + this.nacosClient = requireNonNull(nacosClient, "nacosClient"); + this.endpoint = endpoint; + } + + private static Endpoint defaultEndpoint(Server server) { + final ServerPort serverPort = server.activePort(); + assert serverPort != null; + + final Inet4Address inet4Address = SystemInfo.defaultNonLoopbackIpV4Address(); + final String host = inet4Address != null ? inet4Address.getHostAddress() : server.defaultHostname(); + return Endpoint.of(host, serverPort.localAddress().getPort()); + } + + private static void warnIfInactivePort(Server server, int port) { + for (ServerPort serverPort : server.activePorts().values()) { + if (serverPort.localAddress().getPort() == port) { + return; + } + } + logger.warn("The specified port number {} does not exist. (expected one of activePorts: {})", + port, server.activePorts()); + } + + @Override + public void serverStarted(Server server) { + final Endpoint endpoint = getEndpoint(server); + nacosClient.register(endpoint) + .aggregate() + .handle((res, cause) -> { + if (cause != null) { + logger.warn("Failed to register {}:{} to Nacos: {}", + endpoint.host(), endpoint.port(), nacosClient.uri(), cause); + return null; + } + + if (res.status() != HttpStatus.OK) { + logger.warn("Failed to register {}:{} to Nacos: {} (status: {}, content: {})", + endpoint.host(), endpoint.port(), nacosClient.uri(), res.status(), + res.contentUtf8()); + return null; + } + + logger.info("Registered {}:{} to Nacos: {}", + endpoint.host(), endpoint.port(), nacosClient.uri()); + isRegistered = true; + return null; + }); + } + + private Endpoint getEndpoint(Server server) { + if (endpoint != null) { + if (endpoint.hasPort()) { + warnIfInactivePort(server, endpoint.port()); + } + return endpoint; + } + return defaultEndpoint(server); + } + + @Override + public void serverStopping(Server server) { + final Endpoint endpoint = getEndpoint(server); + if (isRegistered) { + nacosClient.deregister(endpoint) + .aggregate() + .handle((res, cause) -> { + if (cause != null) { + logger.warn("Failed to deregister {}:{} from Nacos: {}", + endpoint.ipAddr(), endpoint.port(), nacosClient.uri(), cause); + } else if (res.status() != HttpStatus.OK) { + logger.warn( + "Failed to deregister {}:{} from Nacos: {}. (status: {}, content: {})", + endpoint.ipAddr(), endpoint.port(), nacosClient.uri(), res.status(), + res.contentUtf8()); + } + return null; + }); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java new file mode 100644 index 00000000000..c378ec36736 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.server.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; +import com.linecorp.armeria.server.Server; + +/** + * Builds a new {@link NacosUpdatingListener}, which registers the server to Nacos. + *

Examples

+ *
{@code
+ * NacosUpdatingListener listener = NacosUpdatingListener.builder(nacosUri, "myService")
+ *                                                       .build();
+ * ServerBuilder sb = Server.builder();
+ * sb.serverListener(listener);
+ * }
+ */ +@UnstableApi +public final class NacosUpdatingListenerBuilder implements NacosConfigSetters { + + private final NacosClientBuilder nacosClientBuilder; + @Nullable + private Endpoint endpoint; + + /** + * Creates a {@link NacosUpdatingListenerBuilder} with a service name. + */ + NacosUpdatingListenerBuilder(URI nacosUri, String serviceName) { + requireNonNull(serviceName, "serviceName"); + checkArgument(!serviceName.isEmpty(), "serviceName can't be empty"); + nacosClientBuilder = NacosClient.builder(nacosUri, serviceName); + } + + /** + * Sets the {@link Endpoint} to register. If not set, the current host name is used by default. + */ + public NacosUpdatingListenerBuilder endpoint(Endpoint endpoint) { + this.endpoint = requireNonNull(endpoint, "endpoint"); + return this; + } + + @Override + public NacosUpdatingListenerBuilder namespaceId(String namespaceId) { + nacosClientBuilder.namespaceId(namespaceId); + return this; + } + + @Override + public NacosUpdatingListenerBuilder groupName(String groupName) { + nacosClientBuilder.groupName(groupName); + return this; + } + + @Override + public NacosUpdatingListenerBuilder clusterName(String clusterName) { + nacosClientBuilder.clusterName(clusterName); + return this; + } + + @Override + public NacosUpdatingListenerBuilder app(String app) { + nacosClientBuilder.app(app); + return this; + } + + @Override + public NacosUpdatingListenerBuilder nacosApiVersion(String nacosApiVersion) { + nacosClientBuilder.nacosApiVersion(nacosApiVersion); + return this; + } + + @Override + public NacosUpdatingListenerBuilder authorization(String username, String password) { + nacosClientBuilder.authorization(username, password); + return this; + } + + /** + * Returns a newly-created {@link NacosUpdatingListener} that registers the {@link Server} to + * Nacos when the {@link Server} starts. + */ + public NacosUpdatingListener build() { + return new NacosUpdatingListener(nacosClientBuilder.build(), endpoint); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java new file mode 100644 index 00000000000..0651abf2f9c --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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. + */ + +/** + * Automatic service registration and discovery with Nacos. + **/ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.server.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java new file mode 100644 index 00000000000..8dafb9f56c6 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.client.nacos; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.Flags; + +class NacosEndpointGroupBuilderTest { + + @Test + void selectionTimeoutDefault() { + try (NacosEndpointGroup group = NacosEndpointGroup.of(URI.create("http://127.0.0.1/node"), + "testService")) { + assertThat(group.selectionTimeoutMillis()).isEqualTo(Flags.defaultResponseTimeoutMillis()); + } + } + + @Test + void selectionTimeoutCustom() { + try (NacosEndpointGroup group = + NacosEndpointGroup.builder(URI.create("http://127.0.0.1/node"), "testService") + .selectionTimeoutMillis(4000) + .build()) { + assertThat(group.selectionTimeoutMillis()).isEqualTo(4000); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java new file mode 100644 index 00000000000..570dd0950ae --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java @@ -0,0 +1,193 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.client.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.internal.nacos.NacosTestBase; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.server.nacos.NacosUpdatingListener; + +@GenerateNativeImageTrace +class NacosEndpointGroupTest extends NacosTestBase { + + private static final List servers = new ArrayList<>(); + private static final String DEFAULT_CLUSTER_NAME = "c1"; + private static volatile List sampleEndpoints; + + @BeforeAll + static void startServers() { + await().pollInSameThread() + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThatCode(() -> { + final List endpoints = newSampleEndpoints(); + servers.clear(); + for (Endpoint endpoint : endpoints) { + final Server server = Server.builder() + .http(endpoint.port()) + .service("/", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener + .builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .clusterName(DEFAULT_CLUSTER_NAME) + .build(); + server.addListener(listener); + server.start().join(); + servers.add(server); + } + sampleEndpoints = endpoints; + }).doesNotThrowAnyException()); + } + + @AfterAll + static void stopServers() { + servers.forEach(Server::close); + servers.clear(); + } + + @Test + void testNacosEndpointGroupWithClient() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + + // stop a server + servers.get(0).stop().join(); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()) + .hasSize(sampleEndpoints.size() - 1)); + + // restart the server + await().pollInSameThread().pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> { + // The port bound to the server could be stolen while stopping the server. + assertThatCode(servers.get(0).start()::join).doesNotThrowAnyException(); + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + } + + @Test + void testNacosEndpointGroupWithUrl() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + + // stop a server + servers.get(0).stop().join(); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()) + .hasSize(sampleEndpoints.size() - 1)); + + // restart the server + await().pollInSameThread().pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> { + // The port bound to the server could be stolen while stopping the server. + assertThatCode(servers.get(0).start()::join).doesNotThrowAnyException(); + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + } + + @Test + void testSelectStrategy() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.selectNow(null)) + .isNotEqualTo(endpointGroup.selectNow(null))); + } + } + + @Test + void testNacosEndpointGroupWithClusterName() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + // default cluster name + try (NacosEndpointGroup endpointGroup = builder.clusterName(DEFAULT_CLUSTER_NAME).build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + // non-existent cluster name + try (NacosEndpointGroup endpointGroup = builder.clusterName("c2").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } + + @Test + void testNacosEndpointGroupWithNamespaceId() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + // default namespace id + try (NacosEndpointGroup endpointGroup = builder.namespaceId("public").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + // non-existent namespace id + try (NacosEndpointGroup endpointGroup = builder.namespaceId("private").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } + + @Test + void testNacosEndpointGroupWithGroupName() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + try (NacosEndpointGroup endpointGroup = builder.build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + try (NacosEndpointGroup endpointGroup = builder.groupName("not-default-group").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java new file mode 100644 index 00000000000..0d14213ad41 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpStatus; + +class NacosClientBuilderTest extends NacosTestBase { + + @Test + void gets403WhenNoToken() throws Exception { + final HttpStatus status = WebClient.of(nacosUri()) + .blocking() + .get("/nacos/v1/ns/service/list?pageNo=0&pageSize=10") + .status(); + assertThat(status).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void nacosApiVersionCanNotStartsWithSlash() { + assertThrows(IllegalArgumentException.class, () -> + NacosClient.builder(URI.create("http://localhost:8500"), serviceName).nacosApiVersion("/v1")); + assertDoesNotThrow(() -> NacosClient.builder(URI.create("http://localhost:8500"), serviceName) + .nacosApiVersion("v1")); + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java new file mode 100644 index 00000000000..aec63d2e389 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.nacos; + +import static com.google.common.base.Preconditions.checkState; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import org.junit.jupiter.api.BeforeAll; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.CompletionActions; +import com.linecorp.armeria.internal.testing.FlakyTest; +import com.linecorp.armeria.server.AbstractHttpService; +import com.linecorp.armeria.server.ServiceRequestContext; + +/** + * A helper class for testing with Nacos. + */ +@FlakyTest +@Testcontainers(disabledWithoutDocker = true) +public abstract class NacosTestBase { + + protected static final String serviceName = "testService"; + protected static final String NACOS_AUTH_TOKEN = "armeriaarmeriaarmeriaarmeriaarmeriaarmeriaarmeriaarmeria"; + protected static final String NACOS_AUTH_SECRET = "nacos"; + @Container + static final GenericContainer nacosContainer = + new GenericContainer(DockerImageName.parse("nacos/nacos-server:v2.3.0-slim")) + .withExposedPorts(8848) + .withEnv("MODE", "standalone") + .withEnv("NACOS_AUTH_ENABLE", "true") + .withEnv("NACOS_AUTH_TOKEN", NACOS_AUTH_TOKEN) + .withEnv("NACOS_AUTH_IDENTITY_KEY", NACOS_AUTH_SECRET) + .withEnv("NACOS_AUTH_IDENTITY_VALUE", NACOS_AUTH_SECRET); + @Nullable + private static URI nacosUri; + + protected NacosTestBase() {} + + protected static List newSampleEndpoints() { + final int[] ports = unusedPorts(3); + return ImmutableList.of(Endpoint.of("host.docker.internal", ports[0]).withWeight(2), + Endpoint.of("host.docker.internal", ports[1]).withWeight(4), + Endpoint.of("host.docker.internal", ports[2]).withWeight(2)); + } + + @BeforeAll + static void start() { + // Initialize Nacos Client + nacosUri = URI.create( + "http://" + nacosContainer.getHost() + ':' + nacosContainer.getMappedPort(8848)); + } + + protected static NacosClient client(@Nullable String serviceName, @Nullable String groupName) { + final NacosClientBuilder builder; + if (serviceName != null) { + builder = NacosClient.builder(nacosUri, serviceName); + } else { + builder = NacosClient.builder(nacosUri, NacosTestBase.serviceName); + } + + if (groupName != null) { + builder.groupName(groupName); + } + return builder.authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .build(); + } + + protected static URI nacosUri() { + checkState(nacosUri != null, "nacosUri has not initialized."); + return nacosUri; + } + + protected static int[] unusedPorts(int numPorts) { + final int[] ports = new int[numPorts]; + final Random random = ThreadLocalRandom.current(); + for (int i = 0; i < numPorts; i++) { + for (;;) { + final int candidatePort = random.nextInt(64512) + 1024; + try (ServerSocket ss = new ServerSocket()) { + ss.bind(new InetSocketAddress("127.0.0.1", candidatePort)); + ports[i] = candidatePort; + break; + } catch (IOException e) { + // Port in use or unable to bind. + continue; + } + } + } + + return ports; + } + + public static class EchoService extends AbstractHttpService { + private volatile HttpStatus responseStatus = HttpStatus.OK; + + @Override + protected final HttpResponse doHead(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.of(req.aggregate() + .thenApply(aReq -> HttpResponse.of(HttpStatus.OK)) + .exceptionally(CompletionActions::log)); + } + + @Override + protected final HttpResponse doPost(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.of(req.aggregate() + .thenApply(this::echo) + .exceptionally(CompletionActions::log)); + } + + protected HttpResponse echo(AggregatedHttpRequest aReq) { + final HttpStatus httpStatus = HttpStatus.valueOf(aReq.contentUtf8()); + if (httpStatus != HttpStatus.UNKNOWN) { + responseStatus = httpStatus; + } + return HttpResponse.of(ResponseHeaders.of(responseStatus), aReq.content()); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java b/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java new file mode 100644 index 00000000000..bb5b558ffca --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.server.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.internal.nacos.NacosTestBase; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; + +@GenerateNativeImageTrace +class NacosUpdatingListenerTest extends NacosTestBase { + + private static final List servers = new ArrayList<>(); + private static volatile List sampleEndpoints; + + @BeforeAll + static void startServers() throws JsonProcessingException { + await().pollInSameThread() + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThatCode(() -> { + final List endpoints = newSampleEndpoints(); + servers.clear(); + for (Endpoint endpoint : endpoints) { + final Server server = Server.builder() + .http(endpoint.port()) + .service("/echo", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener + .builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .endpoint(endpoint) + .build(); + server.addListener(listener); + server.start().join(); + servers.add(server); + } + sampleEndpoints = endpoints; + }).doesNotThrowAnyException()); + } + + @AfterAll + static void stopServers() throws Exception { + servers.forEach(Server::close); + servers.clear(); + } + + @Test + void testBuild() { + assertThat(NacosUpdatingListener.builder(nacosUri(), serviceName) + .build()).isNotNull(); + assertThat(NacosUpdatingListener.builder(nacosUri(), serviceName) + .build()).isNotNull(); + } + + @Test + void testEndpointsCountOfListeningServiceWithAServerStopAndStart() { + // Checks sample endpoints created when initialized. + await().untilAsserted(() -> assertThat(client(null, null).endpoints() + .join()).hasSameSizeAs(sampleEndpoints)); + + // When we close one server then the listener deregister it automatically from nacos. + servers.get(0).stop().join(); + + await().untilAsserted(() -> { + final List results = client(null, null) + .endpoints().join(); + assertThat(results).hasSize(sampleEndpoints.size() - 1); + }); + + // Endpoints increased after service restart. + servers.get(0).start().join(); + + await().untilAsserted(() -> assertThat(client(null, null).endpoints() + .join()).hasSameSizeAs(sampleEndpoints)); + } + + @Test + void testThatGroupNameIsSpecified() { + final int port = unusedPorts(1)[0]; + final Endpoint endpoint = Endpoint.of("host.docker.internal", port).withWeight(1); + + final Server server = Server.builder() + .http(port) + .service("/echo", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener.builder(nacosUri(), "testThatGroupNameIsSpecified") + .nacosApiVersion("v1") + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .endpoint(endpoint) + .groupName("groupName") + .build(); + server.addListener(listener); + server.start().join(); + await().untilAsserted(() -> assertThat(client("testThatGroupNameIsSpecified", "groupName") + .endpoints().join()).hasSize(1)); + server.stop(); + } +} diff --git a/settings.gradle b/settings.gradle index e16cf5341f9..9b7e88b98de 100644 --- a/settings.gradle +++ b/settings.gradle @@ -194,6 +194,7 @@ includeWithFlags ':zookeeper3', 'java', 'publish', 'rel includeWithFlags ':saml', 'java', 'publish', 'relocate', 'native' includeWithFlags ':bucket4j', 'java', 'publish', 'relocate', 'native' includeWithFlags ':consul', 'java', 'publish', 'relocate', 'native' +includeWithFlags ':nacos', 'java', 'publish', 'relocate', 'native' // Published Javadoc-only projects includeWithFlags ':javadoc', 'java', 'publish', 'no_aggregation' From 1ae1e9f30d3a1be1c749488451cf44b7896b7093 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 Nov 2024 10:07:00 +0000 Subject: [PATCH 10/12] Update public suffix list (#5974) Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action Co-authored-by: Meri Kim --- .../main/resources/com/linecorp/armeria/public_suffixes.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt b/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt index e616ec24f80..d940641a053 100644 --- a/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt +++ b/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt @@ -883,7 +883,6 @@ bbt bbva bc.ca bcg -bci.dnstrace.pro bcn bd.se be @@ -6254,7 +6253,6 @@ ono.hyogo.jp onojo.fukuoka.jp onomichi.hiroshima.jp onporter.run -onred.one onrender.com onthewifi.com onza.mythic-beasts.com @@ -7970,7 +7968,6 @@ stackit.zone stada stage.nodeart.io staging.expo.app -staging.onred.one staging.replit.dev stalowa-wola.pl stange.no From 460ea02cec0d83a8f2cf078566ec57acdf71a45a Mon Sep 17 00:00:00 2001 From: Ikhun Um Date: Fri, 8 Nov 2024 11:14:06 +0900 Subject: [PATCH 11/12] Consider a trailing dot when resolve DNS with search domains (#5963) Motivation: There was a report from LY internally where DNS resolver warned for `NXDomain` unexpectedly. ```java java.util.concurrent.CompletionException: java.lang.IllegalArgumentException: Empty label is not a legal name at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315) ... at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver.resolve0(SearchDomainDnsResolver.java:99) at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver.resolve(SearchDomainDnsResolver.java:88) at com.linecorp.armeria.internal.client.dns.HostsFileDnsResolver.resolve(HostsFileDnsResolver.java:130) at com.linecorp.armeria.internal.client.dns.DefaultDnsResolver.resolveOne(DefaultDnsResolver.java:89) at com.linecorp.armeria.internal.client.dns.DefaultDnsResolver.resolve(DefaultDnsResolver.java:81) at com.linecorp.armeria.client.endpoint.dns.DnsEndpointGroup.sendQueries(DnsEndpointGroup.java:155) at com.linecorp.armeria.client.endpoint.dns.DnsEndpointGroup.lambda$sendQueries$3(DnsEndpointGroup.java:173) ... Caused by: java.lang.IllegalArgumentException: Empty label is not a legal name at java.base/java.net.IDN.toASCIIInternal(IDN.java:284) at java.base/java.net.IDN.toASCII(IDN.java:123) at java.base/java.net.IDN.toASCII(IDN.java:152) at com.linecorp.armeria.internal.client.dns.DnsQuestionWithoutTrailingDot.(DnsQuestionWithoutTrailingDot.java:53) at com.linecorp.armeria.internal.client.dns.DnsQuestionWithoutTrailingDot.of(DnsQuestionWithoutTrailingDot.java:48) at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver$SearchDomainQuestionContext.newQuestion(SearchDomainDnsResolver.java:190) at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver$SearchDomainQuestionContext.nextQuestion0(SearchDomainDnsResolver.java:177) at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver$SearchDomainQuestionContext.nextQuestion(SearchDomainDnsResolver.java:150) at com.linecorp.armeria.internal.client.dns.SearchDomainDnsResolver.lambda$resolve0$1(SearchDomainDnsResolver.java:103) ... 19 common frames omitted ``` The NX domain has a trailing dot and search domains start with a dot (`.`). As a result, `example.com..search.domain` was made and rejected by `java.net.IDN` Modifications: - Remove a leading dot from the normalized search domains. - Infix a dot when a hostname does not have a trailing dot. Result: DNS resolver now correctly adds search domains for hostnames with trailing dots. --- .../client/dns/DefaultDnsResolver.java | 9 ++- .../client/dns/DnsQuestionContext.java | 14 +++- .../client/dns/SearchDomainDnsResolver.java | 72 +++++++++++++------ .../TrailingDotAddressResolverTest.java | 22 ++++-- .../client/dns/DefaultDnsResolverTest.java | 25 +++++-- .../dns/SearchDomainDnsResolverTest.java | 43 ++++++++++- .../internal/client/dns/SearchDomainTest.java | 68 +++++++++++++++--- 7 files changed, 204 insertions(+), 49 deletions(-) diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java index 50f04b2c901..2e0f91d0efb 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java @@ -96,7 +96,7 @@ private CompletableFuture> resolveOne(DnsQuestionContext ctx, Dn }); future.handle((unused0, unused1) -> { // Maybe cancel the timeout scheduler. - ctx.cancel(); + ctx.setComplete(); return null; }); return future; @@ -112,7 +112,7 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List { assert executor.inEventLoop(); - maybeCompletePreferredRecords(future, questions, results, order, records, cause); + maybeCompletePreferredRecords(ctx, future, questions, results, order, records, cause); return null; }); } @@ -140,7 +140,8 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List> future, + static void maybeCompletePreferredRecords(DnsQuestionContext ctx, + CompletableFuture> future, List questions, Object[] results, int order, @Nullable List records, @@ -170,6 +171,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut // Found a successful result. assert result instanceof List; future.complete(Collections.unmodifiableList((List) result)); + ctx.setComplete(); return; } @@ -181,6 +183,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut unknownHostException.addSuppressed((Throwable) result); } future.completeExceptionally(unknownHostException); + ctx.setComplete(); } public DnsCache dnsCache() { diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java index a7147526fb5..35c8440d570 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java @@ -29,6 +29,7 @@ final class DnsQuestionContext { private final long queryTimeoutMillis; private final CompletableFuture whenCancelled = new CompletableFuture<>(); private final ScheduledFuture scheduledFuture; + private boolean complete; DnsQuestionContext(EventExecutor executor, long queryTimeoutMillis) { this.queryTimeoutMillis = queryTimeoutMillis; @@ -48,12 +49,21 @@ boolean isCancelled() { return whenCancelled.isCompletedExceptionally(); } - void cancel() { + void cancelScheduler() { if (!scheduledFuture.isDone()) { scheduledFuture.cancel(false); } } + void setComplete() { + complete = true; + cancelScheduler(); + } + + boolean isCompleted() { + return complete; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -65,6 +75,7 @@ public boolean equals(Object o) { final DnsQuestionContext that = (DnsQuestionContext) o; return queryTimeoutMillis == that.queryTimeoutMillis && + complete == that.complete && whenCancelled.equals(that.whenCancelled) && scheduledFuture.equals(that.scheduledFuture); } @@ -74,6 +85,7 @@ public int hashCode() { int result = whenCancelled.hashCode(); result = 31 * result + scheduledFuture.hashCode(); result = 31 * result + (int) queryTimeoutMillis; + result = 31 * result + (complete ? 1 : 0); return result; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java index 712c5189927..431d9e993c0 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java @@ -16,6 +16,7 @@ package com.linecorp.armeria.internal.client.dns; +import static com.google.common.base.MoreObjects.firstNonNull; import static com.google.common.collect.ImmutableList.toImmutableList; import java.util.List; @@ -28,6 +29,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.AbstractUnwrappable; @@ -60,15 +62,18 @@ private static List validateSearchDomain(List searchDomains) { return null; } String normalized = searchDomain; - if (searchDomain.charAt(0) != '.') { - normalized = '.' + searchDomain; + if (searchDomain.charAt(0) == '.') { + // Remove the leading dot. + normalized = searchDomain.substring(1); } - if (searchDomain.charAt(searchDomain.length() - 1) != '.') { + if (normalized.charAt(normalized.length() - 1) != '.') { + // Add a trailing dot. normalized += '.'; } try { // Try to create a sample DnsQuestion to validate the search domain. - DnsQuestionWithoutTrailingDot.of("localhost" + normalized, DnsRecordType.A); + DnsQuestionWithoutTrailingDot.of("localhost." + normalized, + DnsRecordType.A); return normalized; } catch (Exception ex) { logger.warn("Ignoring a malformed search domain: '{}'", searchDomain, ex); @@ -96,6 +101,11 @@ private CompletableFuture> resolve0(DnsQuestionContext ctx, new IllegalStateException("resolver is closed already")); } + if (ctx.isCompleted()) { + // Other DnsRecordType may be resolved already. + return UnmodifiableFuture.completedFuture(ImmutableList.of()); + } + return unwrap().resolve(ctx, question).handle((records, cause) -> { if (records != null) { return UnmodifiableFuture.completedFuture(records); @@ -126,14 +136,18 @@ static final class SearchDomainQuestionContext { private final DnsQuestion original; private final String originalName; private final List searchDomains; + private final int numSearchDomains; private final boolean shouldStartWithHostname; + private final boolean hasTrailingDot; private volatile int numAttemptsSoFar; SearchDomainQuestionContext(DnsQuestion original, List searchDomains, int ndots) { this.original = original; this.searchDomains = searchDomains; + numSearchDomains = searchDomains.size(); originalName = original.name(); - shouldStartWithHostname = hasNDots(originalName, ndots); + hasTrailingDot = originalName.endsWith("."); + shouldStartWithHostname = hasNDots(originalName, ndots) || hasTrailingDot || numSearchDomains == 0; } private static boolean hasNDots(String hostname, int ndots) { @@ -157,32 +171,46 @@ DnsQuestion nextQuestion() { @Nullable private DnsQuestion nextQuestion0() { final int numAttemptsSoFar = this.numAttemptsSoFar; - if (numAttemptsSoFar == 0) { - if (originalName.endsWith(".") || searchDomains.isEmpty()) { - return original; - } - if (shouldStartWithHostname) { - return newQuestion(originalName + '.'); + + final int searchDomainPos; + if (shouldStartWithHostname) { + searchDomainPos = numAttemptsSoFar - 1; + } else { + if (numAttemptsSoFar == numSearchDomains) { + // The last attempt uses the hostname itself. + searchDomainPos = -1; } else { - return newQuestion(originalName + searchDomains.get(0)); + searchDomainPos = numAttemptsSoFar; } } - int nextSearchDomainPos = numAttemptsSoFar; - if (shouldStartWithHostname) { - nextSearchDomainPos = numAttemptsSoFar - 1; + if (searchDomainPos >= numSearchDomains) { + // No more search domain to try. + return null; } - if (nextSearchDomainPos < searchDomains.size()) { - return newQuestion(originalName + searchDomains.get(nextSearchDomainPos)); - } - if (nextSearchDomainPos == searchDomains.size() && !shouldStartWithHostname) { - return newQuestion(originalName + '.'); + final String searchDomain; + // -1 means the hostname itself. + if (searchDomainPos == -1) { + searchDomain = null; + } else { + searchDomain = searchDomains.get(searchDomainPos); } - return null; + + return newQuestion(searchDomain); } - private DnsQuestion newQuestion(String hostname) { + private DnsQuestion newQuestion(@Nullable String searchDomain) { + searchDomain = firstNonNull(searchDomain, ""); + final String hostname; + if (hasTrailingDot) { + if (searchDomain.isEmpty()) { + return original; + } + hostname = originalName + searchDomain; + } else { + hostname = originalName + '.' + searchDomain; + } // - As the search domain is validated already, DnsQuestionWithoutTrailingDot should not raise an // exception. // - Use originalName to delete the cache value in RefreshingAddressResolver when the DnsQuestion diff --git a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java index 68fb9c5d951..504f94cbf53 100644 --- a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableMap; @@ -43,10 +45,13 @@ import io.netty.handler.codec.dns.DefaultDnsResponse; import io.netty.handler.codec.dns.DnsRecord; import io.netty.handler.codec.dns.DnsSection; +import io.netty.resolver.ResolvedAddressTypes; import io.netty.util.ReferenceCountUtil; class TrailingDotAddressResolverTest { + private static final Logger logger = LoggerFactory.getLogger(TrailingDotAddressResolverTest.class); + @RegisterExtension static ServerExtension server = new ServerExtension() { @Override @@ -77,13 +82,15 @@ void resolve() throws Exception { new DefaultDnsQuestion("foo.com.", A), new DefaultDnsResponse(0).addRecord(ANSWER, newAddressRecord("foo.com.", "127.0.0.1"))), dnsRecordCaptor)) { - try (ClientFactory factory = ClientFactory.builder() - .domainNameResolverCustomizer(b -> { - b.serverAddresses(dnsServer.addr()); - b.searchDomains("search.domain1", "search.domain2"); - b.ndots(3); - }) - .build()) { + try (ClientFactory factory = + ClientFactory.builder() + .domainNameResolverCustomizer(b -> { + b.resolvedAddressTypes(ResolvedAddressTypes.IPV4_ONLY); + b.serverAddresses(dnsServer.addr()); + b.searchDomains("search.domain1", "search.domain2"); + b.ndots(3); + }) + .build()) { final BlockingWebClient client = WebClient.builder() .factory(factory) @@ -93,6 +100,7 @@ void resolve() throws Exception { "http://foo.com.:" + server.httpPort() + '/'); assertThat(response.contentUtf8()).isEqualTo("Hello, world!"); assertThat(dnsRecordCaptor.records).isNotEmpty(); + logger.debug("Captured DNS records: {}", dnsRecordCaptor.records); dnsRecordCaptor.records.forEach(record -> { assertThat(record.name()).isEqualTo("foo.com."); }); diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java index 4328afba130..27139316b1d 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java @@ -199,13 +199,16 @@ void shouldWaitForPreferredRecords() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -216,12 +219,15 @@ void shouldWaitForPreferredRecords_ignoreErrorsOnPrecedence() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, null, new AnticipatedException()); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, new AnticipatedException()); assertThat(future).isCompletedWithValue(barDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -232,10 +238,12 @@ void resolvePreferredRecordsFirst() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); // The preferred question is resolved. Don't need to wait for the questions. assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -249,11 +257,14 @@ void shouldWaitForPreferredRecords_allQuestionsAreFailed() { final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. final AnticipatedException barCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, barCause); + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, barCause); assertThat(future).isNotCompleted(); + assertThat(ctx.isCompleted()).isFalse(); final AnticipatedException fooCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 0, null, fooCause); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, fooCause); assertThat(future).isCompletedExceptionally(); + assertThat(ctx.isCompleted()).isTrue(); assertThatThrownBy(future::join) .isInstanceOf(CompletionException.class) .cause() diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java index c17c86c1978..d32bdec4615 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.net.UnknownHostException; import java.util.List; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -76,6 +77,46 @@ public void close() {} DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.io.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.dev.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.", DnsRecordType.A)); - context.cancel(); + context.cancelScheduler(); + } + + @Test + void unknownHostnameEndingWithDot() { + final ByteArrayDnsRecord record = new ByteArrayDnsRecord("example.com", DnsRecordType.A, + 1, new byte[] { 10, 0, 1, 1 }); + final Queue questions = new LinkedBlockingQueue<>(); + final DnsResolver mockResolver = new DnsResolver() { + + @Override + public CompletableFuture> resolve(DnsQuestionContext ctx, DnsQuestion question) { + questions.add(question); + if ("trailing-dot.com.armeria.dev.".equals(question.name())) { + return UnmodifiableFuture.completedFuture(ImmutableList.of(record)); + } else { + return UnmodifiableFuture.exceptionallyCompletedFuture(new UnknownHostException()); + } + } + + @Override + public void close() {} + }; + + final List searchDomains = ImmutableList.of("armeria.io", "armeria.dev"); + final DnsQuestionContext context = new DnsQuestionContext(eventLoop.get(), 10000); + final DnsQuestionWithoutTrailingDot question = + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", DnsRecordType.A); + final SearchDomainDnsResolver resolver = new SearchDomainDnsResolver(mockResolver, searchDomains, 2); + final CompletableFuture> result = resolver.resolve(context, question); + + assertThat(result.join()).contains(record); + assertThat(questions).hasSize(3); + assertThat(questions).containsExactly( + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.io.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.dev.", + DnsRecordType.A)); + context.cancelScheduler(); } } diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java index d966239ae7c..1ea65cb7452 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java @@ -39,15 +39,15 @@ void startsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); // Since `SearchDomainDnsResolver` normalizes search domains while being initialized, // `SearchDomainQuestionContext` should use a normalized search domain that - // starts and ends with a dot for testing . - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + // ends with a dot for testing . + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); final DnsQuestion firstQuestion = ctx.nextQuestion(); assertThat(firstQuestion.name()).isEqualTo(hostname + '.'); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -58,12 +58,12 @@ void startsWithHostname(String hostname, int ndots) { @ParameterizedTest void endsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -73,12 +73,64 @@ void endsWithHostname(String hostname, int ndots) { assertThat(ctx.nextQuestion()).isNull(); } + @Test + void trailingDot() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("foo.com.", "foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 2); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldNotStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + assertThat(ctx.nextQuestion()).isNull(); + } + @Test void noSearchDomain() { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com", DnsRecordType.A); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, ImmutableList.of(), 2); - assertThat(ctx.nextQuestion()).isEqualTo(original); + assertThat(ctx.nextQuestion()).isEqualTo( + DnsQuestionWithoutTrailingDot.of("foo.com", "foo.com.", DnsRecordType.A)); assertThat(ctx.nextQuestion()).isNull(); } } From 0019bc7f8978e26cb34447c469f5f958ab05cb2f Mon Sep 17 00:00:00 2001 From: ChickenchickenLove Date: Fri, 8 Nov 2024 16:17:21 +0900 Subject: [PATCH 12/12] Support micrometer context-propagation (#5577) ### Motivation: - Related Issue : https://github.com/line/armeria/issues/5145 - `Armeria` already support context-propagation to maintain `RequestContext` during executing Reactor code. How it requires maintenance. - `Reactor` integrate `micro-meter:context-propagation` to do context-propagation during `Flux`, `Mono` officially. thus, it would be better to migrate from `RequestContextHook` to `RequestContextPropagationHooks` because it can reduce maintenance cost. ### Modifications: - Add new `Hook` for `Reactor`. - Add new `ThreadLocalAccessor` for `micro-meter:context-propagation` to main `RequestContext` during executing Reactor code like `Mono`, `Flux`. - Add new config `enableContextPropagation` to integrate `micro-meter:context-propagation` with `spring-boot3`. ### Result: - Closes https://github.com/line/armeria/issues/5145 - If user want to use `micrometer:context-propagation` to maintain `RequestContext` during executing Reactor code like `Mono`, `Flux`, just call `RequestContextPropagationHook.enable()`. --------- Co-authored-by: minux Co-authored-by: Trustin Lee Co-authored-by: Trustin Lee Co-authored-by: jrhee17 Co-authored-by: Ikhun Um --- dependencies.toml | 5 + micrometer-context/build.gradle | 4 + .../RequestContextThreadLocalAccessor.java | 106 +++ .../micrometer/context/package-info.java | 26 + ...RequestContextThreadLocalAccessorTest.java | 158 +++++ .../RequestContextPropagationFluxTest.java | 641 ++++++++++++++++++ .../RequestContextPropagationMonoTest.java | 399 +++++++++++ settings.gradle | 1 + 8 files changed, 1340 insertions(+) create mode 100644 micrometer-context/build.gradle create mode 100644 micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java create mode 100644 micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java create mode 100644 micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java create mode 100644 micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java create mode 100644 micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java diff --git a/dependencies.toml b/dependencies.toml index fbfc7650d50..c0a90f46a63 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -19,6 +19,7 @@ caffeine = "2.9.3" cglib = "3.3.0" checkerframework = "2.5.6" checkstyle = "10.3.2" +context-propagation = "1.1.1" controlplane = "1.0.45" curator = "5.7.0" dagger = "2.51.1" @@ -320,6 +321,10 @@ version.ref = "checkerframework" module = "com.puppycrawl.tools:checkstyle" version.ref = "checkstyle" +[libraries.context-propagation] +module = "io.micrometer:context-propagation" +version.ref = "context-propagation" + [libraries.controlplane-api] module = "io.envoyproxy.controlplane:api" version.ref = "controlplane" diff --git a/micrometer-context/build.gradle b/micrometer-context/build.gradle new file mode 100644 index 00000000000..1af31e6e516 --- /dev/null +++ b/micrometer-context/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation libs.context.propagation + testImplementation project(':reactor3') +} diff --git a/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java new file mode 100644 index 00000000000..0844a677f10 --- /dev/null +++ b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.micrometer.context; + +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.RequestContextStorage; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.common.RequestContextUtil; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshot.Scope; +import io.micrometer.context.ThreadLocalAccessor; + +/** + * This class works with the + * Micrometer + * Context Propagation to keep the {@link RequestContext} during + * Reactor operations. + * Get the {@link RequestContextThreadLocalAccessor} to register it to the {@link ContextRegistry}. + * Then, {@link ContextRegistry} will use {@link RequestContextThreadLocalAccessor} to + * propagate context during the + * Reactor operations + * so that you can get the context using {@link RequestContext#current()}. + * However, please note that you should include Mono#contextWrite(ContextView) or + * Flux#contextWrite(ContextView) to end of the Reactor codes. + * If not, {@link RequestContext} will not be keep during Reactor Operation. + */ +@UnstableApi +public final class RequestContextThreadLocalAccessor implements ThreadLocalAccessor { + + private static final Object KEY = RequestContext.class; + + /** + * The value which obtained through {@link RequestContextThreadLocalAccessor}, + * will be stored in the Context under this {@code KEY}. + * This method will be called by {@link ContextSnapshot} internally. + */ + @Override + public Object key() { + return KEY; + } + + /** + * {@link ContextSnapshot} will call this method during the execution + * of lambda functions in {@link ContextSnapshot#wrap(Runnable)}, + * as well as during Mono#subscribe(), Flux#subscribe(), + * {@link Subscription#request(long)}, and CoreSubscriber#onSubscribe(Subscription). + * Following these calls, {@link ContextSnapshot#setThreadLocals()} is + * invoked to restore the state of {@link RequestContextStorage}. + * Furthermore, at the end of these methods, {@link Scope#close()} is executed + * to revert the {@link RequestContextStorage} to its original state. + */ + @Nullable + @Override + public RequestContext getValue() { + return RequestContext.currentOrNull(); + } + + /** + * {@link ContextSnapshot} will call this method during the execution + * of lambda functions in {@link ContextSnapshot#wrap(Runnable)}, + * as well as during Mono#subscribe(), Flux#subscribe(), + * {@link Subscription#request(long)}, and CoreSubscriber#onSubscribe(Subscription). + * Following these calls, {@link ContextSnapshot#setThreadLocals()} is + * invoked to restore the state of {@link RequestContextStorage}. + * Furthermore, at the end of these methods, {@link Scope#close()} is executed + * to revert the {@link RequestContextStorage} to its original state. + */ + @Override + @SuppressWarnings("MustBeClosedChecker") + public void setValue(RequestContext value) { + RequestContextUtil.getAndSet(value); + } + + /** + * This method will be called at the start of {@link ContextSnapshot.Scope} and + * the end of {@link ContextSnapshot.Scope}. If reactor Context does not + * contains {@link RequestContextThreadLocalAccessor#KEY}, {@link ContextSnapshot} will use + * this method to remove the value from {@link ThreadLocal}. + * Please note that {@link RequestContextUtil#pop()} return {@link AutoCloseable} instance, + * but it is not used in `Try with Resources` syntax. this is because {@link ContextSnapshot.Scope} + * will handle the {@link AutoCloseable} instance returned by {@link RequestContextUtil#pop()}. + */ + @Override + @SuppressWarnings("MustBeClosedChecker") + public void setValue() { + RequestContextUtil.pop(); + } +} diff --git a/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java new file mode 100644 index 00000000000..24f7d364c1f --- /dev/null +++ b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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. + */ + +/** + * Micrometer context-propagation plugins to help keep {@link com.linecorp.armeria.common.RequestContext} + * during Reactor operations. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.common.micrometer.context; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java new file mode 100644 index 00000000000..8aaef50d66b --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.common.micrometer.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.internal.common.RequestContextUtil; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshot.Scope; +import io.micrometer.context.ContextSnapshotFactory; + +class RequestContextThreadLocalAccessorTest { + + @Test + void should_return_expected_key() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final Object expectedValue = RequestContext.class; + + // When + final Object result = reqCtxAccessor.key(); + + // Then + assertThat(result).isEqualTo(expectedValue); + } + + @Test + @SuppressWarnings("MustBeClosedChecker") + void should_success_set() { + // Given + final ClientRequestContext ctx = newContext(); + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + + // When + reqCtxAccessor.setValue(ctx); + + // Then + final RequestContext currentCtx = RequestContext.current(); + assertThat(currentCtx).isEqualTo(ctx); + + RequestContextUtil.pop(); + } + + @Test + void should_throw_NPE_when_set_null() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + + // When + Then + assertThatThrownBy(() -> reqCtxAccessor.setValue(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void should_be_null_when_setValue() { + // Given + final ClientRequestContext ctx = newContext(); + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + reqCtxAccessor.setValue(ctx); + + // When + reqCtxAccessor.setValue(); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNull(); + } + + @Test + @SuppressWarnings("MustBeClosedChecker") + void should_be_restore_original_state_when_restore() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final ClientRequestContext previousCtx = newContext(); + final ClientRequestContext currentCtx = newContext(); + reqCtxAccessor.setValue(currentCtx); + + // When + reqCtxAccessor.restore(previousCtx); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNotNull(); + assertThat(reqCtx).isEqualTo(previousCtx); + + RequestContextUtil.pop(); + } + + @Test + void should_be_null_when_restore() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final ClientRequestContext currentCtx = newContext(); + reqCtxAccessor.setValue(currentCtx); + + // When + reqCtxAccessor.restore(); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNull(); + } + + @Test + void requestContext_should_exist_inside_scope_and_not_outside() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + ContextRegistry.getInstance() + .registerThreadLocalAccessor(reqCtxAccessor); + final ClientRequestContext currentCtx = newContext(); + final ClientRequestContext expectedCtx = currentCtx; + reqCtxAccessor.setValue(currentCtx); + + final ContextSnapshotFactory factory = ContextSnapshotFactory.builder() + .clearMissing(true) + .build(); + final ContextSnapshot contextSnapshot = factory.captureAll(); + reqCtxAccessor.setValue(); + + // When : contextSnapshot.setThreadLocals() + try (Scope ignored = contextSnapshot.setThreadLocals()) { + + // Then : should not + final RequestContext reqCtxInScope = RequestContext.currentOrNull(); + assertThat(reqCtxInScope).isSameAs(expectedCtx); + } + + // Then + final RequestContext reqCtxOutOfScope = RequestContext.currentOrNull(); + assertThat(reqCtxOutOfScope).isNull(); + } + + static ClientRequestContext newContext() { + return ClientRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); + } +} diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java new file mode 100644 index 00000000000..7f075c60b80 --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java @@ -0,0 +1,641 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.reactor3; + +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.ctxExists; +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.newContext; +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.noopSubscription; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Publisher; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.micrometer.context.RequestContextThreadLocalAccessor; +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.internal.testing.AnticipatedException; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; + +import io.micrometer.context.ContextRegistry; +import reactor.core.Disposable; +import reactor.core.publisher.ConnectableFlux; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +@GenerateNativeImageTrace +class RequestContextPropagationFluxTest { + + @BeforeAll + static void setUp() { + ContextRegistry + .getInstance() + .registerThreadLocalAccessor(new RequestContextThreadLocalAccessor()); + Hooks.enableAutomaticContextPropagation(); + } + + @AfterAll + static void tearDown() { + Hooks.disableAutomaticContextPropagation(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final AtomicBoolean atomicBoolean = new AtomicBoolean(); + flux = addCallbacks(Flux.error(() -> { + if (!atomicBoolean.getAndSet(true)) { + // Flux.error().publishOn() calls this error supplier immediately to see if it can retrieve + // the value via Callable.call() without ctx. + assertThat(ctxExists(ctx)).isFalse(); + } else { + assertThat(ctxExists(ctx)).isTrue(); + } + return new AnticipatedException(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxFromPublisher(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.from(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCreate(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.create(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCreate_error(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.create(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.error(new AnticipatedException()); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxConcat(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.concat(Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + }), Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "bar"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxDefer(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.defer(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return Flux.just("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxFromStream(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.fromStream(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return Stream.of("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCombineLatest(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.combineLatest(Mono.just("foo"), Mono.just("bar"), (a, b) -> { + assertThat(ctxExists(ctx)).isTrue(); + return a; + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxGenerate(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.generate(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxMerge(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.mergeSequential(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }, s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("bar"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxPush(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.push(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxSwitchOnNext(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.switchOnNext(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext((Publisher) s1 -> { + assertThat(ctxExists(ctx)).isTrue(); + s1.onSubscribe(noopSubscription()); + s1.onNext("foo"); + s1.onComplete(); + }); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxZip(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.zip(Mono.just("foo"), Mono.just("bar"), (foo, bar) -> { + assertThat(ctxExists(ctx)).isTrue(); + return foo; + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxInterval(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.interval(Duration.ofMillis(100)).take(2).concatMap(a -> { + assertThat(ctxExists(ctx)).isTrue(); + return Mono.just("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxConcatDelayError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.concatDelayError(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onError(new AnticipatedException()); + }, s -> { + s.onSubscribe(noopSubscription()); + s.onNext("bar"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxTransform(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.just("foo").transform(fooFlux -> s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext(fooFlux.blockFirst()); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void connectableFlux(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final ConnectableFlux connectableFlux = Flux.just("foo").publish(); + flux = addCallbacks(connectableFlux.autoConnect(2).publishOn(Schedulers.single()), + ctx, + useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + flux.subscribe().dispose(); + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + flux.subscribe().dispose(); + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void connectableFlux_dispose(boolean useContextCapture) throws InterruptedException { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final ConnectableFlux connectableFlux = Flux.just("foo").publish(); + flux = addCallbacks(connectableFlux.autoConnect(2, disposable -> { + assertThat(ctxExists(ctx)).isTrue(); + }).publishOn(Schedulers.newSingle("aaa")), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + final Disposable disposable1 = flux.subscribe(); + await().pollDelay(Duration.ofMillis(200)).until(() -> !disposable1.isDisposed()); + final Disposable disposable2 = flux.subscribe(); + await().untilAsserted(() -> { + assertThat(disposable1.isDisposed()).isTrue(); + assertThat(disposable2.isDisposed()).isTrue(); + }); + } + } else { + final Disposable disposable1 = flux.subscribe(); + await().pollDelay(Duration.ofMillis(200)).until(() -> !disposable1.isDisposed()); + final Disposable disposable2 = flux.subscribe(); + await().untilAsserted(() -> { + assertThat(disposable1.isDisposed()).isTrue(); + assertThat(disposable2.isDisposed()).isTrue(); + }); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void subscriberContextIsNotMissing() { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = Flux.deferContextual(reactorCtx -> { + assertThat((String) reactorCtx.get("foo")).isEqualTo("bar"); + return Flux.just("baz"); + }); + + final Flux flux1 = flux.contextWrite(reactorCtx -> reactorCtx.put("foo", "bar")); + StepVerifier.create(flux1) + .expectNextMatches("baz"::equals) + .verifyComplete(); + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void ctxShouldBeCleanUpEvenIfErrorOccursDuringReactorOperationOnSchedulerThread() + throws InterruptedException { + // Given + final ClientRequestContext ctx = newContext(); + final Flux flux; + final Scheduler single = Schedulers.single(); + + // When + flux = Flux.just("Hello", "Hi") + .subscribeOn(single) + .delayElements(Duration.ofMillis(1000)) + .map(s -> { + if ("Hello".equals(s)) { + throw new RuntimeException(); + } + return s; + }) + .contextWrite(Context.of(RequestContext.class, ctx)); + + // Then + StepVerifier.create(flux) + .expectError(RuntimeException.class) + .verify(); + + final CountDownLatch latch = new CountDownLatch(1); + single.schedule(() -> { + assertThat(ctxExists(ctx)).isFalse(); + latch.countDown(); + }); + latch.await(); + + assertThat(ctxExists(ctx)).isFalse(); + } + + private static Flux addCallbacks(Flux flux0, + ClientRequestContext ctx, + boolean useContextCapture) { + final Flux flux = flux0.doFirst(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSubscribe(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnRequest(l -> assertThat(ctxExists(ctx)).isTrue()) + .doOnNext(foo -> assertThat(ctxExists(ctx)).isTrue()) + .doOnComplete(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnEach(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnError(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnCancel(() -> assertThat(ctxExists(ctx)).isTrue()) + .doFinally(t -> assertThat(ctxExists(ctx)).isTrue()) + .doAfterTerminate(() -> assertThat(ctxExists(ctx)).isTrue()); + + if (useContextCapture) { + return flux.contextCapture(); + } + return flux.contextWrite(Context.of(RequestContext.class, ctx)); + } +} diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java new file mode 100644 index 00000000000..1709883b9de --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java @@ -0,0 +1,399 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.armeria.reactor3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.micrometer.context.RequestContextThreadLocalAccessor; +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.internal.testing.AnticipatedException; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; + +import io.micrometer.context.ContextRegistry; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; +import reactor.util.context.Context; +import reactor.util.function.Tuple2; + +@GenerateNativeImageTrace +class RequestContextPropagationMonoTest { + + @BeforeAll + static void setUp() { + ContextRegistry + .getInstance() + .registerThreadLocalAccessor(new RequestContextThreadLocalAccessor()); + Hooks.enableAutomaticContextPropagation(); + } + + @AfterAll + static void tearDown() { + Hooks.disableAutomaticContextPropagation(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_success(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.success("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_error(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.error(new AnticipatedException()); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_currentContext(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.success("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoDefer(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.defer(() -> Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFromPublisher(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.from(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.error(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return new AnticipatedException(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFirst(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.firstWithSignal(Mono.delay(Duration.ofMillis(1000)).then(Mono.just("bar")), + Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })) + .publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFromFuture(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.fromFuture(future) + .publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoDelay(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.delay(Duration.ofMillis(100)).then(Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoZip(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono> mono; + mono = addCallbacks(Mono.zip(Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + }), Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "bar"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches(t -> "foo".equals(t.getT1()) && "bar".equals(t.getT2())) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches(t -> "foo".equals(t.getT1()) && "bar".equals(t.getT2())) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void subscriberContextIsNotMissing() { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = Mono.deferContextual(Mono::just).handle((reactorCtx, sink) -> { + assertThat((String) reactorCtx.get("foo")).isEqualTo("bar"); + sink.next("baz"); + }); + + final Mono mono1 = mono.contextWrite(reactorCtx -> reactorCtx.put("foo", "bar")); + StepVerifier.create(mono1) + .expectNextMatches("baz"::equals) + .verifyComplete(); + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void ctxShouldBeCleanUpEvenIfErrorOccursDuringReactorOperationOnSchedulerThread() + throws InterruptedException { + // Given + final ClientRequestContext ctx = newContext(); + final Mono mono; + final Scheduler single = Schedulers.single(); + + // When + mono = Mono.just("Hello") + .subscribeOn(single) + .delayElement(Duration.ofMillis(1000)) + .map(s -> { + if ("Hello".equals(s)) { + throw new RuntimeException(); + } + return s; + }) + .contextWrite(Context.of(RequestContext.class, ctx)); + + // Then + StepVerifier.create(mono) + .expectError(RuntimeException.class) + .verify(); + + final CountDownLatch latch = new CountDownLatch(1); + single.schedule(() -> { + assertThat(ctxExists(ctx)).isFalse(); + latch.countDown(); + }); + latch.await(); + + assertThat(ctxExists(ctx)).isFalse(); + } + + static Subscription noopSubscription() { + return new Subscription() { + @Override + public void request(long n) {} + + @Override + public void cancel() {} + }; + } + + static boolean ctxExists(ClientRequestContext ctx) { + return RequestContext.currentOrNull() == ctx; + } + + static ClientRequestContext newContext() { + return ClientRequestContext.builder(HttpRequest.of(HttpMethod.GET, "/")) + .build(); + } + + private static Mono addCallbacks(Mono mono0, ClientRequestContext ctx, + boolean useContextCapture) { + final Mono mono = mono0.doFirst(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSubscribe(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnRequest(l -> assertThat(ctxExists(ctx)).isTrue()) + .doOnNext(foo -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSuccess(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnEach(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnError(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnCancel(() -> assertThat(ctxExists(ctx)).isTrue()) + .doFinally(t -> assertThat(ctxExists(ctx)).isTrue()) + .doAfterTerminate(() -> assertThat(ctxExists(ctx)).isTrue()); + if (useContextCapture) { + return mono.contextCapture(); + } + return mono.contextWrite(Context.of(RequestContext.class, ctx)); + } +} diff --git a/settings.gradle b/settings.gradle index 9b7e88b98de..f43ffc4c779 100644 --- a/settings.gradle +++ b/settings.gradle @@ -122,6 +122,7 @@ includeWithFlags ':logback13', 'java', 'publish', 'rel project(':logback13').projectDir = file('logback/logback13') includeWithFlags ':logback14', 'java11', 'publish', 'relocate', 'no_aggregation' project(':logback14').projectDir = file('logback/logback14') +includeWithFlags ':micrometer-context', 'java', 'relocate', 'native' includeWithFlags ':native-image-config' includeWithFlags ':oauth2', 'java', 'publish', 'relocate', 'native' includeWithFlags ':prometheus1', 'java', 'publish', 'relocate', 'native'