2 * Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
4 * This program and the accompanying materials are made available under the
5 * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6 * and is available at http://www.eclipse.org/legal/epl-v10.html
8 package org.opendaylight.netconf.callhome.server.tls;
10 import static org.mockito.ArgumentMatchers.any;
11 import static org.mockito.ArgumentMatchers.eq;
12 import static org.mockito.ArgumentMatchers.nullable;
13 import static org.mockito.Mockito.doReturn;
14 import static org.mockito.Mockito.timeout;
15 import static org.mockito.Mockito.times;
16 import static org.mockito.Mockito.verify;
18 import com.google.common.util.concurrent.SettableFuture;
19 import io.netty.channel.Channel;
20 import io.netty.handler.ssl.ClientAuth;
21 import io.netty.handler.ssl.SslContextBuilder;
22 import io.netty.handler.ssl.SslHandler;
23 import io.netty.util.HashedWheelTimer;
24 import io.netty.util.concurrent.Promise;
25 import java.math.BigInteger;
26 import java.net.InetAddress;
27 import java.net.InetSocketAddress;
28 import java.net.ServerSocket;
29 import java.security.KeyPair;
30 import java.security.KeyPairGenerator;
31 import java.security.KeyStore;
32 import java.security.PublicKey;
33 import java.security.SecureRandom;
34 import java.security.cert.Certificate;
35 import java.security.spec.RSAKeyGenParameterSpec;
36 import java.time.Duration;
37 import java.time.Instant;
38 import java.util.Date;
40 import java.util.concurrent.TimeUnit;
41 import java.util.concurrent.atomic.AtomicInteger;
42 import javax.net.ssl.KeyManagerFactory;
43 import javax.net.ssl.SSLHandshakeException;
44 import javax.net.ssl.TrustManagerFactory;
45 import org.bouncycastle.asn1.x500.X500Name;
46 import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
47 import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
48 import org.bouncycastle.jce.provider.BouncyCastleProvider;
49 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
50 import org.eclipse.jdt.annotation.NonNull;
51 import org.eclipse.jdt.annotation.Nullable;
52 import org.junit.jupiter.api.Test;
53 import org.junit.jupiter.api.extension.ExtendWith;
54 import org.mockito.Mock;
55 import org.mockito.junit.jupiter.MockitoExtension;
56 import org.opendaylight.netconf.callhome.server.CallHomeStatusRecorder;
57 import org.opendaylight.netconf.client.NetconfClientSession;
58 import org.opendaylight.netconf.client.NetconfClientSessionListener;
59 import org.opendaylight.netconf.nettyutil.AbstractChannelInitializer;
60 import org.opendaylight.netconf.server.NetconfServerSession;
61 import org.opendaylight.netconf.server.NetconfServerSessionNegotiatorFactory;
62 import org.opendaylight.netconf.server.api.monitoring.NetconfMonitoringService;
63 import org.opendaylight.netconf.server.api.monitoring.SessionListener;
64 import org.opendaylight.netconf.server.impl.DefaultSessionIdProvider;
65 import org.opendaylight.netconf.server.osgi.AggregatedNetconfOperationServiceFactory;
66 import org.opendaylight.netconf.transport.api.TransportChannel;
67 import org.opendaylight.netconf.transport.api.TransportChannelListener;
68 import org.opendaylight.netconf.transport.tcp.BootstrapFactory;
69 import org.opendaylight.netconf.transport.tls.TLSServer;
70 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.Host;
71 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.IetfInetUtil;
72 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.PortNumber;
73 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev231228.netconf.client.initiate.stack.grouping.transport.ssh.ssh.TcpClientParametersBuilder;
74 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.monitoring.rev101004.netconf.state.Capabilities;
75 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.monitoring.rev101004.netconf.state.CapabilitiesBuilder;
76 import org.opendaylight.yangtools.yang.common.Uint16;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
80 @ExtendWith(MockitoExtension.class)
81 public class CallHomeTlsServerTest {
82 private static final Logger LOG = LoggerFactory.getLogger("TEST");
84 private static final AtomicInteger COUNTER = new AtomicInteger();
85 private static final char[] EMPTY_SECRET = new char[0];
86 private static final long TIMEOUT = 5000L;
87 private static final Capabilities EMPTY_CAPABILITIES = new CapabilitiesBuilder().setCapability(Set.of()).build();
90 private NetconfMonitoringService monitoringService;
92 private SessionListener serverSessionListener;
94 private NetconfClientSessionListener clientSessionListener;
96 private CallHomeStatusRecorder statusRecorder;
99 void integrationTest() throws Exception {
101 final var serverCert = generateCertData();
102 final var clientCert1 = generateCertData();
103 final var clientCert2 = generateCertData();
104 final var clientCert3 = generateCertData();
106 // SSL context for call-home server (acting as client): denies client 1, allows client 2,3
107 final var serverCtx = SslContextBuilder.forClient()
108 .keyManager(keyManagerFor(serverCert))
109 .trustManager(trustManagerFor(clientCert2, clientCert3)).build();
111 // SSL context for call-home clients (acting as servers)
112 final var serverTrustMgr = trustManagerFor(serverCert);
113 final var clientCtx1 = SslContextBuilder.forServer(keyManagerFor(clientCert1))
114 .trustManager(serverTrustMgr).clientAuth(ClientAuth.REQUIRE).build();
115 final var clientCtx2 = SslContextBuilder.forServer(keyManagerFor(clientCert2))
116 .trustManager(serverTrustMgr).clientAuth(ClientAuth.REQUIRE).build();
117 final var clientCtx3 = SslContextBuilder.forServer(keyManagerFor(clientCert3))
118 .trustManager(serverTrustMgr).clientAuth(ClientAuth.REQUIRE).build();
121 final var authProvider = new CallHomeTlsAuthProvider() {
123 public @Nullable String idFor(@NonNull PublicKey publicKey) {
124 // identify client 3 only
125 return clientCert3.keyPair.getPublic().equals(publicKey) ? "client-id" : null;
129 public SslHandler createSslHandler(final Channel channel) {
130 return serverCtx.newHandler(channel.alloc());
134 // Netconf layer for clients
135 doReturn(serverSessionListener).when(monitoringService).getSessionListener();
136 doReturn(EMPTY_CAPABILITIES).when(monitoringService).getCapabilities();
138 final var negotiatorFactory = NetconfServerSessionNegotiatorFactory.builder()
139 .setTimer(new HashedWheelTimer())
140 .setAggregatedOpService(new AggregatedNetconfOperationServiceFactory())
141 .setIdProvider(new DefaultSessionIdProvider())
142 .setConnectionTimeoutMillis(TIMEOUT)
143 .setMonitoringService(monitoringService)
145 final var netconfTransportListener = new TestNetconfServerInitializer(negotiatorFactory);
147 // tcp layer for clients
148 final var bootstrapFactory = new BootstrapFactory("call-home-test-client", 0);
149 final var serverPort = serverPort();
150 final var tcpConnectParams = new TcpClientParametersBuilder()
151 .setRemoteAddress(new Host(IetfInetUtil.ipAddressFor(InetAddress.getLoopbackAddress())))
152 .setRemotePort(new PortNumber(Uint16.valueOf(serverPort))).build();
154 // Session context manager
155 final var contextMgr = new CallHomeTlsSessionContextManager(authProvider, statusRecorder) {
156 // inject netconf session listener
158 public CallHomeTlsSessionContext createContext(final String id, final Channel channel) {
159 return new CallHomeTlsSessionContext(id, channel, clientSessionListener, SettableFuture.create());
163 // start Call-Home server
164 final var server = CallHomeTlsServer.builder()
165 .withAuthProvider(authProvider)
166 .withSessionContextManager(contextMgr)
167 .withStatusRecorder(statusRecorder)
168 .withPort(serverPort).build();
170 TLSServer client1 = null;
171 TLSServer client2 = null;
172 TLSServer client3 = null;
175 // client 1 rejected on handshake, ensure exception
176 client1 = TLSServer.connect(
177 netconfTransportListener, bootstrapFactory.newBootstrap(), tcpConnectParams,
178 channel -> clientCtx1.newHandler(channel.alloc())).get(TIMEOUT, TimeUnit.MILLISECONDS);
179 verify(statusRecorder, timeout(TIMEOUT).times(1))
180 .onTransportChannelFailure(any(SSLHandshakeException.class));
182 // client 2 rejected because it's not identified by public key accepted on handshake stage
183 client2 = TLSServer.connect(
184 netconfTransportListener, bootstrapFactory.newBootstrap(), tcpConnectParams,
185 channel -> clientCtx2.newHandler(channel.alloc())).get(TIMEOUT, TimeUnit.MILLISECONDS);
186 verify(statusRecorder, timeout(TIMEOUT).times(1))
187 .reportUnknown(any(InetSocketAddress.class), eq(clientCert2.keyPair.getPublic()));
190 client3 = TLSServer.connect(
191 netconfTransportListener, bootstrapFactory.newBootstrap(), tcpConnectParams,
192 channel -> clientCtx3.newHandler(channel.alloc())).get(TIMEOUT, TimeUnit.MILLISECONDS);
193 // verify netconf session established
194 verify(clientSessionListener, timeout(TIMEOUT).times(1)).onSessionUp(any(NetconfClientSession.class));
195 verify(serverSessionListener, timeout(TIMEOUT).times(1)).onSessionUp(any(NetconfServerSession.class));
196 verify(statusRecorder, times(1)).reportSuccess("client-id");
200 shutdownClient(client1);
201 shutdownClient(client2);
202 shutdownClient(client3);
205 // validate disconnect reported
206 verify(serverSessionListener, timeout(TIMEOUT).times(1)).onSessionDown(any(NetconfServerSession.class));
207 verify(clientSessionListener, timeout(TIMEOUT).times(1))
208 .onSessionDown(any(NetconfClientSession.class), nullable(Exception.class));
209 verify(statusRecorder, times(1)).reportDisconnected("client-id");
212 private static void shutdownClient(final @Nullable TLSServer client) throws Exception {
213 if (client != null) {
214 client.shutdown().get(TIMEOUT, TimeUnit.MILLISECONDS);
218 public static int serverPort() throws Exception {
219 try (var socket = new ServerSocket(0)) {
220 return socket.getLocalPort();
224 private static CertData generateCertData() throws Exception {
226 final var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
227 keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4), new SecureRandom());
228 final var keyPair = keyPairGenerator.generateKeyPair();
230 final var now = Instant.now();
231 final var contentSigner = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
232 final var x500Name = new X500Name("CN=TestCertificate" + COUNTER.incrementAndGet());
233 final var certificateBuilder = new JcaX509v3CertificateBuilder(x500Name,
234 BigInteger.valueOf(now.toEpochMilli()),
235 Date.from(now), Date.from(now.plus(Duration.ofDays(365))),
237 keyPair.getPublic());
238 final var certificate = new JcaX509CertificateConverter()
239 .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
240 return new CertData(keyPair, certificate);
243 private static KeyManagerFactory keyManagerFor(final CertData... certs) throws Exception {
244 final var keyManager = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
245 keyManager.init(keyStoreWithCerts(certs), EMPTY_SECRET);
249 private static TrustManagerFactory trustManagerFor(final CertData... certs) throws Exception {
250 final var trustManager = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
251 trustManager.init(keyStoreWithCerts(certs));
255 private static KeyStore keyStoreWithCerts(final CertData... certs) throws Exception {
256 final var keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
257 keyStore.load(null, null);
258 for (var certData : certs) {
259 keyStore.setCertificateEntry("cert" + COUNTER.incrementAndGet(), certData.certificate());
260 keyStore.setKeyEntry("key" + COUNTER.incrementAndGet(), certData.keyPair().getPrivate(),
261 EMPTY_SECRET, new Certificate[]{certData.certificate()});
266 private record CertData(KeyPair keyPair, Certificate certificate) {
269 // Same as org.opendaylight.netconf.server.ServerTransportInitializer but with explicit fireChannelActive()
270 private record TestNetconfServerInitializer(NetconfServerSessionNegotiatorFactory negotiatorFactory)
271 implements TransportChannelListener {
274 public void onTransportChannelEstablished(final TransportChannel channel) {
275 LOG.debug("Call-Home client's transport channel {} established", channel);
276 final var nettyChannel = channel.channel();
277 new AbstractChannelInitializer<NetconfServerSession>() {
279 protected void initializeSessionNegotiator(final Channel ch,
280 final Promise<NetconfServerSession> promise) {
282 .addLast(NETCONF_SESSION_NEGOTIATOR, negotiatorFactory.getSessionNegotiator(ch, promise));
284 }.initialize(nettyChannel, nettyChannel.eventLoop().newPromise());
285 // below line is required
286 nettyChannel.pipeline().fireChannelActive();
290 public void onTransportChannelFailed(final Throwable cause) {
291 LOG.error("Call-Home client's transport channel failed", cause);