2 * Copyright (c) 2024 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.transport.http;
10 import static io.netty.buffer.Unpooled.wrappedBuffer;
11 import static io.netty.handler.codec.http.HttpHeaderNames.CONNECTION;
12 import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
13 import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
14 import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
15 import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN;
16 import static io.netty.handler.codec.http.HttpResponseStatus.OK;
17 import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
18 import static org.junit.jupiter.api.Assertions.assertEquals;
19 import static org.junit.jupiter.api.Assertions.assertNotNull;
20 import static org.mockito.ArgumentMatchers.any;
21 import static org.mockito.Mockito.doReturn;
22 import static org.mockito.Mockito.timeout;
23 import static org.mockito.Mockito.verify;
24 import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTcp;
25 import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTls;
26 import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTcp;
27 import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTls;
29 import com.google.common.util.concurrent.FutureCallback;
30 import com.google.common.util.concurrent.SettableFuture;
31 import io.netty.handler.codec.http.DefaultFullHttpRequest;
32 import io.netty.handler.codec.http.DefaultFullHttpResponse;
33 import io.netty.handler.codec.http.FullHttpResponse;
34 import io.netty.handler.codec.http.HttpMethod;
35 import java.io.IOException;
36 import java.math.BigInteger;
37 import java.net.InetAddress;
38 import java.net.ServerSocket;
39 import java.nio.charset.StandardCharsets;
40 import java.security.KeyPair;
41 import java.security.KeyPairGenerator;
42 import java.security.PrivateKey;
43 import java.security.SecureRandom;
44 import java.security.cert.X509Certificate;
45 import java.security.spec.ECGenParameterSpec;
46 import java.security.spec.RSAKeyGenParameterSpec;
47 import java.time.Duration;
48 import java.time.Instant;
49 import java.util.Date;
51 import java.util.concurrent.Executors;
52 import java.util.concurrent.ScheduledExecutorService;
53 import java.util.concurrent.TimeUnit;
54 import java.util.concurrent.atomic.AtomicInteger;
55 import org.bouncycastle.asn1.x500.X500Name;
56 import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
57 import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
58 import org.bouncycastle.jce.provider.BouncyCastleProvider;
59 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
60 import org.junit.jupiter.api.AfterAll;
61 import org.junit.jupiter.api.BeforeAll;
62 import org.junit.jupiter.api.extension.ExtendWith;
63 import org.junit.jupiter.params.ParameterizedTest;
64 import org.junit.jupiter.params.provider.ValueSource;
65 import org.mockito.Mock;
66 import org.mockito.junit.jupiter.MockitoExtension;
67 import org.opendaylight.netconf.transport.api.TransportChannelListener;
68 import org.opendaylight.netconf.transport.tcp.BootstrapFactory;
69 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientStackGrouping;
70 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerStackGrouping;
72 @ExtendWith(MockitoExtension.class)
73 public class HttpClientServerTest {
75 private static final String USERNAME = "username";
76 private static final String PASSWORD = "pa$$W0rd";
77 private static final Map<String, String> USER_HASHES_MAP = Map.of(USERNAME, "$0$" + PASSWORD);
78 private static final AtomicInteger COUNTER = new AtomicInteger(0);
79 private static final SecureRandom SECURE_RANDOM = new SecureRandom();
80 private static final String[] METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"};
81 private static final String RESPONSE_TEMPLATE = "Method: %s URI: %s Payload: %s";
83 private static ScheduledExecutorService scheduledExecutor;
84 private static RequestDispatcher requestDispatcher;
85 private static BootstrapFactory bootstrapFactory;
86 private static String localAddress;
89 private HttpServerStackGrouping serverConfig;
91 private HttpClientStackGrouping clientConfig;
93 private TransportChannelListener serverTransportListener;
95 private TransportChannelListener clientTransportListener;
98 static void beforeAll() {
99 bootstrapFactory = new BootstrapFactory("IntegrationTest", 0);
100 localAddress = InetAddress.getLoopbackAddress().getHostAddress();
101 scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
103 requestDispatcher = (request, callback) -> {
104 // emulate asynchronous server request processing - run in separate thread with 100 millis delay
105 scheduledExecutor.schedule(() -> {
106 // return 200 response with a content built from request parameters
107 final var method = request.method().name();
108 final var uri = request.uri();
109 final var payload = request.content().readableBytes() > 0
110 ? request.content().toString(StandardCharsets.UTF_8) : "";
111 final var responseMessage = RESPONSE_TEMPLATE.formatted(method, uri, payload);
112 final var response = new DefaultFullHttpResponse(request.protocolVersion(), OK,
113 wrappedBuffer(responseMessage.getBytes(StandardCharsets.UTF_8)));
114 response.headers().set(CONTENT_TYPE, TEXT_PLAIN)
115 .setInt(CONTENT_LENGTH, response.content().readableBytes());
116 callback.onSuccess(response);
117 }, 100, TimeUnit.MILLISECONDS);
122 static void afterAll() {
123 bootstrapFactory.close();
124 scheduledExecutor.shutdown();
127 @ParameterizedTest(name = "TCP with no authorization, HTTP/2: {0}")
128 @ValueSource(booleans = {false, true})
129 void noAuthTcp(final boolean http2) throws Exception {
130 final var localPort = freePort();
131 doReturn(serverTransportTcp(localAddress, localPort)).when(serverConfig).getTransport();
132 doReturn(clientTransportTcp(localAddress, localPort)).when(clientConfig).getTransport();
133 integrationTest(http2);
136 @ParameterizedTest(name = "TCP with Basic authorization, HTTP/2: {0}")
137 @ValueSource(booleans = {false, true})
138 void basicAuthTcp(final boolean http2) throws Exception {
139 final var localPort = freePort();
140 doReturn(serverTransportTcp(localAddress, localPort, USER_HASHES_MAP))
141 .when(serverConfig).getTransport();
142 doReturn(clientTransportTcp(localAddress, localPort, USERNAME, PASSWORD))
143 .when(clientConfig).getTransport();
144 integrationTest(http2);
147 @ParameterizedTest(name = "TLS with no authorization, HTTP/2: {0}")
148 @ValueSource(booleans = {false, true})
149 void noAuthTls(final boolean http2) throws Exception {
150 final var certData = generateX509CertData("RSA");
151 final var localPort = freePort();
152 doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey()))
153 .when(serverConfig).getTransport();
154 doReturn(clientTransportTls(localAddress, localPort, certData.certificate())).when(clientConfig).getTransport();
155 integrationTest(http2);
158 @ParameterizedTest(name = "TLS with Basic authorization, HTTP/2: {0}")
159 @ValueSource(booleans = {false, true})
160 void basicAuthTls(final boolean http2) throws Exception {
161 final var certData = generateX509CertData("EC");
162 final var localPort = freePort();
163 doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey(),
164 USER_HASHES_MAP)).when(serverConfig).getTransport();
165 doReturn(clientTransportTls(localAddress, localPort, certData.certificate(), USERNAME, PASSWORD))
166 .when(clientConfig).getTransport();
167 integrationTest(http2);
170 private void integrationTest(final boolean http2) throws Exception {
171 final var server = HTTPServer.listen(serverTransportListener, bootstrapFactory.newServerBootstrap(),
172 serverConfig, requestDispatcher).get(2, TimeUnit.SECONDS);
174 final var client = HTTPClient.connect(clientTransportListener, bootstrapFactory.newBootstrap(),
175 clientConfig, http2).get(2, TimeUnit.SECONDS);
177 verify(serverTransportListener, timeout(2000)).onTransportChannelEstablished(any());
178 verify(clientTransportListener, timeout(2000)).onTransportChannelEstablished(any());
180 for (var method : METHODS) {
181 final var uri = nextValue("URI");
182 final var payload = nextValue("PAYLOAD");
183 final var request = new DefaultFullHttpRequest(HTTP_1_1, HttpMethod.valueOf(method),
184 uri, wrappedBuffer(payload.getBytes(StandardCharsets.UTF_8)));
185 request.headers().set(CONTENT_TYPE, TEXT_PLAIN)
186 .setInt(CONTENT_LENGTH, request.content().readableBytes())
187 // allow multiple requests on same connections
188 .set(CONNECTION, KEEP_ALIVE);
190 final var future = SettableFuture.<FullHttpResponse>create();
191 client.invoke(request, new FutureCallback<>() {
193 public void onSuccess(final FullHttpResponse result) {
194 future.set(result.copy());
198 public void onFailure(final Throwable cause) {
199 future.setException(cause);
203 final var response = future.get(2, TimeUnit.SECONDS);
204 assertNotNull(response);
205 assertEquals(OK, response.status());
206 final var expected = RESPONSE_TEMPLATE.formatted(method, uri, payload);
207 assertEquals(expected, response.content().toString(StandardCharsets.UTF_8));
210 client.shutdown().get(2, TimeUnit.SECONDS);
213 server.shutdown().get(2, TimeUnit.SECONDS);
217 private static int freePort() throws IOException {
219 final var socket = new ServerSocket(0);
220 final var localPort = socket.getLocalPort();
225 private static String nextValue(final String prefix) {
226 return prefix + COUNTER.incrementAndGet();
229 private static X509CertData generateX509CertData(final String algorithm) throws Exception {
230 final var keyPairGenerator = KeyPairGenerator.getInstance(algorithm);
231 if (isRSA(algorithm)) {
232 keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4), SECURE_RANDOM);
234 keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"), SECURE_RANDOM);
236 final var keyPair = keyPairGenerator.generateKeyPair();
237 final var certificate = generateCertificate(keyPair, isRSA(algorithm) ? "SHA256withRSA" : "SHA256withECDSA");
238 return new X509CertData(certificate, keyPair.getPrivate());
241 private static X509Certificate generateCertificate(final KeyPair keyPair, final String hashAlgorithm)
243 final var now = Instant.now();
244 final var contentSigner = new JcaContentSignerBuilder(hashAlgorithm).build(keyPair.getPrivate());
246 final var x500Name = new X500Name("CN=TestCertificate");
247 final var certificateBuilder = new JcaX509v3CertificateBuilder(x500Name,
248 BigInteger.valueOf(now.toEpochMilli()),
249 Date.from(now), Date.from(now.plus(Duration.ofDays(365))),
251 keyPair.getPublic());
252 return new JcaX509CertificateConverter()
253 .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
256 private static boolean isRSA(final String algorithm) {
257 return "RSA".equals(algorithm);
260 private record X509CertData(X509Certificate certificate, PrivateKey privateKey) {