Refresh IETF client/server models
[netconf.git] / transport / transport-ssh / src / test / java / org / opendaylight / netconf / transport / ssh / SshClientServerTest.java
1 /*
2  * Copyright (c) 2023 PANTHEON.tech s.r.o. All rights reserved.
3  *
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
7  */
8 package org.opendaylight.netconf.transport.ssh;
9
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;
29
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.rev240208.SshClientGrouping;
71 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev240208.ssh.client.grouping.ClientIdentity;
72 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev240208.ssh.client.grouping.ClientIdentityBuilder;
73 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev240208.ssh.client.grouping.ServerAuthentication;
74 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev240208.SshServerGrouping;
75 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev240208.ssh.server.grouping.ClientAuthentication;
76 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev240208.ssh.server.grouping.ServerIdentity;
77 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.client.rev240208.TcpClientGrouping;
78 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev240208.TcpServerGrouping;
79 import org.opendaylight.yangtools.yang.common.Uint16;
80
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);
90
91     private static SSHTransportStackFactory FACTORY;
92
93     @Mock
94     private TcpClientGrouping tcpClientConfig;
95     @Mock
96     private SshClientGrouping sshClientConfig;
97     @Mock
98     private TransportChannelListener clientListener;
99     @Mock
100     private TcpServerGrouping tcpServerConfig;
101     @Mock
102     private SshServerGrouping sshServerConfig;
103     @Mock
104     private TransportChannelListener serverListener;
105
106     @Captor
107     ArgumentCaptor<TransportChannel> clientTransportChannelCaptor;
108     @Captor
109     ArgumentCaptor<TransportChannel> serverTransportChannelCaptor;
110
111     private ServerSocket socket;
112
113     @BeforeAll
114     static void beforeAll() {
115         FACTORY = new SSHTransportStackFactory("IntegrationTest", 0);
116     }
117
118     @AfterAll
119     static void afterAll() {
120         FACTORY.close();
121     }
122
123     @BeforeEach
124     void beforeEach() throws IOException {
125
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()));
130         socket.close();
131
132         when(tcpServerConfig.getLocalAddress()).thenReturn(localAddress);
133         when(tcpServerConfig.requireLocalAddress()).thenCallRealMethod();
134         when(tcpServerConfig.getLocalPort()).thenReturn(localPort);
135         when(tcpServerConfig.requireLocalPort()).thenCallRealMethod();
136
137         when(tcpClientConfig.getRemoteAddress()).thenReturn(new Host(localAddress));
138         when(tcpClientConfig.requireRemoteAddress()).thenCallRealMethod();
139         when(tcpClientConfig.getRemotePort()).thenReturn(localPort);
140         when(tcpClientConfig.requireRemotePort()).thenCallRealMethod();
141     }
142
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);
153         integrationTest(
154             () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, sshServerConfig),
155             () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig));
156     }
157
158     private static Stream<Arguments> itServerKeyVerifyArgs() throws Exception {
159         final var rsaKeyData = generateKeyPairWithCertificate(RSA);
160         final var ecKeyData = generateKeyPairWithCertificate(EC);
161         return Stream.of(
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))
170         );
171     }
172
173     @ParameterizedTest(name = "SSH User Auth using {0}")
174     @MethodSource("itUserAuthArgs")
175     void itUserAuth(final String testDesc, final ClientIdentity clientIdentity, final ClientAuthentication clientAuth)
176             throws Exception {
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);
182         integrationTest(
183             () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, sshServerConfig),
184             () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig));
185     }
186
187     private static Stream<Arguments> itUserAuthArgs() throws Exception {
188         final var rsaKeyData = generateKeyPairWithCertificate(RSA);
189         final var ecKeyData = generateKeyPairWithCertificate(EC);
190         return Stream.of(
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))
217         );
218     }
219
220     private static String getUsername() {
221         return USERNAME.get();
222     }
223
224     /**
225      * Update username for next test.
226      */
227     private static String getUsernameAndUpdate() {
228         return USERNAME.getAndSet(USER + COUNTER.incrementAndGet());
229     }
230
231     private void integrationTest(final Builder<SSHServer> serverBuilder,
232             final Builder<SSHClient> clientBuilder) throws Exception {
233         // start server
234         final var server = serverBuilder.build().get(2, TimeUnit.SECONDS);
235         try {
236             // connect with client
237             final var client = clientBuilder.build().get(2, TimeUnit.SECONDS);
238             try {
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());
252
253             } finally {
254                 client.shutdown().get(2, TimeUnit.SECONDS);
255             }
256         } finally {
257             server.shutdown().get(2, TimeUnit.SECONDS);
258         }
259     }
260
261     @Test
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);
267         integrationTest(
268             () -> FACTORY.listenServer(SUBSYSTEM, serverListener, tcpServerConfig, null, serverConfigurator(username)),
269             () -> FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig,
270                 clientConfigurator(username)));
271     }
272
273     @Test
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);
279
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);
283         try {
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);
287             try {
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());
301
302             } finally {
303                 server.shutdown().get(2, TimeUnit.SECONDS);
304             }
305         } finally {
306             client.shutdown().get(2, TimeUnit.SECONDS);
307         }
308     }
309
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());
316         return channel;
317     }
318
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());
324     }
325
326     private static ClientIdentity usernameOnlyIdentity(final String username) {
327         return new ClientIdentityBuilder().setUsername(username).build();
328     }
329
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());
337         };
338     }
339
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()));
345         };
346     }
347
348     @Test
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);
354
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() {
359             @Override
360             public void onTransportChannelEstablished(final TransportChannel channel) {
361                 channel.channel().pipeline().addFirst("FIRST", new ChannelInboundHandlerAdapter() {
362                     @Override
363                     public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
364                         firstHandlerFuture.set(Boolean.TRUE);
365                         ctx.fireChannelInactive();
366                     }
367                 });
368                 channel.channel().pipeline().addLast("LAST", new ChannelInboundHandlerAdapter() {
369                     @Override
370                     public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
371                         lastHandlerFuture.set(Boolean.TRUE);
372                         ctx.fireChannelInactive();
373                     }
374                 });
375             }
376
377             @Override
378             public void onTransportChannelFailed(final Throwable cause) {
379                 // not used
380             }
381         };
382
383         final var server = FACTORY.listenServer(SUBSYSTEM, serverTransportListener, tcpServerConfig, null,
384                 serverConfigurator(username)).get(2, TimeUnit.SECONDS);
385         try {
386             // connect with client
387             final var client = FACTORY.connectClient(SUBSYSTEM, clientListener, tcpClientConfig, sshClientConfig,
388                 clientConfigurator(username)).get(2, TimeUnit.SECONDS);
389             try {
390                 verify(clientListener, timeout(10_000)).onTransportChannelEstablished(any(TransportChannel.class));
391             } finally {
392                 // disconnect client
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));
399             }
400         } finally {
401             server.shutdown().get(2, TimeUnit.SECONDS);
402         }
403     }
404
405     @FunctionalInterface
406     private interface Builder<T extends SSHTransportStack> {
407         ListenableFuture<T> build() throws UnsupportedConfigurationException;
408     }
409 }