Skip to content

Commit

Permalink
Add tests to the TLS certificate reload
Browse files Browse the repository at this point in the history
- for both the primary and management server
- also update the docs
  • Loading branch information
cescoffier committed Feb 18, 2024
1 parent b79b11f commit 1107268
Show file tree
Hide file tree
Showing 9 changed files with 586 additions and 30 deletions.
5 changes: 5 additions & 0 deletions bom/application/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3593,6 +3593,11 @@
<artifactId>hamcrest</artifactId>
<version>${hamcrest.version}</version>
</dependency>
<dependency>
<groupId>me.escoffier.certs</groupId>
<artifactId>certificate-generator-junit5</artifactId>
<version>0.3.0</version>
</dependency>

<dependency>
<groupId>org.antlr</groupId>
Expand Down
4 changes: 2 additions & 2 deletions docs/src/main/asciidoc/http-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,8 @@ Configure the `quarkus.http.ssl.certificate.reload-period` property to specify t

[source, properties]
----
quarkus.http.ssl.certificate.files=/mount/certs/cert.pem
quarkus.http.ssl.certificate.key-files=/mount/certs/key.pem
quarkus.http.ssl.certificate.files=/mount/certs/tls.crt
quarkus.http.ssl.certificate.key-files=/mount/certs/tls.key
quarkus.http.ssl.certificate.reload-period=1h
----

Expand Down
14 changes: 14 additions & 0 deletions docs/src/main/asciidoc/management-interface-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ quarkus.management.ssl.certificate.key-store-file=server-keystore.jks
quarkus.management.ssl.certificate.key-store-password=secret
----

Key store, trust store and certificate files can be reloaded periodically.

Check warning on line 61 in docs/src/main/asciidoc/management-interface-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'truststore' rather than 'trust store' unless updating existing content that uses the term. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'truststore' rather than 'trust store' unless updating existing content that uses the term.", "location": {"path": "docs/src/main/asciidoc/management-interface-reference.adoc", "range": {"start": {"line": 61, "column": 12}}}, "severity": "WARNING"}
Configure the `quarkus.management.ssl.certificate.reload-period` property to specify the interval at which the certificates should be reloaded:

Check warning on line 62 in docs/src/main/asciidoc/management-interface-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'.", "location": {"path": "docs/src/main/asciidoc/management-interface-reference.adoc", "range": {"start": {"line": 62, "column": 101}}}, "severity": "INFO"}

[source, properties]
----
quarkus.http.management.certificate.files=/mount/certs/tls.crt
quarkus.http.management.certificate.key-files=/mount/certs/tls.key
quarkus.http.management.certificate.reload-period=1h
----

The files are reloaded from the same location as they were initially loaded from.

Check warning on line 71 in docs/src/main/asciidoc/management-interface-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/management-interface-reference.adoc", "range": {"start": {"line": 71, "column": 47}}}, "severity": "INFO"}
If there is no content change, the reloading is a no-op.
It the reloading fails, the server will continue to use the previous certificates.

IMPORTANT: Unlike the main HTTP server, the management interface does not handle _http_ and _https_ at the same time.
If _https_ is configured, plain HTTP requests will be rejected.

Expand Down
10 changes: 8 additions & 2 deletions extensions/vertx-http/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-kubernetes-spi</artifactId>
</dependency>

<!-- Dev UI -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down Expand Up @@ -64,7 +64,7 @@
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down Expand Up @@ -111,6 +111,12 @@
<artifactId>vertx-web-client</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>me.escoffier.certs</groupId>
<artifactId>certificate-generator-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package io.quarkus.vertx.http.certReload;

Check failure on line 1 in extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/certReload/MainHttpServerTlsCertificateReloadTest.java

View check run for this annotation

quarkus-bot / Build summary for 11072682b88e98af1e020e8137c0e72930c6fb93

JVM Tests - JDK 17 Windows

java.lang.RuntimeException: java.lang.RuntimeException: Failed to start quarkus at io.quarkus.test.QuarkusUnitTest.beforeAll(QuarkusUnitTest.java:709) at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Raw output
java.lang.RuntimeException: java.lang.RuntimeException: Failed to start quarkus
	at io.quarkus.test.QuarkusUnitTest.beforeAll(QuarkusUnitTest.java:709)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Caused by: java.lang.RuntimeException: Failed to start quarkus
	at io.quarkus.runner.ApplicationImpl.doStart(Unknown Source)
	at io.quarkus.runtime.Application.start(Application.java:101)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at io.quarkus.runner.bootstrap.StartupActionImpl.run(StartupActionImpl.java:285)
	at io.quarkus.test.QuarkusUnitTest.beforeAll(QuarkusUnitTest.java:662)
	... 1 more
