From 136f09b03eacf9ac61b9989a69c125e56eba514b Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Wed, 17 Jul 2024 13:54:28 +0200 Subject: [PATCH 1/3] Implement the runtime part of the Let's Encrypt support - Expose HTTP endpoints to set up and serve the Let's Encrypt HTTP_01 challenge - Expose an endpoint to request the reloading of the certificates once the challenge is completed and the certificate retrieved --- .../tls/cli/helpers/LetsEncryptHelpers.java | 33 ++ extensions/tls-registry/deployment/pom.xml | 6 + .../io/quarkus/tls/CertificatesProcessor.java | 34 +++ .../tls/LetsEncryptBuildTimeConfig.java | 18 ++ .../io/quarkus/tls/LetsEncryptEnabled.java | 18 ++ extensions/tls-registry/runtime/pom.xml | 7 + .../tls/runtime/LetsEncryptRecorder.java | 288 ++++++++++++++++++ ...LetEncryptReadyAndReloadEndpointsTest.java | 129 ++++++++ .../tls/letsencrypt/LetsEncryptFlowTest.java | 123 ++++++++ .../letsencrypt/LetsEncryptFlowTestBase.java | 216 +++++++++++++ ...ncryptFlowWithManagementInterfaceTest.java | 124 ++++++++ ...cryptFlowWithTlsConfigurationNameTest.java | 125 ++++++++ .../NoLetEncryptDisableRoutesTest.java | 118 +++++++ .../http/tls/letsencrypt/package-info.java | 6 + .../HttpCertificateUpdateEventListener.java | 41 ++- 15 files changed, 1271 insertions(+), 15 deletions(-) create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java create mode 100644 extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptBuildTimeConfig.java create mode 100644 extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptEnabled.java create mode 100644 extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/LetsEncryptRecorder.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetEncryptReadyAndReloadEndpointsTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTestBase.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithManagementInterfaceTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithTlsConfigurationNameTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/NoLetEncryptDisableRoutesTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/package-info.java diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java new file mode 100644 index 0000000000000..6edd5f828a92d --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java @@ -0,0 +1,33 @@ +package io.quarkus.tls.cli.helpers; + +import java.io.File; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +import io.smallrye.certs.CertificateUtils; + +public class LetsEncryptHelpers { + + public static void writePrivateKeyAndCertificateChainsAsPem(PrivateKey pk, X509Certificate[] chain, File privateKeyFile, + File certificateChainFile) throws Exception { + if (pk == null) { + throw new IllegalArgumentException("The private key cannot be null"); + } + if (chain == null || chain.length == 0) { + throw new IllegalArgumentException("The certificate chain cannot be null or empty"); + } + + CertificateUtils.writePrivateKeyToPem(pk, privateKeyFile); + + if (chain.length == 1) { + CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile); + return; + } + + // For some reason the method from CertificateUtils distinguishes the first certificate and the rest of the chain + X509Certificate[] restOfTheChain = new X509Certificate[chain.length - 1]; + System.arraycopy(chain, 1, restOfTheChain, 0, chain.length - 1); + CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile, restOfTheChain); + } + +} diff --git a/extensions/tls-registry/deployment/pom.xml b/extensions/tls-registry/deployment/pom.xml index 35cb957fad7bb..55311ab84b370 100644 --- a/extensions/tls-registry/deployment/pom.xml +++ b/extensions/tls-registry/deployment/pom.xml @@ -31,6 +31,12 @@ quarkus-tls-registry + + + io.quarkus + quarkus-vertx-http-deployment-spi + + io.quarkus 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 310f886adbcb2..947a456e7bc7c 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 @@ -7,14 +7,18 @@ import jakarta.inject.Singleton; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.deployment.annotations.BuildProducer; 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.runtime.configuration.ConfigurationException; import io.quarkus.tls.runtime.CertificateRecorder; +import io.quarkus.tls.runtime.LetsEncryptRecorder; import io.quarkus.tls.runtime.config.TlsConfig; import io.quarkus.vertx.deployment.VertxBuildItem; +import io.quarkus.vertx.http.deployment.spi.RouteBuildItem; public class CertificatesProcessor { @@ -48,4 +52,34 @@ public TlsRegistryBuildItem initializeCertificate( return new TlsRegistryBuildItem(supplier); } + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep(onlyIf = LetsEncryptEnabled.class) + void createManagementRoutes(BuildProducer routes, + LetsEncryptRecorder recorder, + TlsRegistryBuildItem registryBuildItem) { + + // Check if Vert.x Web is present + if (!QuarkusClassLoader.isClassPresentAtRuntime("io.vertx.ext.web.Router")) { + throw new ConfigurationException("Cannot use Let's Encrypt without the quarkus-vertx-http extension"); + } + + recorder.initialize(registryBuildItem.registry()); + + // Route to handle the Let's Encrypt challenge - primary HTTP server + routes.produce(RouteBuildItem.newAbsoluteRoute("/.well-known/acme-challenge/:token") + .withRequestHandler(recorder.challengeHandler()) + .build()); + + // Route to configure the Let's Encrypt challenge - management server + routes.produce(RouteBuildItem.newManagementRoute("lets-encrypt/challenge") + .withRequestHandler(recorder.chalengeAdminHandler()) + .withRouteCustomizer(recorder.setupCustomizer()) + .build()); + + // Route to refresh the certificates - management server + routes.produce(RouteBuildItem.newManagementRoute("lets-encrypt/certs") + .withRequestHandler(recorder.reload()) + .build()); + } + } diff --git a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptBuildTimeConfig.java b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptBuildTimeConfig.java new file mode 100644 index 0000000000000..c78eed8c59a6d --- /dev/null +++ b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptBuildTimeConfig.java @@ -0,0 +1,18 @@ +package io.quarkus.tls; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.tls.lets-encrypt") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface LetsEncryptBuildTimeConfig { + + /** + * Set to {@code true} to enable let's encrypt support. + */ + @WithDefault("false") + boolean enabled(); + +} diff --git a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptEnabled.java b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptEnabled.java new file mode 100644 index 0000000000000..480b3d9456afb --- /dev/null +++ b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/LetsEncryptEnabled.java @@ -0,0 +1,18 @@ +package io.quarkus.tls; + +import java.util.function.BooleanSupplier; + +public class LetsEncryptEnabled implements BooleanSupplier { + + private final LetsEncryptBuildTimeConfig config; + + LetsEncryptEnabled(LetsEncryptBuildTimeConfig config) { + this.config = config; + } + + @Override + public boolean getAsBoolean() { + return config.enabled(); + } + +} diff --git a/extensions/tls-registry/runtime/pom.xml b/extensions/tls-registry/runtime/pom.xml index 9b7fd048a137b..459055fe356c5 100644 --- a/extensions/tls-registry/runtime/pom.xml +++ b/extensions/tls-registry/runtime/pom.xml @@ -26,6 +26,13 @@ io.quarkus quarkus-credentials + + + + io.vertx + vertx-web + true + diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/LetsEncryptRecorder.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/LetsEncryptRecorder.java new file mode 100644 index 0000000000000..a88d576dba0d7 --- /dev/null +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/LetsEncryptRecorder.java @@ -0,0 +1,288 @@ +package io.quarkus.tls.runtime; + +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import jakarta.enterprise.event.Event; +import jakarta.enterprise.inject.spi.CDI; + +import org.jboss.logging.Logger; + +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.tls.CertificateUpdatedEvent; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; +import io.quarkus.tls.runtime.config.TlsConfig; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.RoutingContext; + +/** + * Recorder for Let's Encrypt support. + */ +@Recorder +public class LetsEncryptRecorder { + + private TlsConfigurationRegistry registry; + private Event event; + + private final AtomicReference acmeChallenge = new AtomicReference<>(); + + private static final Logger LOGGER = Logger.getLogger(LetsEncryptRecorder.class); + + public void initialize(Supplier registry) { + this.registry = registry.get(); + this.event = CDI.current().getBeanManager().getEvent().select(CertificateUpdatedEvent.class); + } + + /** + * Represents an ACME HTTP_01 Challenge. + *

+ * Instances are generally created from JSON objects containing the `challenge-resource` (the token) and `challenge-content` + * fields. + * If the token or the challenge is null, the challenge is considered invalid. + * + * @param token the token - part of the URL to verify the challenge + * @param challenge the challenge to be served + */ + private record AcmeChallenge(String token, String challenge) { + boolean matches(String t) { + return token.equals(t); + } + + boolean isValid() { + return token != null && challenge != null; + } + + public String asJson() { + return new JsonObject().put("challenge-resource", token).put("challenge-content", challenge).encode(); + } + + public static AcmeChallenge fromJson(JsonObject json) { + return new AcmeChallenge(json.getString("challenge-resource"), json.getString("challenge-content")); + } + + } + + /** + * Returns a handler that serves the Let's Encrypt challenge. + * The route only accept `GET` request. + * If a challenge has not been set, it returns a 404 status code. + * If a challenge has been set, it returns the challenge content if the token matches, otherwise it returns a 404 status + * code. + * + * @return the handler that serves the Let's Encrypt challenge, returns a 404 status code if the challenge is not set. + */ + public Handler challengeHandler() { + return new Handler() { + @Override + public void handle(RoutingContext rc) { + if (rc.request().method() != HttpMethod.GET) { + rc.response().setStatusCode(405).end(); + return; + } + String token = rc.pathParam("token"); + if (token == null) { + rc.response().setStatusCode(404).end(); + return; + } + + AcmeChallenge challenge = acmeChallenge.get(); + + if (challenge == null) { + LOGGER.debug("No Let's Encrypt challenge has been set"); + rc.response().setStatusCode(404).end(); + return; + } + + if (challenge.matches(token)) { + rc.response().end(challenge.challenge()); + } else { + rc.response().setStatusCode(404).end(); + } + } + }; + } + + /** + * Cleans up the ACME Challenge. + *

+ * If the challenge has not been set or has already being cleared, it returns a 404 status code. + * Otherwise, it clears the challenge and returns a 204 status code. + * + * @param rc the routing context + */ + public void cleanupChallenge(RoutingContext rc) { + if (acmeChallenge.getAndSet(null) == null) { + rc.response().setStatusCode(404).end(); + } else { + rc.response().setStatusCode(204).end(); + } + } + + /** + * Set up the ACME HTTP 01 Challenge. + *

+ * The body of the incoming request contains the challenge to be served as a JSON object containing the + * `challenge-resource` and `challenge-content` fields. + *

+ *

+ * Returns a 204 status code if the challenge has been set. + * Returns a 400 status code if the challenge is already set or the challenge is invalid. + *

+ * + * @param rc the routing context + */ + private void setupChallenge(RoutingContext rc) { + AcmeChallenge challenge; + if (rc.request().method() == HttpMethod.POST) { + challenge = AcmeChallenge.fromJson(rc.body().asJsonObject()); + } else { + String token = rc.request().getParam("challenge-resource"); + String challengeContent = rc.request().getParam("challenge-content"); + challenge = new AcmeChallenge(token, challengeContent); + } + if (!challenge.isValid()) { + LOGGER.warn("Invalid Let's Encrypt challenge: " + rc.body().asJsonObject()); + rc.response().setStatusCode(400).end(); + } else if (acmeChallenge.compareAndSet(null, challenge)) { + rc.response().setStatusCode(204).end(); + } else { + LOGGER.warn("Let's Encrypt challenge already set"); + rc.response().setStatusCode(400).end(); + } + } + + /** + * Checks if the application is configured correctly to serve the Let's Encrypt challenge. + *

+ * It verifies that the application is configured to use HTTPS (either using the default configuration) or using + * the TLS configuration with the name indicated with the `key` query parameter. + *

+ *

+ * Returns a 204 status code if the application is ready to serve the challenge (but the challenge is not yet configured), + * and if the application is configured properly. + * Returns a 200 status code if the challenge is already set, the response body contains the ACME challenge JSON + * representation (containing the `challenge-resource` and `challenge-content` fields). + * Returns a 503 status code if the application is not configured properly. + *

+ * + * @param rc the routing context + */ + public void ready(RoutingContext rc) { + String key = rc.request().getParam("key"); + TlsConfiguration config; + if (key == null) { + key = TlsConfig.DEFAULT_NAME; + config = registry.getDefault().orElse(null); + if (config == null) { + LOGGER.warn( + "Cannot handle Let's Encrypt flow - No default TLS configuration found. You must configure the quarkus.tls.* properties."); + rc.response().setStatusCode(503).end(); + return; + } + } else { + config = registry.get(key).orElse(null); + if (config == null) { + LOGGER.warn("Cannot handle Let's Encrypt flow - No " + key + + " TLS configuration found. You must configure the quarkus.tls." + key + ".* properties."); + rc.response().setStatusCode(503).end(); + return; + } + } + + // Check that the key store is set. + if (config.getKeyStore() == null) { + LOGGER.warn("Cannot handle Let's Encrypt flow - No keystore configured in quarkus.tls." + + (key.equalsIgnoreCase(TlsConfig.DEFAULT_NAME) ? "" : key) + ".key-store"); + rc.response().setStatusCode(503).end(); + return; + } + + // All good + AcmeChallenge challenge = acmeChallenge.get(); + if (challenge == null) { + rc.response().setStatusCode(204).end(); + } else { + rc.response().end(challenge.asJson()); + } + } + + public Handler reload() { + // Registered as a blocking route, so we can fire the reload event in the same thread. + return new Handler() { + @Override + public void handle(RoutingContext rc) { + if (rc.request().method() != HttpMethod.POST) { + rc.response().setStatusCode(405).end(); + return; + } + + Optional configuration; + String key = rc.request().getParam("key"); + if (key != null) { + configuration = registry.get(key); + } else { + configuration = registry.getDefault(); + } + + if (configuration.isEmpty()) { + LOGGER.warn("Cannot reload certificate, no configuration found for " + + (key == null ? "quarkus.tls" : "quarkus.tls." + key)); + rc.response().setStatusCode(404).end(); + } else { + rc.vertx(). executeBlocking(new Callable() { + @Override + public Void call() { + if (configuration.get().reload()) { + event.fire(new CertificateUpdatedEvent((key == null ? TlsConfig.DEFAULT_NAME : key), + configuration.get())); + rc.response().setStatusCode(204).end(); + } else { + LOGGER.error("Failed to reload certificate"); + rc.response().setStatusCode(500).end(); + } + return null; + } + }, false); + } + } + }; + } + + public Consumer setupCustomizer() { + return new Consumer() { + @Override + public void accept(Route r) { + r.method(HttpMethod.POST).method(HttpMethod.GET).method(HttpMethod.DELETE); + } + }; + } + + public Handler chalengeAdminHandler() { + return new Handler() { + @Override + public void handle(RoutingContext rc) { + if (rc.request().method() == HttpMethod.POST) { + setupChallenge(rc); + } else if (rc.request().method() == HttpMethod.DELETE) { + cleanupChallenge(rc); + } else if (rc.request().method() == HttpMethod.GET) { + if (rc.request().getParam("challenge-resource") != null) { + // Alternative upload method + setupChallenge(rc); + } else { + ready(rc); + } + } else { + rc.response().setStatusCode(405).end(); + } + } + }; + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetEncryptReadyAndReloadEndpointsTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetEncryptReadyAndReloadEndpointsTest.java new file mode 100644 index 0000000000000..5a182750b3a44 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetEncryptReadyAndReloadEndpointsTest.java @@ -0,0 +1,129 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import static io.quarkus.vertx.http.tls.letsencrypt.LetsEncryptFlowTestBase.SELF_SIGNED_CA; +import static io.quarkus.vertx.http.tls.letsencrypt.LetsEncryptFlowTestBase.SELF_SIGNED_CERT; +import static io.quarkus.vertx.http.tls.letsencrypt.LetsEncryptFlowTestBase.SELF_SIGNED_KEY; +import static io.quarkus.vertx.http.tls.letsencrypt.LetsEncryptFlowTestBase.await; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.assertj.core.api.Assertions; +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.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +@Certificates(baseDir = "target/certs/lets-encrypt", certificates = { + @Certificate(name = "self-signed", formats = { Format.PEM }), // Initial certificate + @Certificate(name = "acme", formats = { Format.PEM }, duration = 365) // ACME certificate (fake) +}) +@DisabledOnOs(OS.WINDOWS) +public class LetEncryptReadyAndReloadEndpointsTest { + + private static final String configuration = """ + # Configuration foo is ready + quarkus.tls.foo.key-store.pem.0.cert=%s + quarkus.tls.foo.key-store.pem.0.key=%s + + # Default configuration is not ready + quarkus.tls.trust-all=true + + # Configuration bar is not ready + quarkus.tls.bar.trust-all=true + + quarkus.tls.lets-encrypt.enabled=true + """.formatted(SELF_SIGNED_CERT, SELF_SIGNED_KEY); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MyBean.class, LetsEncryptFlowTestBase.class) + .addAsResource(new StringAsset((configuration)), "application.properties")); + + @Inject + Vertx vertx; + + @TestHTTPResource(value = "/q/lets-encrypt/challenge") + String management; + + @TestHTTPResource(value = "/q/lets-encrypt/certs") + String reload; + + @TestHTTPResource(value = "/hello") + String endpoint; + + @Test + void verifyReadyConfiguration() { + WebClientOptions options = new WebClientOptions().setSsl(true) + .setTrustOptions(new PemTrustOptions().addCertPath(SELF_SIGNED_CA.getAbsolutePath())); + WebClient client = WebClient.create(vertx, options); + + // Verify the application is serving the application + HttpResponse response = await(client.getAbs(endpoint).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + + // Verify that the default configuration is not ready (no key store) + response = await(client.getAbs(management).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(503); + + // Verify that the foo configuration is ready + response = await(client.getAbs(management + "/?key=foo").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + // Verify that the bar configuration is not ready + response = await(client.getAbs(management + "/?key=bar").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(503); + + // Verify that the missing configuration is not ready + response = await(client.getAbs(management + "/?key=missing").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(503); + } + + @Test + void verifyReload() { + WebClientOptions options = new WebClientOptions().setSsl(true) + .setTrustOptions(new PemTrustOptions().addCertPath(SELF_SIGNED_CA.getAbsolutePath())); + WebClient client = WebClient.create(vertx, options); + // Reload default + HttpResponse response = await(client.postAbs(reload).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(500); // Cannot reload certificate, none set (not ready) + + // Reload foo + response = await(client.postAbs(reload + "/?key=foo").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + response = await(client.postAbs(reload + "/?key=bar").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(500); // Cannot reload certificate, none set (not ready) + + // Reload missing + response = await(client.postAbs(reload + "/?key=missing").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + } + + @ApplicationScoped + public static class MyBean { + + public void register(@Observes Router router) { + router.get("/hello").handler(rc -> { + rc.response().end("hello"); + }); + } + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTest.java new file mode 100644 index 0000000000000..7af2c011fe3c3 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTest.java @@ -0,0 +1,123 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +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.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; + +@Certificates(baseDir = "target/certs/lets-encrypt", certificates = { + @Certificate(name = "self-signed", formats = { Format.PEM }), // Initial certificate + @Certificate(name = "acme", formats = { Format.PEM }, duration = 365) // ACME certificate (fake) +}) +@DisabledOnOs(OS.WINDOWS) +public class LetsEncryptFlowTest extends LetsEncryptFlowTestBase { + + public static final File temp = new File("target/acme-certificates-" + UUID.randomUUID()); + + private static final String configuration = """ + # Enable SSL, configure the key store using the self-signed certificate + quarkus.tls.key-store.pem.0.cert=%s/cert.pem + quarkus.tls.key-store.pem.0.key=%s/key.pem + quarkus.tls.lets-encrypt.enabled=true + quarkus.http.insecure-requests=disabled + """.formatted(temp.getAbsolutePath(), temp.getAbsolutePath()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MyBean.class) + .addAsResource(new StringAsset((configuration)), "application.properties")) + .overrideRuntimeConfigKey("loc", temp.getAbsolutePath()) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(SELF_SIGNED_CERT.toPath(), + new File(temp, "cert.pem").toPath()); + Files.copy(SELF_SIGNED_KEY.toPath(), + new File(temp, "key.pem").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "cert.pem").toPath()); + Files.deleteIfExists(new File(temp, "key.pem").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @TestHTTPResource(value = "/tls", tls = true) + String endpoint; + + @TestHTTPResource(value = "/q/lets-encrypt/challenge", tls = true) + String management; + + @TestHTTPResource(value = "/q/lets-encrypt/certs", tls = true) + String reload; + + @TestHTTPResource(value = "/.well-known/acme-challenge", tls = true) + String challenge; + + @Test + void testFlow() throws IOException { + initFlow(vertx, null); + testLetsEncryptFlow(); + } + + @Override + void updateCerts() throws IOException { + // Replace the certs on disk + Files.copy(ACME_CERT.toPath(), + new File(certs, "cert.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(ACME_KEY.toPath(), + new File(certs, "key.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + @Override + String getApplicationEndpoint() { + return endpoint; + } + + @Override + String getLetsEncryptManagementEndpoint() { + return management; + } + + @Override + String getLetsEncryptCertsEndpoint() { + return reload; + } + + @Override + String getChallengeEndpoint() { + return challenge; + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTestBase.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTestBase.java new file mode 100644 index 0000000000000..d3cf7a0f2e3a8 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowTestBase.java @@ -0,0 +1,216 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import static io.quarkus.vertx.http.runtime.RouteConstants.ROUTE_ORDER_BODY_HANDLER; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.security.cert.X509Certificate; +import java.util.UUID; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import org.assertj.core.api.Assertions; + +import io.vertx.core.Future; +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.json.JsonObject; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.ext.web.handler.BodyHandler; + +public abstract class LetsEncryptFlowTestBase { + + static final File SELF_SIGNED_CERT = new File("target/certs/lets-encrypt/self-signed.crt"); + static final File SELF_SIGNED_KEY = new File("target/certs/lets-encrypt/self-signed.key"); + static final File SELF_SIGNED_CA = new File("target/certs/lets-encrypt/self-signed-ca.crt"); + + static final File ACME_CERT = new File("target/certs/lets-encrypt/acme.crt"); + static final File ACME_KEY = new File("target/certs/lets-encrypt/acme.key"); + static final File ACME_CA = new File("target/certs/lets-encrypt/acme-ca.crt"); + + private Vertx vertx; + private String tlsConfigurationName; + + static T await(Future future) { + return future.toCompletionStage().toCompletableFuture().join(); + } + + public void initFlow(Vertx vertx, String tlsConfigurationName) { + this.vertx = vertx; + this.tlsConfigurationName = tlsConfigurationName; + } + + abstract void updateCerts() throws IOException; + + abstract String getApplicationEndpoint(); + + abstract String getLetsEncryptManagementEndpoint(); + + abstract String getLetsEncryptCertsEndpoint(); + + abstract String getChallengeEndpoint(); + + void testLetsEncryptFlow() throws IOException { + WebClientOptions options = new WebClientOptions().setSsl(true) + .setTrustOptions(new PemTrustOptions().addCertPath(SELF_SIGNED_CA.getAbsolutePath())); + WebClient client = WebClient.create(vertx, options); + + String readyEndpoint = getLetsEncryptManagementEndpoint(); + if (tlsConfigurationName != null) { + readyEndpoint = readyEndpoint + "?key=" + tlsConfigurationName; + } + + String reloadEndpoint = getLetsEncryptCertsEndpoint(); + if (tlsConfigurationName != null) { + reloadEndpoint = reloadEndpoint + "?key=" + tlsConfigurationName; + } + + // Verify the application is serving the application + HttpResponse response = await(client.getAbs(getApplicationEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + String body = response.bodyAsString(); // We will need it later. + + // Verify if the application is ready to serve the challenge + response = await(client.getAbs(readyEndpoint).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + // Make sure invalid tokens are rejected + response = await(client.postAbs(getLetsEncryptManagementEndpoint()).sendJsonObject( + new JsonObject())); + Assertions.assertThat(response.statusCode()).isEqualTo(400); + + response = await(client.postAbs(getLetsEncryptManagementEndpoint()).sendJsonObject( + new JsonObject() + .put("challenge-content", "aaa"))); + Assertions.assertThat(response.statusCode()).isEqualTo(400); + + response = await(client.postAbs(getLetsEncryptManagementEndpoint()).sendJsonObject( + new JsonObject() + .put("challenge-resource", "aaa"))); + Assertions.assertThat(response.statusCode()).isEqualTo(400); + + // Set the challenge + String challengeContent = UUID.randomUUID().toString(); + String challengeToken = UUID.randomUUID().toString(); + response = await(client.postAbs(getLetsEncryptManagementEndpoint()).sendJsonObject( + new JsonObject() + .put("challenge-content", challengeContent) + .put("challenge-resource", challengeToken))); + + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + // Verify that the challenge is set + response = await(client.getAbs(readyEndpoint).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + Assertions.assertThat(response.bodyAsJsonObject()).isEqualTo(new JsonObject() + .put("challenge-content", challengeContent) + .put("challenge-resource", challengeToken)); + + // Make sure the challenge cannot be set again + response = await(client.postAbs(getLetsEncryptManagementEndpoint()).sendJsonObject( + new JsonObject().put("challenge-resource", "again").put("challenge-content", "again"))); + Assertions.assertThat(response.statusCode()).isEqualTo(400); + + // Verify that the let's encrypt management endpoint only support GET, POST and DELETE + // Make sure the challenge cannot be set again + response = await(client.patchAbs(getLetsEncryptManagementEndpoint()).sendBuffer(Buffer.buffer("again"))); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + + // Verify that the application is serving the challenge + response = await(client.getAbs(getChallengeEndpoint() + "/" + challengeToken).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + Assertions.assertThat(response.bodyAsString()).isEqualTo(challengeContent); + + // Verify that other path and token are not valid + response = await(client.getAbs(getChallengeEndpoint() + "/" + "whatever").send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + response = await(client.getAbs(getChallengeEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + + // Verify that only GET is supported when serving the challenge + response = await(client.postAbs(getChallengeEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + response = await(client.deleteAbs(getChallengeEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + response = await(client.postAbs(getChallengeEndpoint() + "/" + challengeToken).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + response = await(client.deleteAbs(getChallengeEndpoint() + "/" + challengeToken).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + + // Verify that the challenge can be served multiple times + response = await(client.getAbs(getChallengeEndpoint() + "/" + challengeToken).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + Assertions.assertThat(response.bodyAsString()).isEqualTo(challengeContent); + + // Replace the certs on disk + updateCerts(); + + // Clear the challenge + response = await(client.deleteAbs(getLetsEncryptManagementEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + // Check we cannot clear the challenge again + response = await(client.deleteAbs(getLetsEncryptManagementEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + + // Check we cannot retrieve the challenge again + response = await(client.getAbs(getChallengeEndpoint()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + + // Reload the certificate + response = await(client.postAbs(reloadEndpoint).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(204); + + // Verify that reload cannot be call with other verb + response = await(client.getAbs(reloadEndpoint).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + + // Verify the application is serving the new certificate + // We should not use the WebClient as the connection are still established with the old certificate. + URL url = new URL(getApplicationEndpoint()); + assertThatThrownBy(() -> vertx.createHttpClient( + new HttpClientOptions().setSsl(true).setDefaultPort(url.getPort()) + .setTrustOptions(new PemTrustOptions().addCertPath(SELF_SIGNED_CA.getAbsolutePath()))) + .request(HttpMethod.GET, "/tls") + .flatMap(HttpClientRequest::send) + .flatMap(HttpClientResponse::body) + .map(Buffer::toString) + .toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class); + + WebClient newWebClient = WebClient.create(vertx, + options.setTrustOptions(new PemTrustOptions().addCertPath(ACME_CA.getAbsolutePath()))); + String newBody = await(newWebClient.getAbs(url.toString()).send()).bodyAsString(); + Assertions.assertThat(newBody).isNotEqualTo(body); + } + + @ApplicationScoped + public static class MyBean { + + public void register(@Observes Router router) { + router.route().order(ROUTE_ORDER_BODY_HANDLER).handler(BodyHandler.create()); + router + .get("/tls").handler(rc -> { + Assertions.assertThat(rc.request().connection().isSsl()).isTrue(); + Assertions.assertThat(rc.request().isSSL()).isTrue(); + Assertions.assertThat(rc.request().connection().sslSession()).isNotNull(); + var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]) + .getNotAfter().toInstant().toEpochMilli(); + rc.response().end("expiration: " + exp); + }); + } + } + +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithManagementInterfaceTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithManagementInterfaceTest.java new file mode 100644 index 0000000000000..57dc21c4aa04e --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithManagementInterfaceTest.java @@ -0,0 +1,124 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +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.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; + +@Certificates(baseDir = "target/certs/lets-encrypt", certificates = { + @Certificate(name = "self-signed", formats = { Format.PEM }), // Initial certificate + @Certificate(name = "acme", formats = { Format.PEM }, duration = 365) // ACME certificate (fake) + +}) +@DisabledOnOs(OS.WINDOWS) +public class LetsEncryptFlowWithManagementInterfaceTest extends LetsEncryptFlowTestBase { + + public static final File temp = new File("target/acme-certificates-" + UUID.randomUUID()); + + private static final String configuration = """ + # Enable SSL, configure the key store using the self-signed certificate + quarkus.tls.key-store.pem.0.cert=%s/cert.pem + quarkus.tls.key-store.pem.0.key=%s/key.pem + quarkus.tls.lets-encrypt.enabled=true + quarkus.management.enabled=true + quarkus.http.insecure-requests=disabled + """.formatted(temp.getAbsolutePath(), temp.getAbsolutePath()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MyBean.class) + .addAsResource(new StringAsset((configuration)), "application.properties")) + .overrideRuntimeConfigKey("loc", temp.getAbsolutePath()) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(SELF_SIGNED_CERT.toPath(), + new File(temp, "cert.pem").toPath()); + Files.copy(SELF_SIGNED_KEY.toPath(), + new File(temp, "key.pem").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "cert.pem").toPath()); + Files.deleteIfExists(new File(temp, "key.pem").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @TestHTTPResource(value = "/tls", tls = true) + String endpoint; + + @TestHTTPResource(value = "/lets-encrypt/challenge", tls = true, management = true) + String management; + + @TestHTTPResource(value = "/lets-encrypt/certs", tls = true, management = true) + String reload; + + @TestHTTPResource(value = "/.well-known/acme-challenge", tls = true) + String challenge; + + @Test + void testFlow() throws IOException { + initFlow(vertx, null); + testLetsEncryptFlow(); + } + + @Override + void updateCerts() throws IOException { + // Replace the certs on disk + Files.copy(ACME_CERT.toPath(), + new File(certs, "cert.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(ACME_KEY.toPath(), + new File(certs, "key.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + @Override + String getApplicationEndpoint() { + return endpoint; + } + + @Override + String getLetsEncryptManagementEndpoint() { + return management; + } + + @Override + String getLetsEncryptCertsEndpoint() { + return reload; + } + + @Override + String getChallengeEndpoint() { + return challenge; + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithTlsConfigurationNameTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithTlsConfigurationNameTest.java new file mode 100644 index 0000000000000..c38437757c5b9 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/LetsEncryptFlowWithTlsConfigurationNameTest.java @@ -0,0 +1,125 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +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.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Vertx; + +@Certificates(baseDir = "target/certs/lets-encrypt", certificates = { + @Certificate(name = "self-signed", formats = { Format.PEM }), // Initial certificate + @Certificate(name = "acme", formats = { Format.PEM }, duration = 365) // ACME certificate (fake) + +}) +@DisabledOnOs(OS.WINDOWS) +public class LetsEncryptFlowWithTlsConfigurationNameTest extends LetsEncryptFlowTestBase { + + public static final File temp = new File("target/acme-certificates-" + UUID.randomUUID()); + + private static final String configuration = """ + # Enable SSL, configure the key store using the self-signed certificate + quarkus.tls.http.key-store.pem.0.cert=%s/cert.pem + quarkus.tls.http.key-store.pem.0.key=%s/key.pem + quarkus.tls.lets-encrypt.enabled=true + quarkus.management.enabled=true + quarkus.http.insecure-requests=disabled + quarkus.http.tls-configuration-name=http + """.formatted(temp.getAbsolutePath(), temp.getAbsolutePath()); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MyBean.class) + .addAsResource(new StringAsset((configuration)), "application.properties")) + .overrideRuntimeConfigKey("loc", temp.getAbsolutePath()) + .setBeforeAllCustomizer(() -> { + try { + // Prepare a random directory to store the certificates. + temp.mkdirs(); + Files.copy(SELF_SIGNED_CERT.toPath(), + new File(temp, "cert.pem").toPath()); + Files.copy(SELF_SIGNED_KEY.toPath(), + new File(temp, "key.pem").toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .setAfterAllCustomizer(() -> { + try { + Files.deleteIfExists(new File(temp, "cert.pem").toPath()); + Files.deleteIfExists(new File(temp, "key.pem").toPath()); + Files.deleteIfExists(temp.toPath()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + @Inject + Vertx vertx; + + @ConfigProperty(name = "loc") + File certs; + + @TestHTTPResource(value = "/tls", tls = true) + String endpoint; + + @TestHTTPResource(value = "/lets-encrypt/challenge", management = true) + String management; + + @TestHTTPResource(value = "/lets-encrypt/certs", management = true) + String reload; + + @TestHTTPResource(value = "/.well-known/acme-challenge", tls = true) + String challenge; + + @Test + void testFlow() throws IOException { + initFlow(vertx, "http"); + testLetsEncryptFlow(); + } + + @Override + void updateCerts() throws IOException { + // Replace the certs on disk + Files.copy(ACME_CERT.toPath(), + new File(certs, "cert.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(ACME_KEY.toPath(), + new File(certs, "key.pem").toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + @Override + String getApplicationEndpoint() { + return endpoint; + } + + @Override + String getLetsEncryptManagementEndpoint() { + return management; + } + + @Override + String getLetsEncryptCertsEndpoint() { + return reload; + } + + @Override + String getChallengeEndpoint() { + return challenge; + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/NoLetEncryptDisableRoutesTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/NoLetEncryptDisableRoutesTest.java new file mode 100644 index 0000000000000..843beec9571fc --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/NoLetEncryptDisableRoutesTest.java @@ -0,0 +1,118 @@ +package io.quarkus.vertx.http.tls.letsencrypt; + +import java.io.File; +import java.net.URL; +import java.security.cert.X509Certificate; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.assertj.core.api.Assertions; +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.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +/** + * Checks that no routes are exposed if Let's Encrypt is not enabled (default). + */ +@Certificates(baseDir = "target/certs/lets-encrypt", certificates = { + @Certificate(name = "self-signed", formats = { Format.PEM }), // Initial certificate + @Certificate(name = "acme", formats = { Format.PEM }, duration = 365) // ACME certificate (fake), unused in this test +}) +@DisabledOnOs(OS.WINDOWS) +public class NoLetEncryptDisableRoutesTest { + private static final File SELF_SIGNED_CERT = new File("target/certs/lets-encrypt/self-signed.crt"); + private static final File SELF_SIGNED_KEY = new File("target/certs/lets-encrypt/self-signed.key"); + private static final File SELF_SIGNED_CA = new File("target/certs/lets-encrypt/self-signed-ca.crt"); + + private static final String configuration = """ + # Enable SSL, configure the key store using the self-signed certificate + quarkus.tls.key-store.pem.0.cert=%s + quarkus.tls.key-store.pem.0.key=%s + # Let's encrypt not enabled on purpose + quarkus.http.insecure-requests=disabled + """.formatted(SELF_SIGNED_CERT, SELF_SIGNED_KEY); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MyBean.class) + .addAsResource(new StringAsset((configuration)), "application.properties")); + + @Inject + Vertx vertx; + + @TestHTTPResource(value = "/tls", tls = true) + URL url; + + @TestHTTPResource(value = "/q/lets-encrypt/challenge", tls = true) + String management; + + @TestHTTPResource(value = "/q/lets-encrypt/certs", tls = true) + String reload; + + @TestHTTPResource(value = "/.well-known/acme-challenge/whatever", tls = true) + String challenge; + + @Test + void verifyNoLetsEncryptRouteExposedIfDisabled() { + WebClientOptions options = new WebClientOptions().setSsl(true) + .setTrustOptions(new PemTrustOptions().addCertPath(SELF_SIGNED_CA.getAbsolutePath())); + WebClient client = WebClient.create(vertx, options); + + // Verify the application is serving the application + HttpResponse response = await(client.getAbs(url.toExternalForm()).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(200); + + // No management route + response = await(client.getAbs(management).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + response = await(client.postAbs(management).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + response = await(client.deleteAbs(management).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + + // No well-known route + response = await(client.getAbs(challenge).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(404); + + // No reload route + response = await(client.postAbs(reload).send()); + Assertions.assertThat(response.statusCode()).isEqualTo(405); + } + + private T await(Future future) { + return future.toCompletionStage().toCompletableFuture().join(); + } + + @ApplicationScoped + public static class MyBean { + public void register(@Observes Router router) { + router.get("/tls").handler(rc -> { + Assertions.assertThat(rc.request().connection().isSsl()).isTrue(); + Assertions.assertThat(rc.request().isSSL()).isTrue(); + Assertions.assertThat(rc.request().connection().sslSession()).isNotNull(); + var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]) + .getNotAfter().toInstant().toEpochMilli(); + rc.response().end("expiration: " + exp); + }); + } + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/package-info.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/package-info.java new file mode 100644 index 0000000000000..06afdc4d9cdef --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/tls/letsencrypt/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains tests for the let's encrypt routes defined in the TLS registry. + * Because of the Vert.x HTTP -> TLS Registry dependency, these tests are in the Vert.x HTTP module, even if the + * routes are implemented in the TLS Registry extension. + */ +package io.quarkus.vertx.http.tls.letsencrypt; \ No newline at end of file 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 index fe6fa88f02489..2530dd2fbc519 100644 --- 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 @@ -1,7 +1,9 @@ package io.quarkus.vertx.http.runtime; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.function.BiConsumer; import jakarta.enterprise.event.Observes; @@ -29,24 +31,33 @@ public void register(HttpServer server, String tlsConfigurationName, String id) servers.add(new ServerRegistration(server, tlsConfigurationName, id)); } - public void onCertificateUpdate(@Observes CertificateUpdatedEvent event) { + public void onCertificateUpdate(@Observes CertificateUpdatedEvent event) throws InterruptedException { + // Retrieve the server that uses the updated TLS configuration + List registrations = new ArrayList<>(); 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); - } - } - }); + registrations.add(server); } } + CountDownLatch latch = new CountDownLatch(registrations.size()); + for (ServerRegistration server : registrations) { + 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); + } + latch.countDown(); + } + }); + } + + latch.await(); } } From eb8a433af4fece6d71dd45ecf7590dec9b670cc3 Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Mon, 22 Jul 2024 09:02:20 +0200 Subject: [PATCH 2/3] Implement Let's Encrypt CLI commands --- bom/application/pom.xml | 5 + extensions/tls-registry/cli/pom.xml | 17 +- .../quarkus/tls/cli/LetsEncryptCommand.java | 15 + .../java/io/quarkus/tls/cli/TlsCommand.java | 1 + .../tls/cli/helpers/LetsEncryptHelpers.java | 33 --- .../tls/cli/letsencrypt/AcmeClient.java | 199 +++++++++++++ .../cli/letsencrypt/LetsEncryptConstants.java | 19 ++ .../cli/letsencrypt/LetsEncryptHelpers.java | 261 ++++++++++++++++++ .../letsencrypt/LetsEncryptIssueCommand.java | 95 +++++++ .../LetsEncryptPrepareCommand.java | 125 +++++++++ .../letsencrypt/LetsEncryptRenewCommand.java | 83 ++++++ 11 files changed, 819 insertions(+), 34 deletions(-) create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/LetsEncryptCommand.java delete mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/AcmeClient.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptConstants.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptHelpers.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptIssueCommand.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptPrepareCommand.java create mode 100644 extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptRenewCommand.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 5471e46431630..b804692cbc999 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -5115,6 +5115,11 @@ wildfly-elytron-x500-cert ${wildfly-elytron.version} + + org.wildfly.security + wildfly-elytron-x500-cert-acme + ${wildfly-elytron.version} + org.wildfly.security wildfly-elytron-credential diff --git a/extensions/tls-registry/cli/pom.xml b/extensions/tls-registry/cli/pom.xml index 063e7df687d66..5b76a26f2553d 100644 --- a/extensions/tls-registry/cli/pom.xml +++ b/extensions/tls-registry/cli/pom.xml @@ -25,6 +25,21 @@ smallrye-certificate-generator + + io.vertx + vertx-web-client + + + + org.wildfly.security + wildfly-elytron-x500-cert-acme + + + + org.eclipse.parsson + parsson + + org.junit.jupiter junit-jupiter-engine @@ -81,4 +96,4 @@
- \ No newline at end of file + diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/LetsEncryptCommand.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/LetsEncryptCommand.java new file mode 100644 index 0000000000000..13109c816ebe0 --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/LetsEncryptCommand.java @@ -0,0 +1,15 @@ +package io.quarkus.tls.cli; + +import io.quarkus.tls.cli.letsencrypt.LetsEncryptIssueCommand; +import io.quarkus.tls.cli.letsencrypt.LetsEncryptPrepareCommand; +import io.quarkus.tls.cli.letsencrypt.LetsEncryptRenewCommand; +import picocli.CommandLine; + +@CommandLine.Command(name = "lets-encrypt", sortOptions = false, header = "Prepare, generate and renew Let's Encrypt Certificates", subcommands = { + LetsEncryptPrepareCommand.class, + LetsEncryptIssueCommand.class, + LetsEncryptRenewCommand.class, +}) +public class LetsEncryptCommand { + +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/TlsCommand.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/TlsCommand.java index e002c66b2249d..5a7efe370bd93 100644 --- a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/TlsCommand.java +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/TlsCommand.java @@ -7,6 +7,7 @@ @CommandLine.Command(name = "tls", sortOptions = false, header = "Install and Manage TLS development certificates", subcommands = { GenerateCACommand.class, GenerateCertificateCommand.class, + LetsEncryptCommand.class }) public class TlsCommand implements Callable { diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java deleted file mode 100644 index 6edd5f828a92d..0000000000000 --- a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/helpers/LetsEncryptHelpers.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.quarkus.tls.cli.helpers; - -import java.io.File; -import java.security.PrivateKey; -import java.security.cert.X509Certificate; - -import io.smallrye.certs.CertificateUtils; - -public class LetsEncryptHelpers { - - public static void writePrivateKeyAndCertificateChainsAsPem(PrivateKey pk, X509Certificate[] chain, File privateKeyFile, - File certificateChainFile) throws Exception { - if (pk == null) { - throw new IllegalArgumentException("The private key cannot be null"); - } - if (chain == null || chain.length == 0) { - throw new IllegalArgumentException("The certificate chain cannot be null or empty"); - } - - CertificateUtils.writePrivateKeyToPem(pk, privateKeyFile); - - if (chain.length == 1) { - CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile); - return; - } - - // For some reason the method from CertificateUtils distinguishes the first certificate and the rest of the chain - X509Certificate[] restOfTheChain = new X509Certificate[chain.length - 1]; - System.arraycopy(chain, 1, restOfTheChain, 0, chain.length - 1); - CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile, restOfTheChain); - } - -} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/AcmeClient.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/AcmeClient.java new file mode 100644 index 0000000000000..1aef36e1cea1f --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/AcmeClient.java @@ -0,0 +1,199 @@ +package io.quarkus.tls.cli.letsencrypt; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.ERROR; +import static java.lang.System.Logger.Level.INFO; +import static java.lang.System.Logger.Level.WARNING; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.wildfly.common.Assert; +import org.wildfly.security.x500.cert.acme.AcmeAccount; +import org.wildfly.security.x500.cert.acme.AcmeChallenge; +import org.wildfly.security.x500.cert.acme.AcmeClientSpi; +import org.wildfly.security.x500.cert.acme.AcmeException; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; + +public class AcmeClient extends AcmeClientSpi { + + static System.Logger LOGGER = System.getLogger("lets-encrypt-acme-client"); + + private static final String TOKEN_REGEX = "[A-Za-z0-9_-]+"; + + private final String challengeUrl; + private final String certsUrl; + private final WebClientOptions options; + private final Vertx vertx; + + final String managementUser; + final String managementPassword; + final String managementKey; + + private final WebClient managementClient; + + public AcmeClient(String managementUrl, + String managementUser, + String managementPassword, + String managementKey) { + this.vertx = Vertx.vertx(); + LOGGER.log(INFO, "\uD83D\uDD35 Creating AcmeClient with {0}", managementUrl); + + // It will need to become configurable to support mTLS, etc + options = new WebClientOptions(); + if (managementUrl.startsWith("https://")) { + options.setSsl(true).setTrustAll(true).setVerifyHost(false); + } + this.managementClient = WebClient.create(vertx, options); + if (managementUrl.endsWith("/q/lets-encrypt")) { + this.challengeUrl = managementUrl + "/challenge"; + this.certsUrl = managementUrl + "/certs"; + } else { + this.challengeUrl = managementUrl + "/q/lets-encrypt/challenge"; + this.certsUrl = managementUrl + "/q/lets-encrypt/certs"; + } + this.managementUser = managementUser; + this.managementPassword = managementPassword; + this.managementKey = managementKey; + } + + public boolean checkReadiness() { + + // Check status + LOGGER.log(INFO, "\uD83D\uDD35 Checking management challenge endpoint status using {0}", challengeUrl); + HttpRequest request = managementClient.getAbs(challengeUrl); + addKeyAndUser(request); + try { + HttpResponse response = await(request.send()); + int status = response.statusCode(); + switch (status) { + case 200, 204 -> { + return true; + } + case 404 -> { + LOGGER.log(ERROR, + "⚠\uFE0F Let's Encrypt challenge endpoint is not found, make sure that the build-time property `quarkus.tls.lets-encrypt.enabled` is set to `true`"); + return false; + } + default -> { + LOGGER.log(WARNING, "⚠\uFE0F Unexpected status code from the management challenge endpoint: " + status); + return false; + } + } + } catch (Exception e) { + LOGGER.log(DEBUG, "Failed to check the management challenge endpoint status", e); + LOGGER.log(ERROR, + "⚠\uFE0F Quarkus management endpoint is not ready, make sure the Quarkus application is running."); + return false; + } + + } + + @Override + public AcmeChallenge proveIdentifierControl(AcmeAccount account, List challenges) + throws AcmeException { + Assert.checkNotNullParam("account", account); + Assert.checkNotNullParam("challenges", challenges); + AcmeChallenge selectedChallenge = null; + for (AcmeChallenge challenge : challenges) { + if (challenge.getType() == AcmeChallenge.Type.HTTP_01) { + LOGGER.log(DEBUG, "HTTP 01 challenge is selected"); + selectedChallenge = challenge; + break; + } + } + if (selectedChallenge == null) { + throw new RuntimeException("Missing certificate authority challenge"); + } + + // ensure the token is valid before proceeding + String token = selectedChallenge.getToken(); + if (!token.matches(TOKEN_REGEX)) { + throw new RuntimeException("Invalid certificate authority challenge"); + } + + LOGGER.log(DEBUG, "Preparing a selected challenge content for token {0}", token); + String selectedChallengeString = selectedChallenge.getKeyAuthorization(account); + + // respond to the http challenge + if (managementClient != null) { + //TODO: Use JsonObject once POST is supported + //JsonObject challenge = new JsonObject().put("challenge-resource", token).put("challenge-content", + // selectedChallengeString); + HttpRequest request = managementClient.getAbs(challengeUrl); + request.addQueryParam("challenge-resource", token).addQueryParam("challenge-content", selectedChallengeString); + addKeyAndUser(request); + LOGGER.log(DEBUG, "Sending token {0} and challenge content to the management challenge endpoint", token, + selectedChallengeString); + + HttpResponse response = await(request.send()); + + if (response.statusCode() != 204) { + LOGGER.log(ERROR, + "⚠\uFE0F Failed to upload challenge content to the management challenge endpoint, status code: " + + response.statusCode()); + throw new RuntimeException("Failed to respond to certificate authority challenge"); + } else { + LOGGER.log(INFO, "\uD83D\uDD35 Challenge ready for token {0}, waiting for Let's Encrypt to validate...", token); + } + } + return selectedChallenge; + } + + @Override + public void cleanupAfterChallenge(AcmeAccount account, AcmeChallenge challenge) throws AcmeException { + LOGGER.log(INFO, "\uD83D\uDD35 Performing cleanup after the challenge"); + + Assert.checkNotNullParam("account", account); + Assert.checkNotNullParam("challenge", challenge); + // ensure the token is valid before proceeding + String token = challenge.getToken(); + if (!token.matches(TOKEN_REGEX)) { + throw new RuntimeException("Invalid certificate authority challenge"); + } + + LOGGER.log(DEBUG, "Requesting the management challenge endpoint to delete a challenge resource {0}", token); + + HttpRequest request = managementClient.deleteAbs(challengeUrl); + addKeyAndUser(request); + HttpResponse response = await(request.send()); + if (response.statusCode() != 204) { + throw new RuntimeException("Failed to clear challenge content in the Quarkus management endpoint"); + } + } + + public void certificateChainAndKeyAreReady() { + LOGGER.log(INFO, + "\uD83D\uDD35 Notifying management challenge endpoint that a new certificate chain and private key are ready"); + HttpRequest request = managementClient.postAbs(certsUrl); + addKeyAndUser(request); + HttpResponse response = await(request.send()); + if (response.statusCode() != 204) { + throw new RuntimeException("Failed to notify the Quarkus management endpoint"); + } + } + + private void addKeyAndUser(HttpRequest request) { + if (managementKey != null) { + request.addQueryParam("key", managementKey); + } + if (managementUser != null && managementPassword != null) { + request.basicAuthentication(managementUser, managementPassword); + } + } + + private T await(Future future) { + try { + return future.toCompletionStage().toCompletableFuture().get(30, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptConstants.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptConstants.java new file mode 100644 index 0000000000000..5d7c6cc0e9211 --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptConstants.java @@ -0,0 +1,19 @@ +package io.quarkus.tls.cli.letsencrypt; + +import java.io.File; + +public interface LetsEncryptConstants { + + File LETS_ENCRYPT_DIR = new File(".letsencrypt"); + + String CERT_FILE_NAME = "lets-encrypt.crt"; + String KEY_FILE_NAME = "lets-encrypt.key"; + String CA_FILE_NAME = "lets-encrypt-ca.crt"; + + File CERT_FILE = new File(LETS_ENCRYPT_DIR, CERT_FILE_NAME); + File KEY_FILE = new File(LETS_ENCRYPT_DIR, KEY_FILE_NAME); + File CA_FILE = new File(LETS_ENCRYPT_DIR, CA_FILE_NAME); + + File DOT_ENV_FILE = new File(".env");; + +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptHelpers.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptHelpers.java new file mode 100644 index 0000000000000..be91516fa9916 --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptHelpers.java @@ -0,0 +1,261 @@ +package io.quarkus.tls.cli.letsencrypt; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.ERROR; +import static java.lang.System.Logger.Level.INFO; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; +import org.wildfly.security.x500.cert.X509CertificateChainAndSigningKey; +import org.wildfly.security.x500.cert.acme.AcmeAccount; +import org.wildfly.security.x500.cert.acme.AcmeException; + +import io.smallrye.certs.CertificateUtils; +import io.vertx.core.json.JsonObject; + +public class LetsEncryptHelpers { + + static System.Logger LOGGER = System.getLogger("lets-encrypt"); + + public static void writePrivateKeyAndCertificateChainsAsPem(PrivateKey pk, X509Certificate[] chain, File privateKeyFile, + File certificateChainFile) throws Exception { + if (pk == null) { + throw new IllegalArgumentException("The private key cannot be null"); + } + if (chain == null || chain.length == 0) { + throw new IllegalArgumentException("The certificate chain cannot be null or empty"); + } + + CertificateUtils.writePrivateKeyToPem(pk, privateKeyFile); + + if (chain.length == 1) { + CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile); + return; + } + + // For some reason the method from CertificateUtils distinguishes the first certificate and the rest of the chain + X509Certificate[] restOfTheChain = new X509Certificate[chain.length - 1]; + System.arraycopy(chain, 1, restOfTheChain, 0, chain.length - 1); + CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile, restOfTheChain); + } + + public static X509Certificate loadCertificateFromPEM(String pemFilePath) throws IOException, CertificateException { + try (PemReader pemReader = new PemReader(new FileReader(pemFilePath))) { + PemObject pemObject = pemReader.readPemObject(); + if (pemObject == null) { + throw new IOException("Invalid PEM file: No PEM content found."); + } + byte[] content = pemObject.getContent(); + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(content)); + } + } + + public static String createAccount(AcmeClient acmeClient, + String letsEncryptPath, + boolean staging, + String contactEmail) { + LOGGER.log(INFO, "\uD83D\uDD35 Creating {0} Let's Encrypt account", (staging ? "staging" : "production")); + + AcmeAccount acmeAccount = AcmeAccount.builder() + .setTermsOfServiceAgreed(true) + .setServerUrl("https://acme-v02.api.letsencrypt.org/directory") // TODO Should this be configurable? + .setStagingServerUrl("https://acme-staging-v02.api.letsencrypt.org/directory") // TODO Should this be configurable? + .setContactUrls(new String[] { "mailto:" + contactEmail }) + .build(); + + try { + if (!acmeClient.createAccount(acmeAccount, staging)) { + LOGGER.log(INFO, "\uD83D\uDD35 {0} Let's Encrypt account {1} already exists", + (staging ? "Staging" : "Production"), + contactEmail); + } else { + LOGGER.log(INFO, "\uD83D\uDD35 {0} Let's Encrypt account {1} has been created", + (staging ? "Staging" : "Production"), + contactEmail); + } + } catch (AcmeException ex) { + LOGGER.log(ERROR, "⚠\uFE0F Failed to create Let's Encrypt account"); + throw new RuntimeException(ex); + } + + JsonObject accountJson = convertAccountToJson(acmeAccount); + saveAccount(letsEncryptPath, accountJson); + return accountJson.encode(); + } + + private static JsonObject convertAccountToJson(AcmeAccount acmeAccount) { + JsonObject json = new JsonObject(); + json.put("account-url", acmeAccount.getAccountUrl()); + json.put("contact-url", acmeAccount.getContactUrls()[0]); + if (acmeAccount.getPrivateKey() != null) { + json.put("private-key", new String(Base64.getEncoder().encode(acmeAccount.getPrivateKey().getEncoded()), + StandardCharsets.US_ASCII)); + } + if (acmeAccount.getCertificate() != null) { + try { + json.put("certificate", new String(Base64.getEncoder().encode(acmeAccount.getCertificate().getEncoded()), + StandardCharsets.US_ASCII)); + } catch (CertificateEncodingException ex) { + LOGGER.log(INFO, "⚠\uFE0F Failed to get encoded certificate data"); + throw new RuntimeException(ex); + } + } + if (acmeAccount.getKeyAlgorithmName() != null) { + json.put("key-algorithm", acmeAccount.getKeyAlgorithmName()); + } + json.put("key-size", acmeAccount.getKeySize()); + return json; + } + + private static void saveAccount(String letsEncryptPath, JsonObject accountJson) { + LOGGER.log(DEBUG, "Saving account to {0}", letsEncryptPath); + + // If more than one account must be supported, we can save accounts to unique files in .lets-encrypt/accounts + // and require an account alias/id during operations requiring an account + java.nio.file.Path accountPath = Paths.get(letsEncryptPath + "/account.json"); + try { + Files.copy(new ByteArrayInputStream(accountJson.encode().getBytes(StandardCharsets.US_ASCII)), accountPath, + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException ex) { + throw new RuntimeException("Failure to save the account", ex); + } + } + + public static void issueCertificate( + AcmeClient acmeClient, + File letsEncryptPath, + boolean staging, + String domain, + File certChainPemLoc, + File privateKeyPemLoc) { + AcmeAccount acmeAccount = getAccount(letsEncryptPath); + X509CertificateChainAndSigningKey certChainAndPrivateKey; + try { + certChainAndPrivateKey = acmeClient.obtainCertificateChain(acmeAccount, staging, domain); + } catch (AcmeException t) { + throw new RuntimeException(t.getMessage()); + } + LOGGER.log(INFO, "\uD83D\uDD35 Certificate and private key issued, converting them to PEM files"); + + try { + LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem(certChainAndPrivateKey.getSigningKey(), + certChainAndPrivateKey.getCertificateChain(), privateKeyPemLoc, certChainPemLoc); + } catch (Exception ex) { + throw new RuntimeException("Failure to copy certificate pem"); + } + } + + private static AcmeAccount getAccount(File letsEncryptPath) { + LOGGER.log(DEBUG, "Getting account from {0}", letsEncryptPath); + + JsonObject json = readAccountJson(letsEncryptPath); + AcmeAccount.Builder builder = AcmeAccount.builder().setTermsOfServiceAgreed(true) + .setServerUrl("https://acme-v02.api.letsencrypt.org/directory") + .setStagingServerUrl("https://acme-staging-v02.api.letsencrypt.org/directory"); + + String keyAlgorithm = json.getString("key-algorithm"); + builder.setKeyAlgorithmName(keyAlgorithm); + builder.setKeySize(json.getInteger("key-size")); + + if (json.containsKey("private-key") && json.containsKey("certificate")) { + PrivateKey privateKey = getPrivateKey(json.getString("private-key"), keyAlgorithm); + X509Certificate certificate = getCertificate(json.getString("certificate")); + + builder.setKey(certificate, privateKey); + } + + AcmeAccount acmeAccount = builder.build(); + + acmeAccount.setContactUrls(new String[] { json.getString("contact-url") }); + acmeAccount.setAccountUrl(json.getString("account-url")); + + return acmeAccount; + } + + private static JsonObject readAccountJson(File letsEncryptPath) { + LOGGER.log(DEBUG, "Reading account information from {0}", letsEncryptPath); + java.nio.file.Path accountPath = Paths.get(letsEncryptPath + "/account.json"); + try (FileInputStream fis = new FileInputStream(accountPath.toString())) { + return new JsonObject(new String(fis.readAllBytes(), StandardCharsets.US_ASCII)); + } catch (IOException e) { + throw new RuntimeException("Unable to read the account file, you must create account first"); + } + } + + private static X509Certificate getCertificate(String encodedCert) { + try { + byte[] encodedBytes = Base64.getDecoder().decode(encodedCert); + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(encodedBytes)); + } catch (Exception ex) { + throw new RuntimeException("Failure to create a certificate", ex); + } + } + + private static PrivateKey getPrivateKey(String encodedKey, String keyAlgorithm) { + try { + KeyFactory f = KeyFactory.getInstance((keyAlgorithm == null || "RSA".equals(keyAlgorithm) ? "RSA" : "EC")); + byte[] encodedBytes = Base64.getDecoder().decode(encodedKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(encodedBytes); + return f.generatePrivate(spec); + } catch (Exception ex) { + throw new RuntimeException("Failure to create a private key", ex); + } + } + + public static void renewCertificate(AcmeClient acmeClient, + File letsEncryptPath, + boolean staging, + String domain, + File certChainPemLoc, + File privateKeyPemLoc) { + LOGGER.log(INFO, "\uD83D\uDD35 Renewing {0} Let's Encrypt certificate chain and private key", + (staging ? "staging" : "production")); + issueCertificate(acmeClient, letsEncryptPath, staging, domain, certChainPemLoc, privateKeyPemLoc); + } + + public static void deactivateAccount(AcmeClient acmeClient, File letsEncryptPath, boolean staging) throws IOException { + AcmeAccount acmeAccount = getAccount(letsEncryptPath); + LOGGER.log(INFO, "Deactivating {0} Let's Encrypt account", (staging ? "staging" : "production")); + acmeClient.deactivateAccount(acmeAccount, staging); + + LOGGER.log(INFO, "Removing account file from {0}", letsEncryptPath); + + java.nio.file.Path accountPath = Paths.get(letsEncryptPath + "/account.json"); + Files.deleteIfExists(accountPath); + } + + public static void adjustPermissions(File certFile, File keyFile) { + if (!certFile.setReadable(true, false)) { + LOGGER.log(ERROR, "Failed to set certificate file readable"); + } + if (!certFile.setWritable(true, true)) { + LOGGER.log(ERROR, "Failed to set certificate file as not writable"); + } + if (!keyFile.setReadable(true, false)) { + LOGGER.log(ERROR, "Failed to set key file as readable"); + } + if (!keyFile.setWritable(true, true)) { + LOGGER.log(ERROR, "Failed to set key file as not writable"); + } + } +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptIssueCommand.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptIssueCommand.java new file mode 100644 index 0000000000000..8f1308317eca8 --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptIssueCommand.java @@ -0,0 +1,95 @@ +package io.quarkus.tls.cli.letsencrypt; + +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.CERT_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.DOT_ENV_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.KEY_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.LETS_ENCRYPT_DIR; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptHelpers.adjustPermissions; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptHelpers.createAccount; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptHelpers.issueCertificate; +import static java.lang.System.Logger.Level.INFO; + +import java.util.concurrent.Callable; + +import picocli.CommandLine; + +@CommandLine.Command(name = "issue-certificate", mixinStandardHelpOptions = true, description = "Issue a certificate from let's encrypt. This command runs the HTTP 01 challenge of let's encrypt. " + + + "Make sure the application is running before running this command.") +public class LetsEncryptIssueCommand implements Callable { + + static System.Logger LOGGER = System.getLogger("lets-encrypt-issue"); + + @CommandLine.Option(names = { "-d", + "--domain" }, description = "The domain for which the certificate will be generated", required = true) + String domain; + + @CommandLine.Option(names = { "-n", + "--tls-configuration-name" }, description = "The name of the TLS configuration to be used, if not set, the default configuration is used") + String tlsConfigurationName; + + // TODO Check if /lets-encrypt is appended + @CommandLine.Option(names = { + "--management-url" }, description = "The URL of the management endpoint to use for the ACME challenge", required = true) + String managementUrl; + + @CommandLine.Option(names = { + "--management-user" }, description = "The username to use for the management endpoint") + String managementUser; + + @CommandLine.Option(names = { + "--management-password" }, description = "The password to use for the management endpoint") + String managementPassword; + + @CommandLine.Option(names = { + "--email" }, description = "The email of the account to use for the ACME challenge", required = true) + String email; + + @CommandLine.Option(names = { + "--staging" }, description = "Whether to use the staging environment of Let's Encrypt", defaultValue = "false") + boolean staging; + + @Override + public Integer call() throws Exception { + AcmeClient client = new AcmeClient(managementUrl, managementUser, managementPassword, tlsConfigurationName); + + // Step 0 - Verification + // - Make sure the .letsencrypt directory exists + if (!LETS_ENCRYPT_DIR.exists()) { + LOGGER.log(System.Logger.Level.ERROR, + "The .letsencrypt directory does not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure the cert and key files exist + if (!CERT_FILE.isFile() || !KEY_FILE.isFile()) { + LOGGER.log(System.Logger.Level.ERROR, + "The certificate and key files do not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure the .env file exists + if (!DOT_ENV_FILE.isFile()) { + LOGGER.log(System.Logger.Level.ERROR, + "The .env file does not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure application is running + if (!client.checkReadiness()) { + return 1; + } + + // Step 1 - Account + createAccount(client, LETS_ENCRYPT_DIR.getAbsolutePath(), staging, email); + + // Step 2 - run the challenge to obtain first certificate + LOGGER.log(INFO, "\uD83D\uDD35 Requesting initial certificate from {0} Let's Encrypt", (staging ? "staging" : "")); + issueCertificate(client, LETS_ENCRYPT_DIR, staging, domain, CERT_FILE, KEY_FILE); + adjustPermissions(CERT_FILE, KEY_FILE); + + // Step 3 - Reload certificate + client.certificateChainAndKeyAreReady(); + + LOGGER.log(INFO, "✅ Successfully obtained certificate for {0}", domain); + + return 0; + } +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptPrepareCommand.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptPrepareCommand.java new file mode 100644 index 0000000000000..b6fa4a34050dd --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptPrepareCommand.java @@ -0,0 +1,125 @@ +package io.quarkus.tls.cli.letsencrypt; + +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.*; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.CERT_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.DOT_ENV_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.KEY_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.LETS_ENCRYPT_DIR; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +import io.smallrye.certs.CertificateGenerator; +import io.smallrye.certs.CertificateRequest; +import io.smallrye.certs.Format; +import picocli.CommandLine; + +@CommandLine.Command(name = "prepare", mixinStandardHelpOptions = true, description = "Prepare the environment to receive Let's Encrypt certificates." + + " Make sure to restart the application after having run this command.") +public class LetsEncryptPrepareCommand implements Callable { + + static System.Logger LOGGER = System.getLogger("lets-encrypt-prepare"); + + @CommandLine.Option(names = { "-d", + "--domain" }, description = "The domain for which the certificate will be generated", required = true) + String domain; + + @CommandLine.Option(names = { "-n", + "--tls-configuration-name" }, description = "The name of the TLS configuration to be used, if not set, the default configuration is used") + String tlsConfigurationName; + + @Override + public Integer call() throws Exception { + // Step 1 - Create .letsencrypt directory + if (!LETS_ENCRYPT_DIR.exists()) { + if (LETS_ENCRYPT_DIR.mkdir()) { + LOGGER.log(System.Logger.Level.INFO, "✅ Created .letsencrypt directory: {0}", + LETS_ENCRYPT_DIR.getAbsolutePath()); + } + } + + // Step 2 - Generate self-signed certificate + + boolean certExistingAndStillValid = false; + if (CERT_FILE.isFile() && KEY_FILE.isFile()) { + // The cert and key are already present, check if they are expired + var existing = LetsEncryptHelpers.loadCertificateFromPEM(CERT_FILE.getAbsolutePath()); + try { + existing.checkValidity(); + certExistingAndStillValid = true; + } catch (Exception e) { + LOGGER.log(System.Logger.Level.INFO, "⚠\uFE0F The existing certificate is expired, regenerating it..."); + } + } + + if (!certExistingAndStillValid) { + // Generate a self-signed certificate (for the challenge + CertificateGenerator generator = new CertificateGenerator(LETS_ENCRYPT_DIR.toPath(), true); + CertificateRequest request = new CertificateRequest() + .withCN(domain) + .withSubjectAlternativeName("DNS:" + domain) + .withDuration(Duration.ofDays(30)) // Should be plenty to run the challenge + .withFormat(Format.PEM) + .withName("lets-encrypt"); + generator.generate(request); + } else { + LOGGER.log(System.Logger.Level.INFO, "✅ Certificate already exists and is still valid: {0}", + CERT_FILE.getAbsolutePath()); + } + + // Delete the CA file, we do not use it. + if (CA_FILE.isFile()) { + CA_FILE.delete(); + } + + // Step 3 - Create .env file or append if exists + List dotEnvContent = readDotEnvFile(); + + String prefix = "quarkus.tls"; + if (tlsConfigurationName != null) { + prefix += "." + tlsConfigurationName; + } + + // We cannot set quarkus.management.enabled and quarkus.tls.lets-encrypt.enabled as they are build time properties. + addOrReplaceProperty(dotEnvContent, prefix + ".key-store.pem.acme.cert", CERT_FILE.getAbsolutePath()); + addOrReplaceProperty(dotEnvContent, prefix + ".key-store.pem.acme.key", KEY_FILE.getAbsolutePath()); + + Files.write(DOT_ENV_FILE.toPath(), dotEnvContent); + LOGGER.log(System.Logger.Level.INFO, "✅ .env file configured for Let's Encrypt: {0}", DOT_ENV_FILE.getAbsolutePath()); + LOGGER.log(System.Logger.Level.INFO, + "➡\uFE0F Start the application and run `quarkus tls lets-encrypt issue-certificate --domain={0}{1}` to complete the challenge", + domain, + tlsConfigurationName != null ? " -tls-configuration-name=" + tlsConfigurationName : ""); + return 0; + } + + List readDotEnvFile() throws IOException { + if (!DOT_ENV_FILE.exists()) { + return new ArrayList<>(); + } + return new ArrayList<>(Files.readAllLines(DOT_ENV_FILE.toPath())); + } + + void addOrReplaceProperty(List content, String key, String value) { + var line = hasLine(content, key); + if (line != -1) { + content.set(line, key + "=" + value); + } else { + content.add(key + "=" + value); + } + } + + private int hasLine(List content, String key) { + for (int i = 0; i < content.size(); i++) { + if (content.get(i).startsWith(key + "=") || content.get(i).startsWith(key + " =")) { + return i; + } + } + return -1; + } + +} diff --git a/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptRenewCommand.java b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptRenewCommand.java new file mode 100644 index 0000000000000..e34717fc6c025 --- /dev/null +++ b/extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptRenewCommand.java @@ -0,0 +1,83 @@ +package io.quarkus.tls.cli.letsencrypt; + +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.CERT_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.DOT_ENV_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.KEY_FILE; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptConstants.LETS_ENCRYPT_DIR; +import static io.quarkus.tls.cli.letsencrypt.LetsEncryptHelpers.*; +import static java.lang.System.Logger.Level.INFO; + +import java.util.concurrent.Callable; + +import picocli.CommandLine; + +@CommandLine.Command(name = "renew-certificate", mixinStandardHelpOptions = true, description = "Renew a Let's Encrypt. This command re-runs the HTTP 01 challenge of let's encrypt to retrieve a new certificate. " + + "Make sure the application is running before running this command.") +public class LetsEncryptRenewCommand implements Callable { + + static System.Logger LOGGER = System.getLogger("lets-encrypt-issue"); + + @CommandLine.Option(names = { "-d", + "--domain" }, description = "The domain for which the certificate will be generated", required = true) + String domain; + + @CommandLine.Option(names = { "-n", + "--tls-configuration-name" }, description = "The name of the TLS configuration to be used, if not set, the default configuration is used") + String tlsConfigurationName; + + @CommandLine.Option(names = { + "--management-url" }, description = "The URL of the management endpoint to use for the ACME challenge", required = true) + String managementUrl; + + @CommandLine.Option(names = { + "--management-user" }, description = "The username to use for the management endpoint") + String managementUser; + + @CommandLine.Option(names = { + "--management-password" }, description = "The password to use for the management endpoint") + String managementPassword; + + @CommandLine.Option(names = { + "--staging" }, description = "Whether to use the staging environment of Let's Encrypt", defaultValue = "false") + boolean staging; + + @Override + public Integer call() throws Exception { + AcmeClient client = new AcmeClient(managementUrl, managementUser, managementPassword, tlsConfigurationName); + + // Step 0 - Verification + // - Make sure the .letsencrypt directory exists + if (!LETS_ENCRYPT_DIR.exists()) { + LOGGER.log(System.Logger.Level.ERROR, + "The .letsencrypt directory does not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure the cert and key files exist + if (!CERT_FILE.isFile() || !KEY_FILE.isFile()) { + LOGGER.log(System.Logger.Level.ERROR, + "The certificate and key files do not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure the .env file exists + if (!DOT_ENV_FILE.isFile()) { + LOGGER.log(System.Logger.Level.ERROR, + "The .env file does not exist, please run the `quarkus tls letsencrypt prepare` command first"); + return 1; + } + // - Make sure application is running + if (!client.checkReadiness()) { + return 1; + } + + // Step 1 - Renew + renewCertificate(client, LETS_ENCRYPT_DIR, staging, domain, CERT_FILE, KEY_FILE); + adjustPermissions(CERT_FILE, KEY_FILE); + + // Step 2 - Reload certificate + client.certificateChainAndKeyAreReady(); + + LOGGER.log(INFO, "✅ Successfully renewed certificate for {0}", domain); + + return 0; + } +} From 05b0812ed92ed937a3d37c3c7f73e1daffefc2c1 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Wed, 24 Jul 2024 13:16:35 +0100 Subject: [PATCH 3/3] Add Lets Encrypt ACME section to TLS registry documentation --- .../main/asciidoc/tls-registry-reference.adoc | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/tls-registry-reference.adoc b/docs/src/main/asciidoc/tls-registry-reference.adoc index 43bc056fd041a..df2a78a0c4513 100644 --- a/docs/src/main/asciidoc/tls-registry-reference.adoc +++ b/docs/src/main/asciidoc/tls-registry-reference.adoc @@ -19,7 +19,7 @@ It allows to define the TLS configuration in a single place and to reference it The TLS extension should be automatically added to your project as soon as you use a compatible extension. For example, if your application uses Quarkus REST, gRPC or reactive routes, the TLS registry is automatically added to your project. -== Examples +== Using TLS registry To configure a TLS connection, and more specifically the key stores and trust stores, you use the `quarkus.tls.*` properties. @@ -1107,3 +1107,161 @@ On Mac, you can use the following command: ---- sudo security -v remove-trusted-cert -d /Users/clement/.quarkus/quarkus-dev-root-ca.pem ---- + +[[lets-encrypt]] +== Automatic certificate management with Let's Encrypt + +https://letsencrypt.org[Let's Encrypt] is a free, automated certificate authority provided by https://www.abetterinternet.org/[Internet Security Research Group]. + +Let's Encrypt uses https://datatracker.ietf.org/doc/html/rfc8555[Automated certificate management environment (ACME) protocol] to support an automatic certificate issuance and renewal. Please read https://letsencrypt.org/docs/[Let's Encrypt documentation] to learn more about Let's Encrypt and ACME. + +TLS registry project provides a CLI ACME client to issue and renew Let's Encrypt certificates. +Your application uses TLS registry to resolve ACME protocol challenges. + +Follow the steps below to have your Quarkus application prepared and automatically updated with new and renewed Let's Encrypt certificates. + +[[lets-encrypt-prerequisites]] +=== Prerequisites + +Make sure that a fully resolvable DNS domain name is available and can be used to access your application. +This domain name is used for creating a Let's Encrypt account, and supporting Let's Encrypt ACME challenges to prove that you own this domain. +You can use https://ngrok.com/[Ngrok] to start experimenting with the Quarkus Let's Encrypt ACME feature, see <> section below for more information. + +Your Quarkus HTTPS application must use a _build-time_ property to enable a Let's Encrypt ACME challenge route: + +[source, properties] +---- +quarkus.tls.lets-encrypt.enabled=true +---- + +The TLS registry can manage the challenge process from either the main HTTP interface or from the management interface. +Using a management interface is **strongly** recommended to let Quarkus deal with ACME challenge configuration separately to the main application's deployment and security requirements: + +[source, properties] +---- +quarkus.tls.lets-encrypt.enabled=true +quarkus.management.enabled=true +---- + +The challenge itself is served from the primary HTTP interface (the one accessible from your DNS domain name). + +IMPORTANT: Do not start your application yet. + +=== Application preparation + +Before you request a Let's Encrypt certificate, you must run TLS registry Let's Encrypt CLI `prepare` command to prepare your application: + +[source, shell] +---- +quarkus tls lets-encrypt prepare --domain= +---- + +IMPORTANT: Make sure you run a prepare command in the root directory of your application. + +The `prepare` command does the following: + +- Creates a `.letsencrypt` folder in your application's root directory +- Creates a self-signed domain certificate and private key for your application configured in the previous <> step to be able to start and accept HTTPS requests. +- Create a `.env` configuration file in your application's root directory configure the application to use the self-signed domain certificate and private key (until we get the Let's Encrypt certificate). + +The following snippet shows an example of the generated `.env` file: + +[source, properties] +---- +quarkus.tls.key-store.pem.acme.cert=.letsencrypt/lets-encrypt.crt +quarkus.tls.key-store.pem.acme.key=.letsencrypt/lets-encrypt.key +---- + +NOTE: The `.env` file does not contain the `quarkus.tls.lets-encrypt.enabled` and `quarkus.management.enabled` properties as they are build-time properties requiring a rebuild of the application. + +=== Start your application + +You can start your application: + +[source, shell] +---- +java -jar quarkus-run.jar +---- + +Access your application endpoint using `https://your-domain-name:8443/`, for example, `https://your-domain-name:8443/hello`, accept a self-signed certificate in the browser. + +Next, keep the application running and request your first Let's Encrypt certificate. + +[[lets-encrypt-issue-certificate]] +=== Issue certificate + +From the application directory, run the `issue-certificate` command to acquire your first Let's Encrypt certificate: + +[source, shell] +---- +quarkus tls lets-encrypt issue-certificate \ + --domain= \ <1> + --email= \ <2> + --management-url=https://localhost:9000 <3> +---- +<1> Set your domain name. +<2> Provide your contact email address that Let's Encrypt can use to contact you in case of any issues with your Let's Encrypt account. +<3> Set your application management URL which can be used to handle ACME challenges. Use `https://localhost:8443/` if you chose not to enable a management router in the <> step. + +During this command, the TLS registry CLI checks if the application is prepared to serve the challenge, creates and records Let's Encrypt account information, issues a Let's Encrypt certificate request, and interacts with the Quarkus application to resolve ACME challenges. + +Once the Let's Encrypt certificate chain and private key have been successfully acquired, they are converted to PEM format and copied to your application's `.letsencrypt` folder. +The TLS registry is informed that a new certificate and private key are ready, and reloads them automatically. + +Now, access your application's endpoint using `https://your-domain-name:8443/` again. +Confirm in the browser that your domain certificate is now signed by the Let's Encrypt certificate authority. + +Note that currently, a Let's Encrypt account is created implicitly by the `issue-certificate` command to make it easy for users to get started with the ACME protocol. +Support for the Let's Encrypt account management will evolve further. + +[[lets-encrypt-renew-certificate]] +=== Renew certificate + +Renewing certificates is similar to issuing the first certificate, but it requires an existing account created during the <> step. + +Run the following command to renew your Let's Encrypt certificate: + +[source, shell] +---- +quarkus tls lets-encrypt renew-certificate \ + --domain= <1> +---- +<1> Set your domain name. + +During this command, TLS registry CLI reads a Let's Encrypt account information recorded during the <> step, issues a Let's Encrypt certificate request, and communicates with a Quarkus application to have ACME challenges resolved. + +Once the Let's Encrypt certificate chain and private key have been successfully renewed, they are converted to PEM format and copied to your application's `.letsencrypt` folder. +TLS registry is informed that a new certificate and private key are ready and it reloads them automatically. + +[[lets-encrypt-ngrok]] +=== Use NGrok for testing + +https://ngrok.com/[Ngrok] can be used to provide a secure HTTPS tunnel to your application running on localhost, and make it easy to test HTTPS based applications. + +Using Ngrok provides an easiest option to get started with the Quarkus Let's Encrypt ACME feature. + +The first thing you have to do with Ngrok is to ask it to reserve a domain. +You can use https://github.com/quarkiverse/quarkus-ngrok[Quarkiverse NGrok] in devmode, or have it reserved directly in the NGrok dashboard. + +Unfortunately, you can't use your NGrok domain to test the Quarkus Let's Encrypt ACME feature immediately. +This is due to the fact that Ngrok is itself using Let's Encrypt and intercepts ACME challenges which are meant to be handled by the Quarkus application instead. + +Therefore, you need to remove an NGrok Let's Encrypt certificate policy from your NGrok domain: + +[source, shell] +---- +ngrok api --api-key reserved-domains delete-certificate-management-policy +---- + +`YOUR-RESERVED-DOMAIN-ID` is your reserved domain's id which starts from `rd_`, you can find it in the https://dashboard.ngrok.com/cloud-edge/domains[NGrok dashboard domains section]. + +Now, NGrok will forward ACME challenges over HTTP only, therefore you need to start Ngrok like this: + +[source, shell] +---- +ngrok http --domain 8080 --scheme http +---- + +where `8080` is the localhost HTTP port that your application is listening on. + +You can now test the Quarkus Let's Encrypt ACME feature from your local machine.