From fed3d789aca9f476e7e82856a6ffca0ad66ff7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 18 Apr 2024 22:03:48 +0200 Subject: [PATCH] Fix Kafka SSL scenarios on FIPS by regenerating certs --- ...mziKafkaWithDefaultSaslSslMessagingIT.java | 17 +-- pom.xml | 6 + .../io/quarkus/test/services/Container.java | 6 + .../ContainerManagedResourceBuilder.java | 9 ++ .../quarkus/test/bootstrap/BaseService.java | 11 ++ .../bootstrap/LocalhostManagedResource.java | 115 ++++++++++++++++++ quarkus-test-service-kafka/pom.xml | 4 + .../quarkus/test/bootstrap/KafkaService.java | 9 ++ .../BaseKafkaContainerManagedResource.java | 16 ++- .../KafkaContainerManagedResourceBuilder.java | 3 +- .../StrimziKafkaContainerManagedResource.java | 83 ++++++++++++- ...strimzi-default-server-sasl-ssl.properties | 4 +- .../strimzi-default-server-ssl.properties | 2 +- 13 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/LocalhostManagedResource.java diff --git a/examples/kafka/src/test/java/io/quarkus/qe/StrimziKafkaWithDefaultSaslSslMessagingIT.java b/examples/kafka/src/test/java/io/quarkus/qe/StrimziKafkaWithDefaultSaslSslMessagingIT.java index 11aa4ced4..e75be0b6c 100644 --- a/examples/kafka/src/test/java/io/quarkus/qe/StrimziKafkaWithDefaultSaslSslMessagingIT.java +++ b/examples/kafka/src/test/java/io/quarkus/qe/StrimziKafkaWithDefaultSaslSslMessagingIT.java @@ -17,24 +17,13 @@ @QuarkusScenario public class StrimziKafkaWithDefaultSaslSslMessagingIT { - private final static String SASL_USERNAME_VALUE = "client"; - private final static String SASL_PASSWORD_VALUE = "client-secret"; - private static final String TRUSTSTORE_FILE = "strimzi-server-ssl-truststore.p12"; - - @KafkaContainer(vendor = KafkaVendor.STRIMZI, protocol = KafkaProtocol.SASL_SSL, kafkaConfigResources = TRUSTSTORE_FILE) + @KafkaContainer(vendor = KafkaVendor.STRIMZI, protocol = KafkaProtocol.SASL_SSL) static final KafkaService kafka = new KafkaService(); @QuarkusApplication static final RestService app = new RestService() - .withProperty("kafka.bootstrap.servers", kafka::getBootstrapUrl) - .withProperty("kafka.security.protocol", "SASL_SSL") - .withProperty("kafka.ssl.truststore.location", TRUSTSTORE_FILE) - .withProperty("kafka.ssl.truststore.password", "top-secret") - .withProperty("kafka.ssl.truststore.type", "PKCS12") - .withProperty("kafka.sasl.mechanism", "PLAIN") - .withProperty("kafka.sasl.jaas.config", "org.apache.kafka.common.security.plain.PlainLoginModule required " - + "username=\"" + SASL_USERNAME_VALUE + "\" " - + "password=\"" + SASL_PASSWORD_VALUE + "\";"); + .withProperties(kafka::getSslProperties) + .withProperty("kafka.bootstrap.servers", kafka::getBootstrapUrl); @Test public void checkUserResourceByNormalUser() { diff --git a/pom.xml b/pom.xml index 828b7cbeb..31a06b535 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,7 @@ docker.io/infinispan/server:13.0 2 0.1.2.Beta1 + 0.5.0 @@ -200,6 +201,11 @@ quarkus-test-maven ${quarkus.platform.version} + + me.escoffier.certs + certificate-generator + ${certificate-generator.version} + diff --git a/quarkus-test-containers/src/main/java/io/quarkus/test/services/Container.java b/quarkus-test-containers/src/main/java/io/quarkus/test/services/Container.java index d390d53a8..028812668 100644 --- a/quarkus-test-containers/src/main/java/io/quarkus/test/services/Container.java +++ b/quarkus-test-containers/src/main/java/io/quarkus/test/services/Container.java @@ -19,5 +19,11 @@ String[] command() default {}; + /** + * If true, forwards Docker ports from localhost to Docker host on Windows. + * This works around issue when certificates are only generated for localhost. + */ + boolean portDockerHostToLocalhost() default false; + Class builder() default ContainerManagedResourceBuilder.class; } diff --git a/quarkus-test-containers/src/main/java/io/quarkus/test/services/containers/ContainerManagedResourceBuilder.java b/quarkus-test-containers/src/main/java/io/quarkus/test/services/containers/ContainerManagedResourceBuilder.java index 1c4fbf503..2b485ce72 100644 --- a/quarkus-test-containers/src/main/java/io/quarkus/test/services/containers/ContainerManagedResourceBuilder.java +++ b/quarkus-test-containers/src/main/java/io/quarkus/test/services/containers/ContainerManagedResourceBuilder.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.ServiceLoader; +import io.quarkus.test.bootstrap.LocalhostManagedResource; import io.quarkus.test.bootstrap.ManagedResource; import io.quarkus.test.bootstrap.ManagedResourceBuilder; import io.quarkus.test.bootstrap.ServiceContext; @@ -20,6 +21,7 @@ public class ContainerManagedResourceBuilder implements ManagedResourceBuilder { private String expectedLog; private String[] command; private Integer port; + private boolean portDockerHostToLocalhost; protected String getImage() { return image; @@ -48,6 +50,7 @@ public void init(Annotation annotation) { this.command = metadata.command(); this.expectedLog = PropertiesUtils.resolveProperty(metadata.expectedLog()); this.port = metadata.port(); + this.portDockerHostToLocalhost = metadata.portDockerHostToLocalhost(); } @Override @@ -55,10 +58,16 @@ public ManagedResource build(ServiceContext context) { this.context = context; for (ContainerManagedResourceBinding binding : managedResourceBindingsRegistry) { if (binding.appliesFor(context)) { + if (portDockerHostToLocalhost) { + return new LocalhostManagedResource(binding.init(this)); + } return binding.init(this); } } + if (portDockerHostToLocalhost) { + return new LocalhostManagedResource(new GenericDockerContainerManagedResource(this)); + } return new GenericDockerContainerManagedResource(this); } } diff --git a/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/BaseService.java b/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/BaseService.java index e32798c7c..bcc6f86a5 100644 --- a/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/BaseService.java +++ b/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/BaseService.java @@ -99,6 +99,17 @@ public T withProperties(String... propertiesFiles) { return (T) this; } + /** + * The runtime configuration property to be used if the built artifact is + * configured to be run. + * + * NOTE: unlike other {@link this::withProperties}, here we add new properties and keep the old ones + */ + public T withProperties(Supplier> newProperties) { + futureProperties.add(() -> properties.putAll(newProperties.get())); + return (T) this; + } + /** * The runtime configuration property to be used if the built artifact is * configured to be run. diff --git a/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/LocalhostManagedResource.java b/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/LocalhostManagedResource.java new file mode 100644 index 000000000..b45076f4c --- /dev/null +++ b/quarkus-test-core/src/main/java/io/quarkus/test/bootstrap/LocalhostManagedResource.java @@ -0,0 +1,115 @@ +package io.quarkus.test.bootstrap; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.condition.OS; + +import io.quarkus.test.services.URILike; +import io.quarkus.test.utils.Command; + +/** + * Forward Docker ports from localhost to Docker host on Windows. This works around issue when + * certificates are only generated for localhost. + */ +public final class LocalhostManagedResource implements ManagedResource { + + /** + * Our Linux bare-metal instances use Docker on localhost. + */ + private static final boolean FORWARD_PORT = OS.current() == OS.WINDOWS; + private final ManagedResource delegate; + + public LocalhostManagedResource(ManagedResource delegate) { + this.delegate = delegate; + } + + @Override + public String getDisplayName() { + return delegate.getDisplayName(); + } + + @Override + public void stop() { + if (FORWARD_PORT) { + try { + // stop port proxy + new Command("netsh", "interface", "portproxy", "delete", "v4tov4", + "listenport=" + getExposedPort(), "listenaddress=127.0.0.1").runAndWait(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException( + "Failed delete port proxy for Kafka container port " + getExposedPort(), e); + } + } + delegate.stop(); + } + + @Override + public void start() { + delegate.start(); + if (FORWARD_PORT) { + try { + // forward localhost:somePort to dockerIp:somePort + new Command("netsh", "interface", "portproxy", "add", "v4tov4", "listenport=" + getExposedPort(), + "listenaddress=127.0.0.1", "connectport=" + getExposedPort(), + "connectaddress=" + getDockerHost()).runAndWait(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException( + "Failed to setup forwarding for Kafka container port " + getExposedPort(), e); + } + } + } + + @Override + public URILike getURI(Protocol protocol) { + var uriLike = delegate.getURI(protocol); + if (FORWARD_PORT) { + // replace Docker IP with local host + uriLike = new URILike(uriLike.getScheme(), "localhost", uriLike.getPort(), uriLike.getPath()); + } + return uriLike; + } + + private String getDockerHost() { + return delegate.getURI(Protocol.NONE).getHost(); + } + + private int getExposedPort() { + return delegate.getURI(Protocol.NONE).getPort(); + } + + @Override + public boolean isRunning() { + return delegate.isRunning(); + } + + @Override + public boolean isFailed() { + return delegate.isFailed(); + } + + @Override + public List logs() { + return delegate.logs(); + } + + @Override + public void restart() { + delegate.restart(); + } + + @Override + public void validate() { + delegate.validate(); + } + + @Override + public void afterStart() { + delegate.afterStart(); + } + + @Override + public URILike createURI(String scheme, String host, int port) { + return delegate.createURI(scheme, host, port); + } +} diff --git a/quarkus-test-service-kafka/pom.xml b/quarkus-test-service-kafka/pom.xml index eb0c29307..5bf157d24 100644 --- a/quarkus-test-service-kafka/pom.xml +++ b/quarkus-test-service-kafka/pom.xml @@ -52,5 +52,9 @@ true provided + + me.escoffier.certs + certificate-generator + diff --git a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/bootstrap/KafkaService.java b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/bootstrap/KafkaService.java index e44b4484a..4d68e277b 100644 --- a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/bootstrap/KafkaService.java +++ b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/bootstrap/KafkaService.java @@ -1,8 +1,13 @@ package io.quarkus.test.bootstrap; +import static java.util.Objects.requireNonNull; + +import java.util.Map; + public class KafkaService extends BaseService { public static final String KAFKA_REGISTRY_URL_PROPERTY = "ts.kafka.registry.url"; + public static final String KAFKA_SSL_PROPERTIES = "ts.kafka.ssl.properties"; public String getBootstrapUrl() { var host = getURI(); @@ -12,4 +17,8 @@ public String getBootstrapUrl() { public String getRegistryUrl() { return getPropertyFromContext(KAFKA_REGISTRY_URL_PROPERTY); } + + public Map getSslProperties() { + return requireNonNull(getPropertyFromContext(KAFKA_SSL_PROPERTIES)); + } } diff --git a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/BaseKafkaContainerManagedResource.java b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/BaseKafkaContainerManagedResource.java index ff52fa450..7e024b356 100644 --- a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/BaseKafkaContainerManagedResource.java +++ b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/BaseKafkaContainerManagedResource.java @@ -1,5 +1,7 @@ package io.quarkus.test.services.containers; +import java.io.File; + import org.apache.commons.lang3.StringUtils; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; @@ -65,6 +67,10 @@ protected int getKafkaRegistryPort() { return model.getVendor().getRegistry().getPort(); } + protected String getResourceTargetName(String resource) { + return resource; + } + @Override protected GenericContainer initContainer() { GenericContainer kafkaContainer = initKafkaContainer(); @@ -78,7 +84,15 @@ protected GenericContainer initContainer() { } for (String resource : getKafkaConfigResources()) { - kafkaContainer.withCopyFileToContainer(MountableFile.forClasspathResource(resource), kafkaConfigPath + resource); + if (resource.contains(File.separator)) { + // file in the target directory + String fileName = resource.substring(resource.lastIndexOf(File.separator) + 1); + kafkaContainer.withCopyFileToContainer(MountableFile.forHostPath(resource), kafkaConfigPath + fileName); + } else { + // resource + kafkaContainer.withCopyFileToContainer(MountableFile.forClasspathResource(resource), + kafkaConfigPath + getResourceTargetName(resource)); + } } if (model.isWithRegistry()) { diff --git a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/KafkaContainerManagedResourceBuilder.java b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/KafkaContainerManagedResourceBuilder.java index 43588f886..ddcbaaff9 100644 --- a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/KafkaContainerManagedResourceBuilder.java +++ b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/KafkaContainerManagedResourceBuilder.java @@ -3,6 +3,7 @@ import java.lang.annotation.Annotation; import java.util.ServiceLoader; +import io.quarkus.test.bootstrap.LocalhostManagedResource; import io.quarkus.test.bootstrap.ManagedResource; import io.quarkus.test.bootstrap.ManagedResourceBuilder; import io.quarkus.test.bootstrap.ServiceContext; @@ -115,7 +116,7 @@ public ManagedResource build(ServiceContext context) { } if (vendor == KafkaVendor.STRIMZI) { - return new StrimziKafkaContainerManagedResource(this); + return new LocalhostManagedResource(new StrimziKafkaContainerManagedResource(this)); } return new ConfluentKafkaContainerManagedResource(this); diff --git a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/StrimziKafkaContainerManagedResource.java b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/StrimziKafkaContainerManagedResource.java index 29dc86a7a..4a2ac2e7d 100644 --- a/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/StrimziKafkaContainerManagedResource.java +++ b/quarkus-test-service-kafka/src/main/java/io/quarkus/test/services/containers/StrimziKafkaContainerManagedResource.java @@ -1,25 +1,42 @@ package io.quarkus.test.services.containers; +import static me.escoffier.certs.Format.PKCS12; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.testcontainers.containers.GenericContainer; +import io.quarkus.test.bootstrap.KafkaService; import io.quarkus.test.bootstrap.Protocol; import io.quarkus.test.services.URILike; import io.quarkus.test.services.containers.model.KafkaProtocol; import io.quarkus.test.services.containers.model.KafkaVendor; import io.quarkus.test.services.containers.strimzi.ExtendedStrimziKafkaContainer; import io.quarkus.test.utils.DockerUtils; +import me.escoffier.certs.CertificateGenerator; +import me.escoffier.certs.CertificateRequest; +import me.escoffier.certs.Pkcs12CertificateFiles; public class StrimziKafkaContainerManagedResource extends BaseKafkaContainerManagedResource { + private static final String STRIMZI_SERVER_SSL = "strimzi-server-ssl"; private static final String SSL_SERVER_PROPERTIES_DEFAULT = "strimzi-default-server-ssl.properties"; - private static final String SSL_SERVER_KEYSTORE_DEFAULT = "strimzi-default-server-ssl-keystore.p12"; + private static final String DEPRECATED_SSL_SERVER_KEYSTORE = "strimzi-default-server-ssl-keystore.p12"; + private static final String SSL_SERVER_KEYSTORE = STRIMZI_SERVER_SSL + "-keystore.p12"; + private static final String SSL_SERVER_TRUSTSTORE = STRIMZI_SERVER_SSL + "-truststore.p12"; private static final String SASL_SERVER_PROPERTIES_DEFAULT = "strimzi-default-server-sasl.properties"; private static final String SASL_SSL_SERVER_PROPERTIES_DEFAULT = "strimzi-default-server-sasl-ssl.properties"; + private static final String SASL_USERNAME_VALUE = "client"; + private static final String SASL_PASSWORD_VALUE = "client-secret"; protected StrimziKafkaContainerManagedResource(KafkaContainerManagedResourceBuilder model) { super(model); @@ -85,15 +102,77 @@ protected String getServerProperties() { return super.getServerProperties(); } + @Override + protected String getResourceTargetName(String resource) { + return DEPRECATED_SSL_SERVER_KEYSTORE.equals(resource) ? SSL_SERVER_KEYSTORE : resource; + } + @Override protected String[] getKafkaConfigResources() { List effectiveUserKafkaConfigResources = new ArrayList<>(); effectiveUserKafkaConfigResources.addAll(Arrays.asList(super.getKafkaConfigResources())); if (model.getProtocol() == KafkaProtocol.SSL || model.getProtocol() == KafkaProtocol.SASL_SSL) { - effectiveUserKafkaConfigResources.add(SSL_SERVER_KEYSTORE_DEFAULT); + final String trustStoreLocation; + if (useDefaultServerProperties()) { + if (useDefaultTrustStore()) { + // generate certs + final Path certsDir; + try { + certsDir = Files.createTempDirectory("certs"); + } catch (IOException e) { + throw new RuntimeException(e); + } + CertificateGenerator generator = new CertificateGenerator(certsDir, true); + CertificateRequest request = (new CertificateRequest()).withName(STRIMZI_SERVER_SSL) + .withClientCertificate(false).withFormat(PKCS12).withCN("localhost").withPassword("top-secret") + .withDuration(Duration.ofDays(2)); + try { + var certFile = (Pkcs12CertificateFiles) generator.generate(request).get(0); + trustStoreLocation = certFile.trustStoreFile().toString(); + effectiveUserKafkaConfigResources.add(trustStoreLocation); + effectiveUserKafkaConfigResources.add(certFile.keyStoreFile().toString()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else { + // truststore in application resources dir + trustStoreLocation = SSL_SERVER_TRUSTSTORE; + + // this we add for backwards compatibility with older tests + // TODO: remove deprecated keystore + effectiveUserKafkaConfigResources.add(DEPRECATED_SSL_SERVER_KEYSTORE); + } + } else { + trustStoreLocation = "${custom-kafka-trust-store-location:}"; + } + + var configPropertyIterator = Map.of( + "kafka.ssl.enable", "true", + "kafka.ssl.truststore.location", trustStoreLocation, + "kafka.ssl.truststore.password", "top-secret", + "kafka.ssl.truststore.type", "PKCS12"); + if (model.getProtocol() == KafkaProtocol.SASL_SSL) { + configPropertyIterator = new HashMap<>(configPropertyIterator); + configPropertyIterator.put("kafka.security.protocol", "SASL_SSL"); + configPropertyIterator.put("kafka.sasl.mechanism", "PLAIN"); + configPropertyIterator.put("kafka.sasl.jaas.config", + "org.apache.kafka.common.security.plain.PlainLoginModule required " + + "username=\"" + SASL_USERNAME_VALUE + "\" " + + "password=\"" + SASL_PASSWORD_VALUE + "\";"); + configPropertyIterator.put("ssl.endpoint.identification.algorithm", "https"); + } + model.getContext().put(KafkaService.KAFKA_SSL_PROPERTIES, Map.copyOf(configPropertyIterator)); } return effectiveUserKafkaConfigResources.toArray(new String[effectiveUserKafkaConfigResources.size()]); } + + private boolean useDefaultTrustStore() { + return super.getKafkaConfigResources().length == 0; + } + + private boolean useDefaultServerProperties() { + return model.getServerProperties() == null || model.getServerProperties().isEmpty(); + } } diff --git a/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-sasl-ssl.properties b/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-sasl-ssl.properties index d675a3401..43fa7c74a 100644 --- a/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-sasl-ssl.properties +++ b/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-sasl-ssl.properties @@ -74,14 +74,14 @@ listener.name.sasl_ssl.plain.sasl.jaas.config=org.apache.kafka.common.security.p ############################# SSL ############################# -ssl.keystore.location=/opt/kafka/config/strimzi-default-server-ssl-keystore.p12 +ssl.keystore.location=/opt/kafka/config/strimzi-server-ssl-keystore.p12 ssl.keystore.password=top-secret ssl.keystore.type=PKCS12 ssl.key.password=top-secret ssl.truststore.location=/opt/kafka/config/strimzi-server-ssl-truststore.p12 ssl.truststore.password=top-secret ssl.truststore.type=PKCS12 -ssl.endpoint.identification.algorithm= +ssl.endpoint.identification.algorithm=https ssl.client.auth=required diff --git a/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-ssl.properties b/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-ssl.properties index e17517338..37cc5bfb6 100644 --- a/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-ssl.properties +++ b/quarkus-test-service-kafka/src/main/resources/strimzi-default-server-ssl.properties @@ -62,7 +62,7 @@ inter.broker.listener.name=BROKER #### SSL #### -ssl.keystore.location=/opt/kafka/config/strimzi-default-server-ssl-keystore.p12 +ssl.keystore.location=/opt/kafka/config/strimzi-server-ssl-keystore.p12 ssl.keystore.password=top-secret ssl.keystore.type=PKCS12 ssl.key.password=top-secret