Caused by: java.nio.file.NoSuchFileException: D:aquarkusquarkusextensionsvertx-httpdeploymenttargettest-certificates-35d3e665-ce65-4613-bb6a-eec460ad97f7\tls.crt
	at java.base/sun.nio.fs.WindowsException.translateToIOException(WindowsException.java:85)
	at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:103)
	at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(WindowsException.java:108)
	at java.base/sun.nio.fs.WindowsFileSystemProvider.newByteChannel(WindowsFileSystemProvider.java:236)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:380)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:432)
	at java.base/java.nio.file.spi.FileSystemProvider.newInputStream(FileSystemProvider.java:422)
	at java.base/java.nio.file.Files.newInputStream(Files.java:160)
	at io.quarkus.vertx.http.runtime.options.HttpServerOptionsUtils.getFileContent(HttpServerOptionsUtils.java:382)
	at io.quarkus.vertx.http.runtime.options.HttpServerOptionsUtils.createPemKeyCertOptions(HttpServerOptionsUtils.java:401)
	at io.quarkus.vertx.http.runtime.options.HttpServerOptionsUtils.createSslOptions(HttpServerOptionsUtils.java:89)
	at io.quarkus.vertx.http.runtime.VertxHttpRecorder.initializeMainHttpServer(VertxHttpRecorder.java:670)
	at io.quarkus.vertx.http.runtime.VertxHttpRecorder.doServerStart(VertxHttpRecorder.java:761)
	at io.quarkus.vertx.http.runtime.VertxHttpRecorder.startServer(VertxHttpRecorder.java:319)
	at io.quarkus.deployment.steps.VertxHttpProcessor$openSocket189362710.deploy_0(Unknown Source)
	at io.quarkus.deployment.steps.VertxHttpProcessor$openSocket189362710.deploy(Unknown Source)
	... 6 more

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.UUID;

import javax.net.ssl.SSLHandshakeException;

import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpClientRequest;
import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.net.PemTrustOptions;
import io.vertx.ext.web.Router;
import me.escoffier.certs.Format;
import me.escoffier.certs.junit5.Certificate;
import me.escoffier.certs.junit5.Certificates;

@Certificates(baseDir = "target/certificates", certificates = {
@Certificate(name = "reload-A", formats = Format.PEM),
@Certificate(name = "reload-B", formats = Format.PEM, duration = 365),
})
public class MainHttpServerTlsCertificateReloadTest {

@TestHTTPResource(value = "/hello", ssl = true)
URL url;

public static final File temp = new File("target/test-certificates-" + UUID.randomUUID());

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar.addClasses(MyBean.class))
.overrideConfigKey("quarkus.http.ssl.insecure-requests", "redirect")
.overrideConfigKey("quarkus.http.ssl.certificate.reload-period", "30s")
.overrideConfigKey("quarkus.http.ssl.certificate.files", temp.getAbsolutePath() + "/tls.crt")
.overrideConfigKey("quarkus.http.ssl.certificate.key-files", temp.getAbsolutePath() + "/tls.key")
.overrideConfigKey("loc", temp.getAbsolutePath())
.setBeforeAllCustomizer(() -> {
try {
// Prepare a random directory to store the certificates.
temp.mkdirs();
Files.copy(new File("target/certificates/reload-A.crt").toPath(),
new File(temp, "/tls.crt").toPath());
Files.copy(new File("target/certificates/reload-A.key").toPath(),
new File(temp, "/tls.key").toPath());
Files.copy(new File("target/certificates/reload-A-ca.crt").toPath(),
new File(temp, "/ca.crt").toPath());
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.setAfterAllCustomizer(() -> {
try {
Files.deleteIfExists(new File(temp, "/tls.crt").toPath());
Files.deleteIfExists(new File(temp, "/tls.key").toPath());
Files.deleteIfExists(new File(temp, "/ca.crt").toPath());
Files.deleteIfExists(temp.toPath());
} catch (Exception e) {
throw new RuntimeException(e);
}
});

@Inject
Vertx vertx;

@ConfigProperty(name = "loc")
File certs;

@Test
void test() throws IOException {
var options = new HttpClientOptions()
.setSsl(true)
.setDefaultPort(url.getPort())
.setDefaultHost(url.getHost())
.setTrustOptions(new PemTrustOptions().addCertPath("target/certificates/reload-A-ca.crt"));

String response1 = vertx.createHttpClient(options)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

// Update certs
Files.copy(new File("target/certificates/reload-B.crt").toPath(),
new File(certs, "/tls.crt").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
Files.copy(new File("target/certificates/reload-B.key").toPath(),
new File(certs, "/tls.key").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);

// Trigger the reload
TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10));

// The client truststore is not updated, thus it should fail.
assertThatThrownBy(() -> vertx.createHttpClient(options)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class);

var options2 = new HttpClientOptions(options)
.setTrustOptions(new PemTrustOptions().addCertPath("target/certificates/reload-B-ca.crt"));

var response2 = vertx.createHttpClient(options2)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

assertThat(response1).isNotEqualTo(response2); // Because cert duration are different.

// Trigger another reload
TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10));

var response3 = vertx.createHttpClient(options2)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

assertThat(response2).isEqualTo(response3);
}

