Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020-2023. AxonIQ
* Copyright (c) 2020-2026. AxonIQ
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -36,12 +36,14 @@
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import io.grpc.Status;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
Expand All @@ -59,6 +61,7 @@
import java.util.function.UnaryOperator;

import static io.axoniq.axonserver.connector.impl.ObjectUtils.randomHex;
import static java.util.Objects.requireNonNull;

/**
* The component which manages all the connections which an Axon client can establish with an Axon Server instance. Does
Expand Down Expand Up @@ -380,6 +383,9 @@ public Builder token(String token) {
/**
* Configures the use of Transport Layer Security (TLS) using the default settings from the JVM.
* <p>
* Invoking this or any other transport security method replaces the transport security configuration of
* earlier invocations.
* <p>
* Defaults to not using TLS.
*
* @return this builder for further configuration
Expand All @@ -393,6 +399,9 @@ public Builder useTransportSecurity() {
/**
* Configures the use of Transport Layer Security (TLS) using the settings from the given {@code sslContext}.
* <p>
* Invoking this or any other transport security method replaces the transport security configuration of
* earlier invocations.
* <p>
* Defaults to not using TLS.
*
* @param sslContext The context defining TLS parameters
Expand All @@ -404,6 +413,100 @@ public Builder useTransportSecurity(SslContext sslContext) {
return this;
}

/**
Comment thread
abuijze marked this conversation as resolved.
* Configures the use of Transport Layer Security (TLS), verifying the server's certificate against the
* certificates in the given {@code trustedCertificates} file. Use this when Axon Server (or a proxy in front
* of it) uses a certificate that is not signed by a Certificate Authority trusted by the JVM by default.
* <p>
* The given file must be in PEM format and is read once, when this method is invoked. A rotated certificate
* file is not picked up automatically; it requires reconfiguring the factory.
* <p>
* Invoking this or any other transport security method replaces the transport security configuration of
* earlier invocations.
* <p>
* Defaults to not using TLS.
*
* @param trustedCertificates A PEM file containing the CA certificates to verify the server's certificate
* against
* @return this builder for further configuration
* @see #useMutualTransportSecurity(File, File, File)
*/
public Builder useTransportSecurity(File trustedCertificates) {
return useTransportSecurity(buildSslContext(null, null, trustedCertificates));
}

/**
* Configures the use of mutual Transport Layer Security (mTLS), presenting the given client certificate to
* the server during the TLS handshake. The server's certificate is verified using the JVM's default trusted
* Certificate Authorities.
* <p>
* The given files must be in PEM format, with the private key in PKCS#8 format. They are read once, when
* this method is invoked. Rotated certificate files are not picked up automatically; they require
* reconfiguring the factory.
* <p>
* Invoking this or any other transport security method replaces the transport security configuration of
* earlier invocations.
* <p>
* Defaults to not using TLS.
*
* @param clientCertificateChain A PEM file containing the client's certificate chain
* @param clientPrivateKey A PEM file containing the client's PKCS#8 private key
* @return this builder for further configuration
* @see #useMutualTransportSecurity(File, File, File)
*/
public Builder useMutualTransportSecurity(File clientCertificateChain, File clientPrivateKey) {
return useMutualTransportSecurity(clientCertificateChain, clientPrivateKey, null);
}

