From 10654d6a220a5cc9905340de4306bc4cc35e2146 Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Thu, 27 Jun 2024 08:31:39 +0200 Subject: [PATCH 1/2] Periodic TLS certificate reload Provide a way to periodically reload certificates from the file system and document how to implement your own reloader. --- .../main/asciidoc/tls-registry-reference.adoc | 61 +++++++ .../io/quarkus/tls/CertificatesProcessor.java | 6 +- .../quarkus/tls/CertificateUpdatedEvent.java | 14 ++ .../tls/runtime/CertificateRecorder.java | 23 ++- .../tls/runtime/TlsCertificateUpdater.java | 61 +++++++ .../tls/runtime/config/TlsBucketConfig.java | 16 ++ .../http/deployment/VertxHttpProcessor.java | 2 + ...loadWithTlsRegistryAndUpdateEventTest.java | 170 ++++++++++++++++++ ...loadWithTlsRegistryAndUpdateEventTest.java | 167 +++++++++++++++++ ...sCertificateReloadWithTlsRegistryTest.java | 15 +- .../vertx/http/runtime/CertificateConfig.java | 3 + .../HttpCertificateUpdateEventListener.java | 52 ++++++ .../vertx/http/runtime/VertxHttpRecorder.java | 15 ++ 13 files changed, 597 insertions(+), 8 deletions(-) create mode 100644 extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/CertificateUpdatedEvent.java create mode 100644 extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/TlsCertificateUpdater.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadWithTlsRegistryAndUpdateEventTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadWithTlsRegistryAndUpdateEventTest.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCertificateUpdateEventListener.java diff --git a/docs/src/main/asciidoc/tls-registry-reference.adoc b/docs/src/main/asciidoc/tls-registry-reference.adoc index 3c918ee0b26a8..8f5e55be81a33 100644 --- a/docs/src/main/asciidoc/tls-registry-reference.adoc +++ b/docs/src/main/asciidoc/tls-registry-reference.adoc @@ -519,3 +519,64 @@ When the application starts, the TLS registry performs some checks to ensure the - the CRLs are valid If any of these checks fail, the application will fail to start. + +== Reloading Certificates + +The `TlsConfiguration` obtained from the `TLSConfigurationRegistry` includes a mechanism for reloading certificates. +The `reload` method refreshes the key stores and trust stores, typically by reloading them from the file system. + +NOTE: The reload operation is not automatic and must be triggered manually. Additionally, the `TlsConfiguration` implementation must support reloading (which is the case for the configured certificate). + +The `reload` method returns a `boolean` indicating whether the reload was successful. +A value of `true` means the reload operation was successful, not necessarily that there were updates to the certificates. + +After a `TlsConfiguration` has been reloaded, servers and clients using this configuration may need to perform specific actions to apply the new certificates. +The recommended approach is to fire a CDI event (`CertificateReloadedEvent`) that servers and clients can listen to and make the necessary changes: + +[source, java] +---- +@Inject +TlsConfigurationRegistry registry; + +public void reload() { + TlsConfiguration config = registry.get("name").orElseThrow(); + if (config.reload()) { + event.fire(new CertificateReloadedEvent("name", config)); + } +} + +// In the server or client code +public void onReload(@Observes CertificateReloadedEvent event) { + if ("name".equals(event.getName())) { + server.updateSSLOptions(event.tlsConfiguration().getSSLOptions()); + // Or update the SSLContext. + } +} +---- + +These APIs provide a way to implement custom certificate reloading. + +=== Periodic reloading + +The TLS registry does include a built-in mechanism for periodically checking the file system for changes and reloading the certificates. +You can configure periodic reloading of certificates using properties. +The `reload-period` property specifies the interval at which certificates are reloaded, and it will emit a `CertificateReloadedEvent`. + +[source, properties] +---- +quarkus.tls.reload-period=1h +quarkus.tls.key-store.pem.0.cert=tls.crt +quarkus.tls.key-store.pem.0.key=tls.key +---- + +For each named configuration, you can set a specific reload period: + +[source, properties] +---- +quarkus.tls.http.reload-period=30min +quarkus.tls.http.key-store.pem.0.cert=tls.crt +quarkus.tls.http.key-store.pem.0.key=tls.key +---- + +Remember that the impacted server and client may need to listen to the `CertificateReloadedEvent` to apply the new certificates. +This is automatically done for the Quarkus HTTP server (including the management interface if enabled). \ No newline at end of file diff --git a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/CertificatesProcessor.java b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/CertificatesProcessor.java index d9b27e0408591..310f886adbcb2 100644 --- a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/CertificatesProcessor.java +++ b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/CertificatesProcessor.java @@ -11,6 +11,7 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.tls.runtime.CertificateRecorder; import io.quarkus.tls.runtime.config.TlsConfig; import io.quarkus.vertx.deployment.VertxBuildItem; @@ -22,10 +23,11 @@ public class CertificatesProcessor { public TlsRegistryBuildItem initializeCertificate( TlsConfig config, Optional vertx, CertificateRecorder recorder, BuildProducer syntheticBeans, - List otherCertificates) { + List otherCertificates, + ShutdownContextBuildItem shutdown) { if (vertx.isPresent()) { - recorder.validateCertificates(config, vertx.get().getVertx()); + recorder.validateCertificates(config, vertx.get().getVertx(), shutdown); } for (TlsCertificateBuildItem certificate : otherCertificates) { diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/CertificateUpdatedEvent.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/CertificateUpdatedEvent.java new file mode 100644 index 0000000000000..bce5890f3154f --- /dev/null +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/CertificateUpdatedEvent.java @@ -0,0 +1,14 @@ +package io.quarkus.tls; + +/** + * Event fired when a certificate is updated. + *