public static class MyBean {

public void onStart(@Observes Router router) {
router.get("/hello").handler(rc -> {
var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]).getNotAfter()
.toInstant().toEpochMilli();
rc.response().end("Hello " + exp);
});
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package io.quarkus.vertx.http.certReload;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.UUID;

import javax.net.ssl.SSLHandshakeException;

import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.vertx.http.runtime.options.TlsCertificateReloader;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpClientRequest;
import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.net.PfxOptions;
import io.vertx.ext.web.Router;
import me.escoffier.certs.Format;
import me.escoffier.certs.junit5.Certificate;
import me.escoffier.certs.junit5.Certificates;

@Certificates(baseDir = "target/certificates", certificates = {
@Certificate(name = "reload-A", formats = Format.PKCS12, password = "password"),
@Certificate(name = "reload-B", formats = Format.PKCS12, password = "password", duration = 365),
})
public class MainHttpServerTlsPKCS12CertificateReloadTest {

@TestHTTPResource(value = "/hello", ssl = true)
URL url;

public static final File temp = new File("target/test-certificates-" + UUID.randomUUID());

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar.addClasses(MyBean.class))
.overrideConfigKey("quarkus.http.ssl.insecure-requests", "redirect")
.overrideConfigKey("quarkus.http.ssl.certificate.reload-period", "30s")
.overrideConfigKey("quarkus.http.ssl.certificate.key-store-file", temp.getAbsolutePath() + "/tls.p12")
.overrideConfigKey("loc", temp.getAbsolutePath())
.setBeforeAllCustomizer(() -> {
try {
// Prepare a random directory to store the certificates.
temp.mkdirs();
Files.copy(new File("target/certificates/reload-A-keystore.p12").toPath(),
new File(temp, "/tls.p12").toPath());
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.setAfterAllCustomizer(() -> {
try {
Files.deleteIfExists(new File(temp, "/tls.p12").toPath());
Files.deleteIfExists(temp.toPath());
} catch (Exception e) {
throw new RuntimeException(e);
}
});

@Inject
Vertx vertx;

@ConfigProperty(name = "loc")
File certs;

@Test
void test() throws IOException {
var options = new HttpClientOptions()
.setSsl(true)
.setDefaultPort(url.getPort())
.setDefaultHost(url.getHost())
.setTrustOptions(
new PfxOptions().setPath("target/certificates/reload-A-truststore.p12").setPassword("password"));

String response1 = vertx.createHttpClient(options)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

// Update certs
Files.copy(new File("target/certificates/reload-B-keystore.p12").toPath(),
new File(certs, "/tls.p12").toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);

// Trigger the reload
TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10));

// The client truststore is not updated, thus it should fail.
assertThatThrownBy(() -> vertx.createHttpClient(options)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join()).hasCauseInstanceOf(SSLHandshakeException.class);

var options2 = new HttpClientOptions(options)
.setTrustOptions(
new PfxOptions().setPath("target/certificates/reload-B-truststore.p12").setPassword("password"));

var response2 = vertx.createHttpClient(options2)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

assertThat(response1).isNotEqualTo(response2); // Because cert duration are different.

// Trigger another reload
TlsCertificateReloader.reload().await().atMost(Duration.ofSeconds(10));

var response3 = vertx.createHttpClient(options2)
.request(HttpMethod.GET, "/hello")
.flatMap(HttpClientRequest::send)
.flatMap(HttpClientResponse::body)
.map(Buffer::toString)
.toCompletionStage().toCompletableFuture().join();

assertThat(response2).isEqualTo(response3);
}

public static class MyBean {

public void onStart(@Observes Router router) {
router.get("/hello").handler(rc -> {
var exp = ((X509Certificate) rc.request().connection().sslSession().getLocalCertificates()[0]).getNotAfter()
.toInstant().toEpochMilli();
rc.response().end("Hello " + exp);
});
}

}

}
Loading

0 comments on commit 1107268

Please sign in to comment.