/**
* Configures the use of mutual Transport Layer Security (mTLS), presenting the given client certificate to
* the server during the TLS handshake. The server's certificate is verified against the certificates in the
* given {@code trustedCertificates} file, or against the JVM's default trusted Certificate Authorities when
* {@code trustedCertificates} is {@code null}.
* <p>
* The given files must be in PEM format, with the private key in PKCS#8 format. They are read once, when
* this method is invoked. Rotated certificate files are not picked up automatically; they require
* reconfiguring the factory.
* <p>
* Invoking this or any other transport security method replaces the transport security configuration of
* earlier invocations.
* <p>
* Defaults to not using TLS.
*
* @param clientCertificateChain A PEM file containing the client's certificate chain
* @param clientPrivateKey A PEM file containing the client's PKCS#8 private key
* @param trustedCertificates A PEM file containing the CA certificates to verify the server's certificate
* against, or {@code null} to use the JVM's default trusted Certificate
* Authorities
* @return this builder for further configuration
*/
public Builder useMutualTransportSecurity(File clientCertificateChain,
File clientPrivateKey,
File trustedCertificates) {
requireNonNull(clientCertificateChain, "clientCertificateChain may not be null");
requireNonNull(clientPrivateKey, "clientPrivateKey may not be null");
return useTransportSecurity(buildSslContext(clientCertificateChain, clientPrivateKey,
trustedCertificates));
}

private static SslContext buildSslContext(File clientCertificateChain,
File clientPrivateKey,
File trustedCertificates) {
SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient();
if (clientCertificateChain != null) {
sslContextBuilder.keyManager(clientCertificateChain, clientPrivateKey);
}
if (trustedCertificates != null) {
sslContextBuilder.trustManager(trustedCertificates);
}
try {
return sslContextBuilder.build();
} catch (Exception e) {
throw new IllegalArgumentException("Could not create an SSL context from the given certificate files",
e);
}
}

/**
* Indicates whether the connector should always reconnect via the Routing Servers. When {@code true} (default),
* the connector will contact the Routing Servers for a new destination each time a connection is dropped. When
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2026. AxonIQ
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.axoniq.axonserver.connector;
Comment thread
abuijze marked this conversation as resolved.

import io.axoniq.axonserver.connector.impl.ServerAddress;
import io.axoniq.axonserver.grpc.control.ClientIdentification;
import io.axoniq.axonserver.grpc.control.PlatformInboundInstruction;
import io.axoniq.axonserver.grpc.control.PlatformInfo;
import io.axoniq.axonserver.grpc.control.PlatformOutboundInstruction;
import io.axoniq.axonserver.grpc.control.PlatformServiceGrpc;
import io.grpc.Server;
import io.grpc.TlsServerCredentials;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.stub.StreamObserver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.io.File;
import java.io.IOException;
import java.net.ServerSocket;
import java.util.concurrent.TimeUnit;
import java.util.function.UnaryOperator;

import static io.axoniq.axonserver.connector.testutils.AssertUtils.assertWithin;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Test class validating the (mutual) TLS configuration options of the {@link AxonServerConnectionFactory}, against a
* stub PlatformService endpoint secured with TLS.
*
* @author Allard Buijze
*/
class MutualTlsConnectionTest {

private Server server;
private int serverPort;
private AxonServerConnectionFactory connectionFactory;

@AfterEach
void tearDown() {
if (connectionFactory != null) {
connectionFactory.shutdown();
}
if (server != null) {
server.shutdownNow();
}
}

@Test
void connectsWithClientCertificateWhenServerRequiresClientAuth() throws Exception {
startServer(TlsServerCredentials.ClientAuth.REQUIRE);

AxonServerConnection connection = connect(
factory -> factory.useMutualTransportSecurity(tlsFile("client.crt"),
tlsFile("client.key"),
tlsFile("ca.crt"))
);

assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnected()));
}

@Test
void failsToConnectWithoutClientCertificateWhenServerRequiresClientAuth() throws Exception {
startServer(TlsServerCredentials.ClientAuth.REQUIRE);

AxonServerConnection connection = connect(
factory -> factory.useTransportSecurity(tlsFile("ca.crt"))
);

assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnectionFailed()));
assertFalse(connection.isConnected());
}

@Test
void connectsWithTrustedCertificatesOnlyWhenServerDoesNotRequireClientAuth() throws Exception {
startServer(TlsServerCredentials.ClientAuth.NONE);

AxonServerConnection connection = connect(
factory -> factory.useTransportSecurity(tlsFile("ca.crt"))
);

assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnected()));
}

