diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0372530525214..c25ede4dfc633 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -3593,6 +3593,11 @@ hamcrest ${hamcrest.version} + + me.escoffier.certs + certificate-generator-junit5 + 0.3.0 + org.antlr diff --git a/docs/src/main/asciidoc/http-reference.adoc b/docs/src/main/asciidoc/http-reference.adoc index 22c9db0150bcc..dbab826dc68cb 100644 --- a/docs/src/main/asciidoc/http-reference.adoc +++ b/docs/src/main/asciidoc/http-reference.adoc @@ -239,8 +239,8 @@ Configure the `quarkus.http.ssl.certificate.reload-period` property to specify t [source, properties] ---- -quarkus.http.ssl.certificate.files=/mount/certs/cert.pem -quarkus.http.ssl.certificate.key-files=/mount/certs/key.pem +quarkus.http.ssl.certificate.files=/mount/certs/tls.crt +quarkus.http.ssl.certificate.key-files=/mount/certs/tls.key quarkus.http.ssl.certificate.reload-period=1h ---- diff --git a/docs/src/main/asciidoc/management-interface-reference.adoc b/docs/src/main/asciidoc/management-interface-reference.adoc index 345592ca61f9d..0f245b3875795 100644 --- a/docs/src/main/asciidoc/management-interface-reference.adoc +++ b/docs/src/main/asciidoc/management-interface-reference.adoc @@ -58,6 +58,20 @@ quarkus.management.ssl.certificate.key-store-file=server-keystore.jks quarkus.management.ssl.certificate.key-store-password=secret ---- +Key store, trust store and certificate files can be reloaded periodically. +Configure the `quarkus.management.ssl.certificate.reload-period` property to specify the interval at which the certificates should be reloaded: + +[source, properties] +---- +quarkus.http.management.certificate.files=/mount/certs/tls.crt +quarkus.http.management.certificate.key-files=/mount/certs/tls.key +quarkus.http.management.certificate.reload-period=1h +---- + +The files are reloaded from the same location as they were initially loaded from. +If there is no content change, the reloading is a no-op. +It the reloading fails, the server will continue to use the previous certificates. + IMPORTANT: Unlike the main HTTP server, the management interface does not handle _http_ and _https_ at the same time. If _https_ is configured, plain HTTP requests will be rejected. diff --git a/extensions/vertx-http/deployment/pom.xml b/extensions/vertx-http/deployment/pom.xml index 14d44cbf77b92..4bbe58e889033 100644 --- a/extensions/vertx-http/deployment/pom.xml +++ b/extensions/vertx-http/deployment/pom.xml @@ -33,7 +33,7 @@ io.quarkus quarkus-kubernetes-spi - + io.quarkus @@ -64,7 +64,7 @@ com.fasterxml.jackson.datatype jackson-datatype-jdk8 - + io.quarkus @@ -111,6 +111,12 @@ vertx-web-client test + + + me.escoffier.certs + certificate-generator-junit5 + test + diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadTest.java new file mode 100644 index 0000000000000..51b3354b1eccb --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadTest.java @@ -0,0 +1,160 @@ +package io.quarkus.vertx.http.certReload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.UUID; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.ext.web.Router; +import me.escoffier.certs.Format; +import me.escoffier.certs.junit5.Certificate; +import me.escoffier.certs.junit5.Certificates; + +@Certificates(baseDir = "target/certificates", certificates = { + @Certificate(name = "reload-A", formats = Format.PEM), + @Certificate(name = "reload-B", formats = Format.PEM, duration = 365), +}) +@DisabledOnOs(OS.WINDOWS) +public class MainHttpServerTlsCertificateReloadTest { + + @TestHTTPResource(value = "/hello", ssl = true) + URL url; + + public static final File temp = new File("target/test-certificates-" + UUID.randomUUID()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(MyBean.class)) + .overrideConfigKey("quarkus.http.ssl.insecure-requests", "redirect") + .overrideConfigKey("quarkus.http.ssl.certificate.reload-period", "30s") + .overrideConfigKey("quarkus.http.ssl.certificate.files", temp.getAbsolutePath() + "/tls.crt") + .overrideConfigKey("quarkus.http.ssl.certificate.key-files", temp.getAbsolutePath() + "/tls.key") + .overrideConfigKey("loc", temp.getAbsolutePath()) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(new File("target/certificates/reload-A.crt").toPath(), + new File(temp, "/tls.crt").toPath()); + Files.copy(new File("target/certificates/reload-A.key").toPath(), + new File(temp, "/tls.key").toPath()); + Files.copy(new File("target/certificates/reload-A-ca.crt").toPath(), + new File(temp, "/ca.crt").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "/tls.crt").toPath()); + Files.deleteIfExists(new File(temp, "/tls.key").toPath()); + Files.deleteIfExists(new File(temp, "/ca.crt").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @Test + void test() throws IOException { + var options = new HttpClientOptions() + .setSsl(true) + .setDefaultPort(url.getPort()) + .setDefaultHost(url.getHost()) + .setTrustOptions(new PemTrustOptions().addCertPath("target/certificates/reload-A-ca.crt")); + + String response1 = vertx.createHttpClient(options) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + // Update certs + Files.copy(new File("target/certificates/reload-B.crt").toPath(), + new File(certs, "/tls.crt").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Files.copy(new File("target/certificates/reload-B.key").toPath(), + new File(certs, "/tls.key").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + // Trigger the reload + TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10)); + + // The client truststore is not updated, thus it should fail. + assertThatThrownBy(() -> vertx.createHttpClient(options) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + + var options2 = new HttpClientOptions(options) + .setTrustOptions(new PemTrustOptions().addCertPath("target/certificates/reload-B-ca.crt")); + + var response2 = vertx.createHttpClient(options2) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + assertThat(response1).isNotEqualTo(response2); // Because cert duration are different. + + // Trigger another reload + TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10)); + + var response3 = vertx.createHttpClient(options2) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + assertThat(response2).isEqualTo(response3); + } + + public static class MyBean { + + public void onStart(@Observes Router router) { + router.get("/hello").handler(rc -> { + var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]).getNotAfter() + .toInstant().toEpochMilli(); + rc.response().end("Hello " + exp); + }); + } + + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadTest.java new file mode 100644 index 0000000000000..fcd81cf1d284f --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadTest.java @@ -0,0 +1,153 @@ +package io.quarkus.vertx.http.certReload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.UUID; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.net.PfxOptions; +import io.vertx.ext.web.Router; +import me.escoffier.certs.Format; +import me.escoffier.certs.junit5.Certificate; +import me.escoffier.certs.junit5.Certificates; + +@Certificates(baseDir = "target/certificates", certificates = { + @Certificate(name = "reload-A", formats = Format.PKCS12, password = "password"), + @Certificate(name = "reload-B", formats = Format.PKCS12, password = "password", duration = 365), +}) +@DisabledOnOs(OS.WINDOWS) +public class MainHttpServerTlsPKCS12CertificateReloadTest { + + @TestHTTPResource(value = "/hello", ssl = true) + URL url; + + public static final File temp = new File("target/test-certificates-" + UUID.randomUUID()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(MyBean.class)) + .overrideConfigKey("quarkus.http.ssl.insecure-requests", "redirect") + .overrideConfigKey("quarkus.http.ssl.certificate.reload-period", "30s") + .overrideConfigKey("quarkus.http.ssl.certificate.key-store-file", temp.getAbsolutePath() + "/tls.p12") + .overrideConfigKey("loc", temp.getAbsolutePath()) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(new File("target/certificates/reload-A-keystore.p12").toPath(), + new File(temp, "/tls.p12").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "/tls.p12").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @Test + void test() throws IOException { + var options = new HttpClientOptions() + .setSsl(true) + .setDefaultPort(url.getPort()) + .setDefaultHost(url.getHost()) + .setTrustOptions( + new PfxOptions().setPath("target/certificates/reload-A-truststore.p12").setPassword("password")); + + String response1 = vertx.createHttpClient(options) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + // Update certs + Files.copy(new File("target/certificates/reload-B-keystore.p12").toPath(), + new File(certs, "/tls.p12").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + // Trigger the reload + TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10)); + + // The client truststore is not updated, thus it should fail. + assertThatThrownBy(() -> vertx.createHttpClient(options) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + + var options2 = new HttpClientOptions(options) + .setTrustOptions( + new PfxOptions().setPath("target/certificates/reload-B-truststore.p12").setPassword("password")); + + var response2 = vertx.createHttpClient(options2) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + assertThat(response1).isNotEqualTo(response2); // Because cert duration are different. + + // Trigger another reload + TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10)); + + var response3 = vertx.createHttpClient(options2) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + assertThat(response2).isEqualTo(response3); + } + + public static class MyBean { + + public void onStart(@Observes Router router) { + router.get("/hello").handler(rc -> { + var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]).getNotAfter() + .toInstant().toEpochMilli(); + rc.response().end("Hello " + exp); + }); + } + + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadTest.java new file mode 100644 index 0000000000000..b62898abfaf6e --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadTest.java @@ -0,0 +1,174 @@ +package io.quarkus.vertx.http.certReload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; +import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.ext.web.RoutingContext; +import me.escoffier.certs.Format; +import me.escoffier.certs.junit5.Certificate; +import me.escoffier.certs.junit5.Certificates; + +@Certificates(baseDir = "target/certificates", certificates = { + @Certificate(name = "reload-C", formats = Format.PEM), + @Certificate(name = "reload-D", formats = Format.PEM, duration = 365), +}) +@DisabledOnOs(OS.WINDOWS) +public class ManagementHttpServerTlsCertificateReloadTest { + + public static final File temp = new File("target/test-certificates-" + UUID.randomUUID()); + + private static final String APP_PROPS = """ + quarkus.management.enabled=true + quarkus.management.ssl.certificate.reload-period=30s + quarkus.management.ssl.certificate.files=%s + quarkus.management.ssl.certificate.key-files=%s + + loc=%s + """.formatted(temp.getAbsolutePath() + "/tls.crt", temp.getAbsolutePath() + "/tls.key", temp.getAbsolutePath()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(APP_PROPS), "application.properties")) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(new File("target/certificates/reload-C.crt").toPath(), + new File(temp, "/tls.crt").toPath()); + Files.copy(new File("target/certificates/reload-C.key").toPath(), + new File(temp, "/tls.key").toPath()); + Files.copy(new File("target/certificates/reload-C-ca.crt").toPath(), + new File(temp, "/ca.crt").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .addBuildChainCustomizer(buildCustomizer()) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "/tls.crt").toPath()); + Files.deleteIfExists(new File(temp, "/tls.key").toPath()); + Files.deleteIfExists(new File(temp, "/ca.crt").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + static Consumer buildCustomizer() { + return new Consumer() { + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + NonApplicationRootPathBuildItem buildItem = context.consume(NonApplicationRootPathBuildItem.class); + context.produce(buildItem.routeBuilder() + .management() + .route("/hello") + .handler(new MyHandler()) + .build()); + } + }).produces(RouteBuildItem.class) + .consumes(NonApplicationRootPathBuildItem.class) + .build(); + } + }; + } + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @Test + void test() throws IOException { + var options = new HttpClientOptions() + .setSsl(true) + .setDefaultPort(9001) // Management interface test port + .setDefaultHost("localhost") + .setTrustOptions(new PemTrustOptions().addCertPath(new File(certs, "/ca.crt").getAbsolutePath())); + + String response1 = vertx.createHttpClient(options) + .request(HttpMethod.GET, "/q/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + // Update certs + Files.copy(new File("target/certificates/reload-D.crt").toPath(), + new File(certs, "/tls.crt").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Files.copy(new File("target/certificates/reload-D.key").toPath(), + new File(certs, "/tls.key").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + // Trigger the reload + TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10)); + + // The client truststore is not updated, thus it should fail. + assertThatThrownBy(() -> vertx.createHttpClient(options) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + + var options2 = new HttpClientOptions(options) + .setTrustOptions(new PemTrustOptions().addCertPath("target/certificates/reload-D-ca.crt")); + + var response2 = vertx.createHttpClient(options2) + .request(HttpMethod.GET, "/hello") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join(); + + assertThat(response1).isNotEqualTo(response2); // Because cert duration are different. + } + + public static class MyHandler implements Handler { + @Override + public void handle(RoutingContext rc) { + var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]).getNotAfter() + .toInstant().toEpochMilli(); + rc.response().end("Hello Management " + exp); + } + } + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java index cd61b39fedda1..14d2b3a8a802c 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java @@ -60,7 +60,7 @@ import io.quarkus.vertx.http.runtime.management.ManagementInterfaceConfiguration; import io.quarkus.vertx.http.runtime.options.HttpServerCommonHandlers; import io.quarkus.vertx.http.runtime.options.HttpServerOptionsUtils; -import io.quarkus.vertx.http.runtime.options.TlsCertificateReloadUtils; +import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader; import io.smallrye.common.vertx.VertxContext; import io.vertx.core.AbstractVerticle; import io.vertx.core.AsyncResult; @@ -171,7 +171,7 @@ private boolean uriValid(HttpServerRequest httpServerRequest) { private static HttpServerOptions httpMainDomainSocketOptions; private static HttpServerOptions httpManagementServerOptions; - private static final List taskIds = new CopyOnWriteArrayList<>(); + private static final List refresTaskIds = new CopyOnWriteArrayList<>(); final HttpBuildTimeConfig httpBuildTimeConfig; final ManagementInterfaceBuildTimeConfig managementBuildTimeConfig; final RuntimeValue httpConfiguration; @@ -634,11 +634,11 @@ private static CompletableFuture initializeManagementInterface(Vertx new IllegalStateException("Unable to start the management interface", ar.cause())); } else { if (httpManagementServerOptions.isSsl() - && managementConfig.ssl.certificate.reloadPeriod.isPresent()) { - long l = TlsCertificateReloadUtils.handleCertificateReloading( + && (managementConfig.ssl.certificate.reloadPeriod.isPresent())) { + long l = TlsCertificateReloader.initCertReloadingAction( vertx, ar.result(), httpManagementServerOptions, managementConfig.ssl); if (l != -1) { - taskIds.add(l); + refresTaskIds.add(l); } } @@ -822,8 +822,8 @@ public void handle(AsyncResult event) { // shutdown the management interface try { - for (Long id : taskIds) { - vertx.cancelTimer(id); + for (Long id : refresTaskIds) { + TlsCertificateReloader.unschedule(vertx, id); } if (managementServer != null && !isVertxClose) { managementServer.close(handler); @@ -1204,8 +1204,8 @@ public void handle(AsyncResult event) { portSystemProperties.set(schema, actualPort, launchMode); } - if (https && quarkusConfig.ssl.certificate.reloadPeriod.isPresent()) { - long l = TlsCertificateReloadUtils.handleCertificateReloading( + if (https && (quarkusConfig.ssl.certificate.reloadPeriod.isPresent())) { + long l = TlsCertificateReloader.initCertReloadingAction( vertx, httpsServer, httpsOptions, quarkusConfig.ssl); if (l != -1) { reloadingTasks.add(l); @@ -1226,7 +1226,7 @@ public void handle(AsyncResult event) { public void stop(Promise stopFuture) { for (Long id : reloadingTasks) { - vertx.cancelTimer(id); + TlsCertificateReloader.unschedule(vertx, id); } final AtomicInteger remainingCount = new AtomicInteger(0); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloadUtils.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloader.java similarity index 64% rename from extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloadUtils.java rename to extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloader.java index 4b1512775268d..00566ad5ab2a8 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloadUtils.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/options/TlsCertificateReloader.java @@ -7,11 +7,15 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; import org.jboss.logging.Logger; import io.quarkus.vertx.http.runtime.ServerSslConfig; +import io.smallrye.mutiny.Uni; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; @@ -26,18 +30,18 @@ /** * Utility class to handle TLS certificate reloading. */ -public class TlsCertificateReloadUtils { +public class TlsCertificateReloader { - public static long handleCertificateReloading(Vertx vertx, HttpServer server, + /** + * A structure storing the reload tasks. + */ + private static final List TASKS = new CopyOnWriteArrayList<>(); + + private static final Logger LOGGER = Logger.getLogger(TlsCertificateReloader.class); + + public static long initCertReloadingAction(Vertx vertx, HttpServer server, HttpServerOptions options, ServerSslConfig configuration) { - // Validation - if (configuration.certificate.reloadPeriod.isEmpty()) { - return -1; - } - if (configuration.certificate.reloadPeriod.get().toMillis() < 30_000) { - throw new IllegalArgumentException( - "Unable to configure TLS reloading - The reload period cannot be less than 30 seconds"); - } + if (options == null) { throw new IllegalArgumentException("Unable to configure TLS reloading - The HTTP server options were not provided"); } @@ -46,12 +50,23 @@ public static long handleCertificateReloading(Vertx vertx, HttpServer server, throw new IllegalArgumentException("Unable to configure TLS reloading - TLS/SSL is not enabled on the server"); } - Logger log = Logger.getLogger(TlsCertificateReloadUtils.class); - return vertx.setPeriodic(configuration.certificate.reloadPeriod.get().toMillis(), new Handler() { + long period; + // Validation + if (configuration.certificate.reloadPeriod.isPresent()) { + if (configuration.certificate.reloadPeriod.get().toMillis() < 30_000) { + throw new IllegalArgumentException( + "Unable to configure TLS reloading - The reload period cannot be less than 30 seconds"); + } + period = configuration.certificate.reloadPeriod.get().toMillis(); + } else { + return -1; + } + + Supplier> task = new Supplier>() { @Override - public void handle(Long id) { + public Uni get() { - vertx.executeBlocking(new Callable() { + Future future = vertx.executeBlocking(new Callable() { @Override public SSLOptions call() throws Exception { // We are reading files - must be done on a worker thread. @@ -76,17 +91,51 @@ public Future apply(SSLOptions res) { @Override public void handle(AsyncResult ar) { if (ar.failed()) { - log.error("Unable to reload the TLS certificate, keeping the current one.", ar.cause()); + LOGGER.error("Unable to reload the TLS certificate, keeping the current one.", ar.cause()); } else { if (ar.result()) { - log.debug("TLS certificates updated"); + LOGGER.debug("TLS certificates updated"); } // Not updated, no change. } } }); + + return Uni.createFrom().completionStage(future.toCompletionStage()); + } + }; + + long id = vertx.setPeriodic(configuration.certificate.reloadPeriod.get().toMillis(), new Handler() { + @Override + public void handle(Long id) { + //noinspection ResultOfMethodCallIgnored + task.get().subscribeAsCompletionStage(); } }); + + TASKS.add(new ReloadCertificateTask(id, task)); + return id; + } + + public static void unschedule(Vertx vertx, long id) { + vertx.cancelTimer(id); + for (ReloadCertificateTask task : TASKS) { + if (task.it == id) { + TASKS.remove(task); + break; + } + } + } + + /** + * Trigger all the reload tasks. + * This method is NOT part of the public API, and is only used for testing purpose. + * + * @return a Uni that is completed when all the reload tasks have been executed + */ + public static Uni reload() { + var unis = TASKS.stream().map(ReloadCertificateTask::action).map(Supplier::get).collect(Collectors.toList()); + return Uni.join().all(unis).andFailFast().replaceWithVoid(); } private static SSLOptions reloadFileContent(SSLOptions ssl, ServerSslConfig configuration) throws IOException { @@ -133,4 +182,8 @@ private static SSLOptions reloadFileContent(SSLOptions ssl, ServerSslConfig conf return copy; } + + record ReloadCertificateTask(long it, Supplier> action) { + + } }