2 * Copyright (c) 2023 PANTHEON.tech s.r.o. 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.transport.ssh;
10 import static org.junit.jupiter.api.Assertions.assertEquals;
11 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
12 import static org.junit.jupiter.api.Assertions.assertNotNull;
13 import static org.junit.jupiter.api.Assertions.assertTrue;
14 import static org.mockito.ArgumentMatchers.any;
15 import static org.mockito.Mockito.timeout;
16 import static org.mockito.Mockito.verify;
17 import static org.mockito.Mockito.when;
18 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientAuthHostBased;
19 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientAuthWithPassword;
20 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientAuthWithPublicKey;
21 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientIdentityHostBased;
22 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientIdentityWithPassword;
23 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildClientIdentityWithPublicKey;
24 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildServerAuthWithCertificate;
25 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildServerAuthWithPublicKey;
26 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildServerIdentityWithCertificate;
27 import static org.opendaylight.netconf.transport.ssh.TestUtils.buildServerIdentityWithKeyPair;
28 import static org.opendaylight.netconf.transport.ssh.TestUtils.generateKeyPairWithCertificate;
30 import com.google.common.util.concurrent.ListenableFuture;
31 import com.google.common.util.concurrent.SettableFuture;
32 import io.netty.channel.Channel;
33 import io.netty.channel.ChannelHandlerContext;
34 import io.netty.channel.ChannelInboundHandlerAdapter;
35 import java.io.IOException;
36 import java.net.InetAddress;
37 import java.net.ServerSocket;
38 import java.util.Collection;
39 import java.util.List;
40 import java.util.concurrent.TimeUnit;
41 import java.util.concurrent.atomic.AtomicInteger;
42 import java.util.concurrent.atomic.AtomicReference;
43 import java.util.stream.Stream;
44 import org.apache.commons.codec.digest.Crypt;
45 import org.junit.jupiter.api.AfterAll;
46 import org.junit.jupiter.api.BeforeAll;
47 import org.junit.jupiter.api.BeforeEach;
48 import org.junit.jupiter.api.DisplayName;
49 import org.junit.jupiter.api.Test;
50 import org.junit.jupiter.api.extension.ExtendWith;
51 import org.junit.jupiter.params.ParameterizedTest;
52 import org.junit.jupiter.params.provider.Arguments;
53 import org.junit.jupiter.params.provider.MethodSource;
54 import org.mockito.ArgumentCaptor;
55 import org.mockito.Captor;
56 import org.mockito.Mock;
57 import org.mockito.junit.jupiter.MockitoExtension;
58 import org.opendaylight.netconf.shaded.sshd.client.auth.password.PasswordIdentityProvider;
59 import org.opendaylight.netconf.shaded.sshd.client.session.ClientSession;
60 import org.opendaylight.netconf.shaded.sshd.common.session.Session;
61 import org.opendaylight.netconf.shaded.sshd.server.auth.password.UserAuthPasswordFactory;
62 import org.opendaylight.netconf.shaded.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
63 import org.opendaylight.netconf.shaded.sshd.server.session.ServerSession;
64 import org.opendaylight.netconf.transport.api.TransportChannel;
65 import org.opendaylight.netconf.transport.api.TransportChannelListener;
66 import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
67 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.Host;
68 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.IetfInetUtil;
69 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.PortNumber;
70 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev231228.SshClientGrouping;
71 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev231228.ssh.client.grouping.ClientIdentity;
72 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev231228.ssh.client.grouping.ClientIdentityBuilder;
73 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev231228.ssh.client.grouping.ServerAuthentication;
74 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev231228.SshServerGrouping;
75 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev231228.ssh.server.grouping.ClientAuthentication;
76 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev231228.ssh.server.grouping.ServerIdentity;
77 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.client.rev231228.TcpClientGrouping;
78 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev231228.TcpServerGrouping;
79 import org.opendaylight.yangtools.yang.common.Uint16;
81 @ExtendWith(MockitoExtension.class)
82 public class SshClientServerTest {
83 private static final String RSA = "RSA";
84 private static final String EC = "EC";
85 private static final String USER = "user";
86 private static final String PASSWORD = "pa$$w0rd";
87 private static final String SUBSYSTEM = "subsystem";
88 private static final AtomicInteger COUNTER = new AtomicInteger(0);
89 private static final AtomicReference<String> USERNAME = new AtomicReference<>(USER);
91 private static SSHTransportStackFactory FACTORY;
94 private TcpClientGrouping tcpClientConfig;
96 private SshClientGrouping sshClientConfig;
98 private TransportChannelListener clientListener;
100 private TcpServerGrouping tcpServerConfig;
102 private SshServerGrouping sshServerConfig;
104 private TransportChannelListener serverListener;
107 ArgumentCaptor<TransportChannel> clientTransportChannelCaptor;
109 ArgumentCaptor<TransportChannel> serverTransportChannelCaptor;
111 private ServerSocket socket;
114 static void beforeAll() {
115 FACTORY = new SSHTransportStackFactory("IntegrationTest", 0);
119 static void afterAll() {
124 void beforeEach() throws IOException {
126 // create temp socket to get available port for test
127 socket = new ServerSocket(0);
128 final var localAddress = IetfInetUtil.ipAddressFor(InetAddress.getLoopbackAddress());
129 final var localPort = new PortNumber(Uint16.valueOf(socket.getLocalPort()));
132 when(tcpServerConfig.getLocalAddress()).thenReturn(localAddress);
133 when(tcpServerConfig.requireLocalAddress()).thenCallRealMethod();
134 when(tcpServerConfig.getLocalPort()).thenReturn(localPort);
135 when(tcpServerConfig.requireLocalPort()).thenCallRealMethod();
137 when(tcpClientConfig.getRemoteAddress()).thenReturn(new Host(localAddress));
138 when(tcpClientConfig.requireRemoteAddress()).thenCallRealMethod();
139 when(tcpClientConfig.getRemotePort()).thenReturn(localPort);
140 when(tcpClientConfig.requireRemotePort()).thenCallRealMethod();
143 @ParameterizedTest(name = "SSH Server Host Key Verification -- {0}")
144 @MethodSource("itServerKeyVerifyArgs")
145 void itServerKeyVerify(final String testDesc, final ServerIdentity serverIdentity,
146 final ServerAuthentication serverAuth) throws Exception {
147 final var clientIdentity = buildClientIdentityWithPassword(getUsername(), PASSWORD);
148 final var clientAuth = buildClientAuthWithPassword(getUsernameAndUpdate(), "$0$" + PASSWORD);
149 when(sshClientConfig.getClientIdentity()).thenReturn(clientIdentity);
150 when(sshClientConfig.getServerAuthentication()).thenReturn(serverAuth);
151 when(sshServerConfig.getServerIdentity()).thenReturn(serverIdentity);
152 when(sshServerConfig.getClientAuthentication()).thenReturn(clientAuth);
154 () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, sshServerConfig),
155 () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig));
158 private static Stream<Arguments> itServerKeyVerifyArgs() throws Exception {
159 final var rsaKeyData = generateKeyPairWithCertificate(RSA);
160 final var ecKeyData = generateKeyPairWithCertificate(EC);
162 Arguments.of("RSA public key",
163 buildServerIdentityWithKeyPair(rsaKeyData), buildServerAuthWithPublicKey(rsaKeyData)),
164 Arguments.of("EC public key",
165 buildServerIdentityWithKeyPair(ecKeyData), buildServerAuthWithPublicKey(ecKeyData)),
166 Arguments.of("RSA certificate",
167 buildServerIdentityWithCertificate(rsaKeyData), buildServerAuthWithCertificate(rsaKeyData)),
168 Arguments.of("EC certificate",
169 buildServerIdentityWithCertificate(ecKeyData), buildServerAuthWithCertificate(ecKeyData))
173 @ParameterizedTest(name = "SSH User Auth using {0}")
174 @MethodSource("itUserAuthArgs")
175 void itUserAuth(final String testDesc, final ClientIdentity clientIdentity, final ClientAuthentication clientAuth)
177 final var serverIdentity = buildServerIdentityWithKeyPair(generateKeyPairWithCertificate(RSA)); // required
178 when(sshClientConfig.getClientIdentity()).thenReturn(clientIdentity);
179 when(sshClientConfig.getServerAuthentication()).thenReturn(null); // Accept all keys
180 when(sshServerConfig.getServerIdentity()).thenReturn(serverIdentity);
181 when(sshServerConfig.getClientAuthentication()).thenReturn(clientAuth);
183 () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, sshServerConfig),
184 () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig));
187 private static Stream<Arguments> itUserAuthArgs() throws Exception {
188 final var rsaKeyData = generateKeyPairWithCertificate(RSA);
189 final var ecKeyData = generateKeyPairWithCertificate(EC);
191 Arguments.of("Password -- clear text ",
192 buildClientIdentityWithPassword(getUsername(), PASSWORD),
193 buildClientAuthWithPassword(getUsernameAndUpdate(), "$0$" + PASSWORD)),
194 Arguments.of("Password -- MD5",
195 buildClientIdentityWithPassword(getUsername(), PASSWORD),
196 buildClientAuthWithPassword(getUsernameAndUpdate(), Crypt.crypt(PASSWORD, "$1$md5salt"))),
197 Arguments.of("Password -- SHA-256",
198 buildClientIdentityWithPassword(getUsername(), PASSWORD),
199 buildClientAuthWithPassword(getUsernameAndUpdate(),
200 Crypt.crypt(PASSWORD, "$5$sha256salt"))),
201 Arguments.of("Password -- SHA-512 with rounds",
202 buildClientIdentityWithPassword(getUsername(), PASSWORD),
203 buildClientAuthWithPassword(getUsernameAndUpdate(),
204 Crypt.crypt(PASSWORD, "$6$rounds=4500$sha512salt"))),
205 Arguments.of("HostBased -- RSA keys",
206 buildClientIdentityHostBased(getUsername(), rsaKeyData),
207 buildClientAuthHostBased(getUsernameAndUpdate(), rsaKeyData)),
208 Arguments.of("HostBased -- EC keys",
209 buildClientIdentityHostBased(getUsername(), ecKeyData),
210 buildClientAuthHostBased(getUsernameAndUpdate(), ecKeyData)),
211 Arguments.of("PublicKey -- RSA keys",
212 buildClientIdentityWithPublicKey(getUsername(), rsaKeyData),
213 buildClientAuthWithPublicKey(getUsernameAndUpdate(), rsaKeyData)),
214 Arguments.of("PublicKey -- EC keys",
215 buildClientIdentityWithPublicKey(getUsername(), ecKeyData),
216 buildClientAuthWithPublicKey(getUsernameAndUpdate(), ecKeyData))
220 private static String getUsername() {
221 return USERNAME.get();
225 * Update username for next test.
227 private static String getUsernameAndUpdate() {
228 return USERNAME.getAndSet(USER + COUNTER.incrementAndGet());
231 private void integrationTest(final Builder<SSHServer> serverBuilder,
232 final Builder<SSHClient> clientBuilder) throws Exception {
234 final var server = serverBuilder.build().get(2, TimeUnit.SECONDS);
236 // connect with client
237 final var client = clientBuilder.build().get(2, TimeUnit.SECONDS);
239 verify(serverListener, timeout(10_000))
240 .onTransportChannelEstablished(serverTransportChannelCaptor.capture());
241 verify(clientListener, timeout(10_000))
242 .onTransportChannelEstablished(clientTransportChannelCaptor.capture());
243 // validate channels are in expected state
244 var serverChannel = assertChannel(serverTransportChannelCaptor.getAllValues());
245 var clientChannel = assertChannel(clientTransportChannelCaptor.getAllValues());
246 // validate channels are connecting same sockets
247 assertEquals(serverChannel.remoteAddress(), clientChannel.localAddress());
248 assertEquals(serverChannel.localAddress(), clientChannel.remoteAddress());
249 // validate sessions are authenticated
250 assertSession(ServerSession.class, server.getSessions());
251 assertSession(ClientSession.class, client.getSessions());
254 client.shutdown().get(2, TimeUnit.SECONDS);
257 server.shutdown().get(2, TimeUnit.SECONDS);
262 @DisplayName("External service integration")
263 void externalServiceIntegration() throws Exception {
264 final var username = getUsernameAndUpdate();
265 when(sshClientConfig.getClientIdentity()).thenReturn(usernameOnlyIdentity(username));
266 when(sshClientConfig.getServerAuthentication()).thenReturn(null);
268 () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, null, serverConfigurator(username)),
269 () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig,
270 clientConfigurator(username)));
274 @DisplayName("Call-home protocol support with services integration")
275 void callHome() throws Exception {
276 final var username = getUsernameAndUpdate();
277 when(sshClientConfig.getClientIdentity()).thenReturn(usernameOnlyIdentity(username));
278 when(sshClientConfig.getServerAuthentication()).thenReturn(null);
280 // start call-home client first, accepting inbound tcp connections
281 final var client = FACTORY.listenClient(SUBSYSTEM, clientListener, tcpServerConfig, sshClientConfig,
282 clientConfigurator(username)).get(2, TimeUnit.SECONDS);
284 // start a call-home server, init connection
285 final var server = FACTORY.connectServer(SUBSYSTEM, serverListener, tcpClientConfig, null,
286 serverConfigurator(username)).get(2, TimeUnit.SECONDS);
288 verify(serverListener, timeout(10_000))
289 .onTransportChannelEstablished(serverTransportChannelCaptor.capture());
290 verify(clientListener, timeout(10_000))
291 .onTransportChannelEstablished(clientTransportChannelCaptor.capture());
292 // validate channels are in expected state
293 var serverChannel = assertChannel(serverTransportChannelCaptor.getAllValues());
294 var clientChannel = assertChannel(clientTransportChannelCaptor.getAllValues());
295 // validate channels are connecting same sockets
296 assertEquals(serverChannel.remoteAddress(), clientChannel.localAddress());
297 assertEquals(serverChannel.localAddress(), clientChannel.remoteAddress());
298 // validate sessions are authenticated
299 assertSession(ClientSession.class, client.getSessions());
300 assertSession(ServerSession.class, server.getSessions());
303 server.shutdown().get(2, TimeUnit.SECONDS);
306 client.shutdown().get(2, TimeUnit.SECONDS);
310 private static Channel assertChannel(final List<TransportChannel> transportChannels) {
311 assertNotNull(transportChannels);
312 assertEquals(1, transportChannels.size());
313 final var channel = assertInstanceOf(SSHTransportChannel.class, transportChannels.get(0)).channel();
314 assertNotNull(channel);
315 assertTrue(channel.isOpen());
319 private static <T extends Session> void assertSession(final Class<T> type, final Collection<Session> sessions) {
320 assertNotNull(sessions);
321 assertEquals(1, sessions.size());
322 final T session = assertInstanceOf(type, sessions.iterator().next());
323 assertTrue(session.isAuthenticated());
326 private static ClientIdentity usernameOnlyIdentity(final String username) {
327 return new ClientIdentityBuilder().setUsername(username).build();
330 private static ServerFactoryManagerConfigurator serverConfigurator(final String username) {
331 return factoryManager -> {
332 // authenticate user by credentials and generate host key
333 factoryManager.setUserAuthFactories(List.of(new UserAuthPasswordFactory()));
334 factoryManager.setPasswordAuthenticator(
335 (usr, psw, session) -> username.equals(usr) && PASSWORD.equals(psw));
336 factoryManager.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
340 private static ClientFactoryManagerConfigurator clientConfigurator(final String username) {
341 return factoryManager -> {
342 factoryManager.setPasswordIdentityProvider(PasswordIdentityProvider.wrapPasswords(PASSWORD));
343 factoryManager.setUserAuthFactories(List.of(
344 new org.opendaylight.netconf.shaded.sshd.client.auth.password.UserAuthPasswordFactory()));
349 @DisplayName("Handle channel inactive event")
350 void handleChannelInactive() throws Exception {
351 final var username = getUsernameAndUpdate();
352 when(sshClientConfig.getClientIdentity()).thenReturn(usernameOnlyIdentity(username));
353 when(sshClientConfig.getServerAuthentication()).thenReturn(null);
355 // place channelInactive handlers on a server side channel when connection is established
356 final var firstHandlerFuture = SettableFuture.<Boolean>create();
357 final var lastHandlerFuture = SettableFuture.<Boolean>create();
358 final var serverTransportListener = new TransportChannelListener() {
360 public void onTransportChannelEstablished(final TransportChannel channel) {
361 channel.channel().pipeline().addFirst("FIRST", new ChannelInboundHandlerAdapter() {
363 public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
364 firstHandlerFuture.set(Boolean.TRUE);
365 ctx.fireChannelInactive();
368 channel.channel().pipeline().addLast("LAST", new ChannelInboundHandlerAdapter() {
370 public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
371 lastHandlerFuture.set(Boolean.TRUE);
372 ctx.fireChannelInactive();
378 public void onTransportChannelFailed(final Throwable cause) {
383 final var server = FACTORY.listenServer(SUBSYSTEM, serverTransportListener, tcpServerConfig, null,
384 serverConfigurator(username)).get(2, TimeUnit.SECONDS);
386 // connect with client
387 final var client = FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig,
388 clientConfigurator(username)).get(2, TimeUnit.SECONDS);
390 verify(clientListener, timeout(10_000)).onTransportChannelEstablished(any(TransportChannel.class));
393 client.shutdown().get(2, TimeUnit.SECONDS);
394 // validate channel closure on server side is handled properly:
395 // both first and last handlers expected to be triggered
396 // indicating there is no obstacles for the event in a channel pipeline
397 assertEquals(Boolean.TRUE, firstHandlerFuture.get(1, TimeUnit.SECONDS));
398 assertEquals(Boolean.TRUE, lastHandlerFuture.get(1, TimeUnit.SECONDS));
401 server.shutdown().get(2, TimeUnit.SECONDS);
406 private interface Builder<T extends SSHTransportStack> {
407 ListenableFuture<T> build() throws UnsupportedConfigurationException;