@Test
void mutualTransportSecurityRejectsMissingCertificateOrKey() {
AxonServerConnectionFactory.Builder builder = AxonServerConnectionFactory.forClient("mtls-test");

assertThrows(NullPointerException.class,

Check warning on line 105 in src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=AxonIQ_axonserver-connector-java&issues=AZ9mUsxldJ16KzQTwdjl&open=AZ9mUsxldJ16KzQTwdjl&pullRequest=501
() -> builder.useMutualTransportSecurity(null, tlsFile("client.key")));
assertThrows(NullPointerException.class,

Check warning on line 107 in src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=AxonIQ_axonserver-connector-java&issues=AZ9mUsxldJ16KzQTwdjm&open=AZ9mUsxldJ16KzQTwdjm&pullRequest=501
() -> builder.useMutualTransportSecurity(tlsFile("client.crt"), null));
assertThrows(IllegalArgumentException.class,

Check warning on line 109 in src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=AxonIQ_axonserver-connector-java&issues=AZ9gQuVxxi9ITgheSRqL&open=AZ9gQuVxxi9ITgheSRqL&pullRequest=501
() -> builder.useMutualTransportSecurity(new File("does-not-exist.crt"),
new File("does-not-exist.key")));
}

private void startServer(TlsServerCredentials.ClientAuth clientAuth) throws Exception {
TlsServerCredentials.Builder credentials = TlsServerCredentials.newBuilder()
.keyManager(tlsFile("server.crt"),
tlsFile("server.key"))
.clientAuth(clientAuth);
if (clientAuth != TlsServerCredentials.ClientAuth.NONE) {
credentials.trustManager(tlsFile("ca.crt"));
}
server = NettyServerBuilder.forPort(freePort(), credentials.build())
.addService(new StubPlatformService())
.build()
.start();
}

private AxonServerConnection connect(UnaryOperator<AxonServerConnectionFactory.Builder> tlsConfig) {
connectionFactory = tlsConfig.apply(AxonServerConnectionFactory.forClient("mtls-test")
.routingServers(new ServerAddress("localhost",
serverPort)))
.build();
return connectionFactory.connect("default");
}

private int freePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
serverPort = socket.getLocalPort();
return serverPort;
}
}

private File tlsFile(String name) {
try {
return new File(getClass().getResource("/tls/" + name).toURI());
} catch (Exception e) {
throw new IllegalStateException("Could not locate test resource /tls/" + name, e);
}
}