+ * IMPORTANT: Consumers of this event should be aware that the event is fired from a blocking context (worker thread), + * and thus can perform blocking operations. + * + * @param name the name of the certificate (as configured in the configuration, {@code } for the default certificate) + * @param tlsConfiguration the updated TLS configuration - the certificate has already been updated + */ +public record CertificateUpdatedEvent(String name, TlsConfiguration tlsConfiguration) { + +} diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java index 8ac43e481142c..fb3912bc267f3 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java @@ -8,6 +8,7 @@ import java.util.function.Supplier; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; @@ -25,6 +26,7 @@ public class CertificateRecorder implements TlsConfigurationRegistry { private final Map certificates = new ConcurrentHashMap<>(); + private volatile TlsCertificateUpdater reloader; /** * Validate the certificate configuration. @@ -35,7 +37,7 @@ public class CertificateRecorder implements TlsConfigurationRegistry { * @param config the configuration * @param vertx the Vert.x instance */ - public void validateCertificates(TlsConfig config, RuntimeValue vertx) { + public void validateCertificates(TlsConfig config, RuntimeValue vertx, ShutdownContext shutdownContext) { // Verify the default config if (config.defaultCertificateConfig().isPresent()) { verifyCertificateConfig(config.defaultCertificateConfig().get(), vertx.getValue(), TlsConfig.DEFAULT_NAME); @@ -45,6 +47,15 @@ public void validateCertificates(TlsConfig config, RuntimeValue vertx) { for (String name : config.namedCertificateConfig().keySet()) { verifyCertificateConfig(config.namedCertificateConfig().get(name), vertx.getValue(), name); } + + shutdownContext.addShutdownTask(new Runnable() { + @Override + public void run() { + if (reloader != null) { + reloader.close(); + } + } + }); } public void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String name) { @@ -55,7 +66,7 @@ public void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String KeyStoreConfig keyStoreConfig = config.keyStore().get(); ks = verifyKeyStore(keyStoreConfig, vertx, name); sni = keyStoreConfig.sni(); - if (sni) { + if (sni && ks != null) { try { if (Collections.list(ks.keyStore.aliases()).size() <= 1) { throw new IllegalStateException( @@ -81,6 +92,14 @@ public void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String } certificates.put(name, new VertxCertificateHolder(vertx, name, config, ks, ts)); + + // Handle reloading if needed + if (config.reloadPeriod().isPresent()) { + if (reloader == null) { + reloader = new TlsCertificateUpdater(vertx); + } + reloader.add(name, certificates.get(name), config.reloadPeriod().get()); + } } public static KeyStoreAndKeyCertOptions verifyKeyStore(KeyStoreConfig config, Vertx vertx, String name) { diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/TlsCertificateUpdater.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/TlsCertificateUpdater.java new file mode 100644 index 0000000000000..1e9296ad3974a --- /dev/null +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/TlsCertificateUpdater.java @@ -0,0 +1,61 @@ +package io.quarkus.tls.runtime; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.enterprise.event.Event; +import jakarta.enterprise.inject.spi.CDI; + +import io.quarkus.tls.CertificateUpdatedEvent; +import io.quarkus.tls.TlsConfiguration; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; + +/** + * A helper class that reload the TLS certificates at a configured interval. + * When the certificate is reloaded, a {@link CertificateUpdatedEvent} is fired. + */ +public class TlsCertificateUpdater { + + private final Vertx vertx; + private final CopyOnWriteArrayList tasks; + private final Event event; + + public TlsCertificateUpdater(Vertx vertx) { + this.vertx = vertx; + this.tasks = new CopyOnWriteArrayList<>(); + this.event = CDI.current().getBeanManager().getEvent().select(CertificateUpdatedEvent.class); + } + + public void close() { + for (Long task : tasks) { + vertx.cancelTimer(task); + } + tasks.clear(); + } + + public void add(String name, TlsConfiguration tlsConfiguration, Duration period) { + var id = vertx.setPeriodic(period.toMillis(), new Handler() { + @Override + public void handle(Long id) { + vertx.executeBlocking(new Callable() { + @Override + public Void call() { + // Reload is most probably a blocking operation as it needs to reload the certificate from the + // file system. Thus, it is executed in a blocking context. + // Then we fire the event. This is also potentially blocking, as the consumer are invoked on the + // same thread. + if (tlsConfiguration.reload()) { + event.fire(new CertificateUpdatedEvent(name, tlsConfiguration)); + } + return null; + } + }, false); + } + }); + + tasks.add(id); + } + +} diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TlsBucketConfig.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TlsBucketConfig.java index 48fe8e5b9f49b..a45c6aa0534be 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TlsBucketConfig.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/config/TlsBucketConfig.java @@ -7,6 +7,7 @@ import java.util.Set; import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.tls.CertificateUpdatedEvent; import io.smallrye.config.WithDefault; @ConfigGroup @@ -106,4 +107,19 @@ public interface TlsBucketConfig { */ Optional hostnameVerificationAlgorithm(); + /** + * When configured, the server will reload the certificates (from the file system for example) and fires a + * {@link CertificateUpdatedEvent} if the reload is successful + *

+ * This property configures the period to reload the certificates. IF not set, the certificates won't be reloaded + * automatically. + * However, the application can still trigger the reload manually using the {@link io.quarkus.tls.TlsConfiguration#reload()} + * method, + * and then fire the {@link CertificateUpdatedEvent} manually. + *

+ * The fired event is used to notify the application that the certificates have been updated, and thus proceed with the + * actual switch of certificates. + */ + Optional reloadPeriod(); + } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java index 335899e3fb120..495e331d6257a 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/VertxHttpProcessor.java @@ -68,6 +68,7 @@ import io.quarkus.vertx.http.runtime.CurrentRequestProducer; import io.quarkus.vertx.http.runtime.CurrentVertxRequest; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpCertificateUpdateEventListener; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.VertxConfigBuilder; import io.quarkus.vertx.http.runtime.VertxHttpRecorder; @@ -171,6 +172,7 @@ AdditionalBeanBuildItem additionalBeans() { .setUnremovable() .addBeanClass(CurrentVertxRequest.class) .addBeanClass(CurrentRequestProducer.class) + .addBeanClass(HttpCertificateUpdateEventListener.class) .build(); } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadWithTlsRegistryAndUpdateEventTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadWithTlsRegistryAndUpdateEventTest.java new file mode 100644 index 0000000000000..688b0855c1e06 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadWithTlsRegistryAndUpdateEventTest.java @@ -0,0 +1,170 @@ +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.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.enterprise.event.Event; +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.tls.CertificateUpdatedEvent; +import io.quarkus.tls.TlsConfigurationRegistry; +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 MainHttpServerTlsCertificateReloadWithTlsRegistryAndUpdateEventTest { + + @TestHTTPResource(value = "/hello", tls = 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.tls.key-store.pem.0.cert", temp.getAbsolutePath() + "/tls.crt") + .overrideConfigKey("quarkus.tls.key-store.pem.0.key", 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; + + @Inject + Event event; + + @Inject + TlsConfigurationRegistry registry; + + @Test + void test() throws IOException, ExecutionException, InterruptedException, TimeoutException { + 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 + registry.getDefault().orElseThrow().reload(); + event.fire(new CertificateUpdatedEvent("", registry.getDefault().orElseThrow())); + + // 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 + registry.getDefault().orElseThrow().reload(); + event.fire(new CertificateUpdatedEvent("", registry.getDefault().orElseThrow())); + + 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/MainHttpServerTlsPKCS12CertificateReloadWithTlsRegistryAndUpdateEventTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadWithTlsRegistryAndUpdateEventTest.java new file mode 100644 index 0000000000000..8c178851a02cc --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsPKCS12CertificateReloadWithTlsRegistryAndUpdateEventTest.java @@ -0,0 +1,167 @@ +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.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.enterprise.event.Event; +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.tls.CertificateUpdatedEvent; +import io.quarkus.tls.TlsConfigurationRegistry; +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 MainHttpServerTlsPKCS12CertificateReloadWithTlsRegistryAndUpdateEventTest { + + @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.insecure-requests", "redirect") + .overrideConfigKey("quarkus.http.tls-configuration-name", "http") + + .overrideConfigKey("quarkus.tls.http.key-store.p12.path", temp.getAbsolutePath() + "/tls.p12") + .overrideConfigKey("quarkus.tls.http.key-store.p12.password", "password") + + .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; + + @Inject + TlsConfigurationRegistry registry; + + @Inject + Event event; + + @Test + void test() throws IOException, ExecutionException, InterruptedException, TimeoutException { + 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 + registry.get("http").orElseThrow().reload(); + event.fire(new CertificateUpdatedEvent("http", registry.get("http").orElseThrow())); + + // 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 + registry.get("http").orElseThrow().reload(); + event.fire(new CertificateUpdatedEvent("http", registry.get("http").orElseThrow())); + + 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/ManagementHttpServerTlsCertificateReloadWithTlsRegistryTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadWithTlsRegistryTest.java index 6393e45eea48e..6864fcfa75aa1 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadWithTlsRegistryTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/ManagementHttpServerTlsCertificateReloadWithTlsRegistryTest.java @@ -9,12 +9,12 @@ import java.security.cert.X509Certificate; import java.util.UUID; import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import javax.net.ssl.SSLHandshakeException; +import jakarta.enterprise.event.Event; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -28,9 +28,10 @@ import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.tls.CertificateUpdatedEvent; +import io.quarkus.tls.TlsConfigurationRegistry; 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; @@ -55,7 +56,6 @@ public class ManagementHttpServerTlsCertificateReloadWithTlsRegistryTest { private static final String APP_PROPS = """ quarkus.management.enabled=true - quarkus.management.ssl.certificate.reload-period=30s quarkus.tls.key-store.pem.0.cert=%s quarkus.tls.key-store.pem.0.key=%s loc=%s @@ -118,6 +118,12 @@ public void execute(BuildContext context) { @ConfigProperty(name = "loc") File certs; + @Inject + TlsConfigurationRegistry registry; + + @Inject + Event event; + @Test void test() throws IOException, ExecutionException, InterruptedException, TimeoutException { var options = new HttpClientOptions() @@ -140,7 +146,8 @@ void test() throws IOException, ExecutionException, InterruptedException, Timeou new File(certs, "/tls.key").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); // Trigger the reload - TlsCertificateReloader.reload().toCompletableFuture().get(10, TimeUnit.SECONDS); + registry.getDefault().orElseThrow().reload(); + event.fire(new CertificateUpdatedEvent("", registry.getDefault().orElseThrow())); // The client truststore is not updated, thus it should fail. assertThatThrownBy(() -> vertx.createHttpClient(options) diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java index c5048503200cd..f2d2685d0db86 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java @@ -220,6 +220,9 @@ public class CertificateConfig { * Also, the update can also occur when the TLS certificate is configured using paths (and not in-memory). *

* The reload period must be equal or greater than 30 seconds. If not set, the certificate will not be reloaded. + *

+ * IMPORTANT: It's recommended to use the TLS registry to handle the certificate reloading. + *

*/ @ConfigItem public Optional reloadPeriod; diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCertificateUpdateEventListener.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCertificateUpdateEventListener.java new file mode 100644 index 0000000000000..fe6fa88f02489 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCertificateUpdateEventListener.java @@ -0,0 +1,52 @@ +package io.quarkus.vertx.http.runtime; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; + +import org.jboss.logging.Logger; + +import io.quarkus.tls.CertificateUpdatedEvent; +import io.vertx.core.http.HttpServer; + +/** + * A listener that listens for certificate updates and updates the HTTP server accordingly. + */ +@Singleton +public class HttpCertificateUpdateEventListener { + + private final static Logger LOG = Logger.getLogger(HttpCertificateUpdateEventListener.class); + private final List servers = new CopyOnWriteArrayList<>(); + + record ServerRegistration(HttpServer server, String tlsConfigurationName, String id) { + + } + + public void register(HttpServer server, String tlsConfigurationName, String id) { + servers.add(new ServerRegistration(server, tlsConfigurationName, id)); + } + + public void onCertificateUpdate(@Observes CertificateUpdatedEvent event) { + for (ServerRegistration server : servers) { + if (server.tlsConfigurationName.equalsIgnoreCase(event.name())) { + server.server.updateSSLOptions(event.tlsConfiguration().getSSLOptions()) + .toCompletionStage().whenComplete(new BiConsumer() { + @Override + public void accept(Boolean v, Throwable t) { + if (t == null) { + LOG.infof("The TLS configuration `%s` used by the HTTP server `%s` has been updated", + event.name(), server.id); + } else { + LOG.warnf(t, "Failed to update TLS configuration `%s` for the HTTP server `%s`", + event.name(), + server.id); + } + } + }); + } + } + } +} 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 5529a6e3b95ee..0b91740c6100b 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 @@ -34,6 +34,7 @@ import jakarta.enterprise.event.Event; import jakarta.enterprise.inject.Default; +import jakarta.enterprise.inject.spi.CDI; import org.crac.Resource; import org.jboss.logging.Logger; @@ -65,6 +66,7 @@ import io.quarkus.runtime.configuration.MemorySize; import io.quarkus.runtime.shutdown.ShutdownConfig; import io.quarkus.tls.TlsConfigurationRegistry; +import io.quarkus.tls.runtime.config.TlsConfig; import io.quarkus.vertx.core.runtime.VertxCoreRecorder; import io.quarkus.vertx.core.runtime.config.VertxConfiguration; import io.quarkus.vertx.http.HttpServerOptionsCustomizer; @@ -682,6 +684,13 @@ private static CompletableFuture initializeManagementInterface(Vertx } } + if (httpManagementServerOptions.isSsl()) { + CDI.current().select(HttpCertificateUpdateEventListener.class).get() + .register(ar.result(), + managementConfig.tlsConfigurationName.orElse(TlsConfig.DEFAULT_NAME), + "management interface"); + } + actualManagementPort = ar.result().actualPort(); if (actualManagementPort != httpManagementServerOptions.getPort()) { var managementPortSystemProperties = new PortSystemProperties(); @@ -1294,6 +1303,12 @@ public void handle(AsyncResult event) { } } + if (https) { + CDI.current().select(HttpCertificateUpdateEventListener.class).get() + .register(event.result(), quarkusConfig.tlsConfigurationName.orElse(TlsConfig.DEFAULT_NAME), + "http server"); + } + if (remainingCount.decrementAndGet() == 0) { //make sure we only complete once startFuture.complete(null); From 32fc596164d2a39ad00156417db263061679c7e2 Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Thu, 27 Jun 2024 08:43:52 +0200 Subject: [PATCH 2/2] Document how to use Kubernetes secret and the cert-manager --- .../main/asciidoc/tls-registry-reference.adoc | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/tls-registry-reference.adoc b/docs/src/main/asciidoc/tls-registry-reference.adoc index 8f5e55be81a33..94cd3a6babf99 100644 --- a/docs/src/main/asciidoc/tls-registry-reference.adoc +++ b/docs/src/main/asciidoc/tls-registry-reference.adoc @@ -579,4 +579,117 @@ quarkus.tls.http.key-store.pem.0.key=tls.key ---- Remember that the impacted server and client may need to listen to the `CertificateReloadedEvent` to apply the new certificates. -This is automatically done for the Quarkus HTTP server (including the management interface if enabled). \ No newline at end of file +This is automatically done for the Quarkus HTTP server (including the management interface if enabled). + +== Using Kubernetes secrets or cert-manager + +When running in Kubernetes, you can use Kubernetes secrets to store the key stores and trust stores. + +=== Using Kubernetes secrets + +To use Kubernetes secrets, you need to create a secret with the key stores and trust stores. +Let's take the following secret as an example: + +[source, yaml] +---- +apiVersion: v1 +data: + tls.crt: ... + tls.key: ... +kind: Secret +metadata: + name: my-certs +type: kubernetes.io/tls +---- + +The easiest way to uses these certificates is to mount the secret as a volume in the pod: + +[source, yaml] +---- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: demo + app.kubernetes.io/version: 1.0.0-SNAPSHOT + app.kubernetes.io/managed-by: quarkus + name: demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: demo + app.kubernetes.io/version: 1.0.0-SNAPSHOT + template: + metadata: + labels: + app.kubernetes.io/managed-by: quarkus + app.kubernetes.io/name: demo + app.kubernetes.io/version: 1.0.0-SNAPSHOT + spec: + containers: + - env: + - name: KUBERNETES_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + image: ... + imagePullPolicy: IfNotPresent + name: demo + ports: + - containerPort: 8443 # Configure the port to be HTTPS + name: http + protocol: TCP + volumeMounts: + - mountPath: /certs + name: my-volume + volumes: + - name: my-volume + secret: + defaultMode: 0666 # Set the permissions, otherwise the pod may not be able to read the files + optional: false + secretName: my-certs # Reference the secret +---- + +Then, you can configure the TLS registry to use the certificates: + +[source, properties] +---- +# ... +# TLS Registry configuration +%prod.quarkus.tls.http.key-store.pem.0.cert=/certs/tls.crt +%prod.quarkus.tls.http.key-store.pem.0.key=/certs/tls.key + +# HTTP server configuration: +%prod.quarkus.http.tls-configuration-name=http +%prod.quarkus.http.insecure-requests=disabled +---- + +You can combine this with the periodic reloading to automatically reload the certificates when they change. + +=== Using cert-manager + +When running in Kubernetes, you can use cert-manager to automatically generate and renew certificates. +Cert-manager will produce a secret with the key stores and trust stores. +So, configuring the TLS registry is the same as when using Kubernetes secrets. +The generated secret uses the following files: + +- `tls.crt` for the certificate +- `tls.key` for the private key +- `ca.crt` for the CA certificate (if needed) + +To handle the renewal, you can use the periodic reloading mechanism: + +[source, properties] +---- +# ... +# TLS Registry configuration +%prod.quarkus.tls.http.key-store.pem.0.cert=/certs/tls.crt +%prod.quarkus.tls.http.key-store.pem.0.key=/certs/tls.key +%prod.quarkus.tls.http.reload-period=24h + +# HTTP server configuration: +%prod.quarkus.http.tls-configuration-name=http +%prod.quarkus.http.insecure-requests=disabled +---- +