private static class StubPlatformService extends PlatformServiceGrpc.PlatformServiceImplBase {

@Override
public void getPlatformServer(ClientIdentification request, StreamObserver<PlatformInfo> responseObserver) {
responseObserver.onNext(PlatformInfo.newBuilder().setSameConnection(true).build());
responseObserver.onCompleted();
}

@Override
public StreamObserver<PlatformInboundInstruction> openStream(
StreamObserver<PlatformOutboundInstruction> responseObserver) {
return new StreamObserver<PlatformInboundInstruction>() {
@Override
public void onNext(PlatformInboundInstruction value) {
// no-op: the stub only needs to keep the stream open
}

@Override
public void onError(Throwable t) {
// no-op
}

@Override
public void onCompleted() {
responseObserver.onCompleted();
}
};
}
}
}
19 changes: 19 additions & 0 deletions src/test/resources/tls/ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDFzCCAf+gAwIBAgIULHat2vB2ieUzjtCWRdRkIXXWe00wDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPVGVzdCBUcnVzdGVkIENBMCAXDTI2MDcxNDA4MzU1M1oY
DzIxMjYwNjIwMDgzNTUzWjAaMRgwFgYDVQQDDA9UZXN0IFRydXN0ZWQgQ0EwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCo3ODm78QSpgh4Sy5S/USAbMqT
M6XaNe41+jGwoHrBaFaeCEXADtSSE8O/oFHrrbWnbFxpdMSrXHVCicnl2kPJxQzV
ezo5NuPDEdjKSfjA3Y3uCo71EBP3iUmUZdk9um2gX+t99dIIIq+A0stZs10IWdpr
NTPPelO5qtqO2m/3/aUjKDj9f47UfTaxi6aNseAlrll7ccNI2Csz9x+2zpftsq5y
kCI/AD2ZOkX+1dqi44sVGU6+x/QsFQB3QVRKsdgXIBp8CVzpt4PkzPzlKNq0WiEt
cAcv5kxkDe/e4ZBBnf5l+V4TxHF/z8LPwp8Mr47OpYtGCma25q+jPtK+quUxAgMB
AAGjUzBRMB0GA1UdDgQWBBQHS4hLKOqr4vBgRc8WoyJSVKLiBDAfBgNVHSMEGDAW
gBQHS4hLKOqr4vBgRc8WoyJSVKLiBDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQBTkMW0nxZM5+12JyzEbXdLW+Az9LGc9YdlLz/0YAaGYXremQXy
pVun2qx730vwkS8nTvat0WPPXfIR8QzO6WTf/BNB4drWN9znqYW58KY78tnSUjNb
uf707QeHCbICH1z+tErQf48JU6HeOLPvXvyvxc4RZAm4kavWbzsKDr8IJfMSh9RC
Aiv0zE/RUHL1wmd6w6UQqmzbzjqi3mv+h0GdRPrTlpus7GngTN8R5Bu0s05ONtSy
fqMTqdQthxdt1jWlnacPrn/LZ/ldAamqecazgBXuKNzqfjfvAOzCgtVl4KWmqjKT
xwyCT49G9g5taftkgR+9NTFKf/UWYbUUY6YQ
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions src/test/resources/tls/ca.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCo3ODm78QSpgh4
Sy5S/USAbMqTM6XaNe41+jGwoHrBaFaeCEXADtSSE8O/oFHrrbWnbFxpdMSrXHVC
icnl2kPJxQzVezo5NuPDEdjKSfjA3Y3uCo71EBP3iUmUZdk9um2gX+t99dIIIq+A
0stZs10IWdprNTPPelO5qtqO2m/3/aUjKDj9f47UfTaxi6aNseAlrll7ccNI2Csz
9x+2zpftsq5ykCI/AD2ZOkX+1dqi44sVGU6+x/QsFQB3QVRKsdgXIBp8CVzpt4Pk
zPzlKNq0WiEtcAcv5kxkDe/e4ZBBnf5l+V4TxHF/z8LPwp8Mr47OpYtGCma25q+j
PtK+quUxAgMBAAECggEAHuunMRJpbGLDvUdC+cZPo1aId5DnB0CF2JNyQt337G9v
IOvx3bu5d/AvkWjJZ2b9EuW4PEU5+Ajrnd3+Pt0LOaif831niW+AIEml0VDfQG2x
XgvH5p7lkReuBgLqVy/KgCngRMXdCklTqrquqsO5vtw5LxVkYdML/r0G3U6pXxbi
V0aHp/8FCZEUbIte0JeZgIUjnCHg1O80JDCMHtYhyIy3VOauTbeAVfcYGBPBOVpo
YwxOhZpBzaoDYZze0e9+6Jtcu01uId85HvAULRoMDoUmNQOBD2I96ke3sRhB1Ac+
6quwq3K+dzjla9zqjpLnP8W6AUyhAwQhQxICK05fOwKBgQDWLTNQ67w04G9RyS7A
2JZQw2KnES0sswvL1HcjNkZ8PiFG5GWAN9YBNE7XiDj9EIOJdniEumav/9BCC4Td
JmSH2/s+Xp8fxIZonz27BIpHJaxWzsY8PXrnZx3NsAKZptX2bJq13hb57UDv7aZl
pgziYNMF4HEw09yHUTgDOpuJuwKBgQDJ1mtd9mv7z7j9wd90cZIqnhvd5/rzC0EH
BC6xQ9idPpofELBXHw0CZFsYTEVlZKVoBC1TZmWGLVxDCr4sZaWvtn45kZ66jWHv
yDdwnOqJsIzm1Z5+43WCpnxHEiXdAqBKPAVMglviCx8EcZMMMptA7lnQ8faN/J0/
20NbjoBYAwKBgDj9uBz9TYyIeDa8IQvB1mXRSAwKvwmY7zei5rzg8WqYIcS6e+Pd
d73ETIJ/xiJY5ZeLhdBTxYVpveIFLKqoT7HzMGzNQuxyIA3w6b09HQqHlM9YoFlh
RrYSs1SLVHXMdjznc1eXrGd+4Xu2skN0JxOKDj0PFy+WAKWlUBFT5BWHAoGABL+8
yvsvUz9x9NaI8W5yp/oMYc5Bg1Jydz3L83PLNvfwHGcCHyql3baKximVQGt70NS4
VEwGe3+5ugIYs2a43UeH9MbOW0lUUpX9Z/LpOdAjoJLKJeYoL6jHJ8zvgyG6r8R1
w3UVwF3OnwYlFZZjPMXe6yGmUXTuJkB8dz0FHA0CgYEAzzbTyQpTAX9aMb1/Ij0W
1hwc2eafCXmy4e5VkKf5YcVKDZMcSvkicC4VAlUxWOSQ/ZwXmv4mTamnlbXS2oW2
Rdan6oD2FAUG5aI8LXyG1bfuJcp3dWjgfrfJB0cS134jXk+XKMdNvsi4WsV60hJa
Ksmu2ROmTUXC2meV+93V3Eg=
-----END PRIVATE KEY-----
17 changes: 17 additions & 0 deletions src/test/resources/tls/client.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICuTCCAaECFGQlQgq3PBbtJnTpsXCsSIiYtvhYMA0GCSqGSIb3DQEBCwUAMBox
GDAWBgNVBAMMD1Rlc3QgVHJ1c3RlZCBDQTAgFw0yNjA3MTQwODM1NTRaGA8yMTI2
MDYyMDA4MzU1NFowFjEUMBIGA1UEAwwLdGVzdC1jbGllbnQwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCVBtGyAy86lzCzmhqyZlmECQJZVHgPlT2Y4cRG
MUupbFCH1QOjqsV7nuMoW7EQYPU3Xs9aBrabij3BsbRCfxcZHNGVxjEAYdjPyqi1
uMGcLvNrbj4XM6CD/kV4QZpj6wHX2yCO1onTvGK6f40MfoTbuFAqPS6RngO6fmH9
ODuXawTH6o4bpYMKwUeK9HFpOtJvFQW+MePTmU4J7ZPhPs/8WZW8DmLV5TVZzZ6Y
dpYHLr15UHN7E6O4BRoHF8oLXPOoNBTq+xx1UFFY3XrJP4Xr9VVQQVQnO3V4Kv3I
mAPTrS/h9tn6QFQbxkYT8uUt/m6D5zgqvQCjSMWc/A4P2QXVAgMBAAEwDQYJKoZI
hvcNAQELBQADggEBAH9HhGR+SDDHknn9kkV4BEl4uWZIHW18ppfEfzBPllhA55Y+
ZqboPvuzoRbuYntGFL/P9Ws3svmoIDUfVQPDMmjm4Kujnjb1jDyY5bmDdQs2RjDH
HJnXPqxA0+mlNXrpbsstqqZdFa1WxeT+lqTgALrNT9teBnA4cBczoQFGHwH+ltRs
ZYaycFa4blgclOL75oScbiQigB1fWPY7QGMnEwQLGN2I6HhKYyy/dFua6wTLGIeG
3ifbYec4T/lwLOeT+kVQz8iDTN3fn75YnUortUdkZ0j5mVFxsx5oOKXj7RYdF0GA
I223lYDUCxGRnV+euTCPYTWFYOsERXIUt3iM+sk=
-----END CERTIFICATE-----
Loading