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.SettableFuture;
30 import io.netty.handler.codec.http.DefaultFullHttpRequest;
31 import io.netty.handler.codec.http.DefaultFullHttpResponse;
32 import io.netty.handler.codec.http.FullHttpResponse;
33 import io.netty.handler.codec.http.HttpMethod;
34 import java.io.IOException;
35 import java.math.BigInteger;
36 import java.net.InetAddress;
37 import java.net.ServerSocket;
38 import java.nio.charset.StandardCharsets;
39 import java.security.KeyPair;
40 import java.security.KeyPairGenerator;
41 import java.security.PrivateKey;
42 import java.security.SecureRandom;
43 import java.security.cert.X509Certificate;
44 import java.security.spec.ECGenParameterSpec;
45 import java.security.spec.RSAKeyGenParameterSpec;
46 import java.time.Duration;
47 import java.time.Instant;
48 import java.util.Date;
50 import java.util.concurrent.Executors;
51 import java.util.concurrent.ScheduledExecutorService;
52 import java.util.concurrent.TimeUnit;
53 import java.util.concurrent.atomic.AtomicInteger;
54 import org.bouncycastle.asn1.x500.X500Name;
55 import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
56 import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
57 import org.bouncycastle.jce.provider.BouncyCastleProvider;
58 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
59 import org.junit.jupiter.api.AfterAll;
60 import org.junit.jupiter.api.BeforeAll;
61 import org.junit.jupiter.api.extension.ExtendWith;
62 import org.junit.jupiter.params.ParameterizedTest;
63 import org.junit.jupiter.params.provider.ValueSource;
64 import org.mockito.Mock;
65 import org.mockito.junit.jupiter.MockitoExtension;
66 import org.opendaylight.netconf.transport.api.TransportChannelListener;
67 import org.opendaylight.netconf.transport.tcp.BootstrapFactory;
68 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientStackGrouping;
69 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerStackGrouping;
71 @ExtendWith(MockitoExtension.class)
72 public class HttpClientServerTest {
74 private static final String USERNAME = "username";
75 private static final String PASSWORD = "pa$$W0rd";
76 private static final Map<String, String> USER_HASHES_MAP = Map.of(USERNAME, "$0$" + PASSWORD);
77 private static final AtomicInteger COUNTER = new AtomicInteger(0);
78 private static final SecureRandom SECURE_RANDOM = new SecureRandom();
79 private static final String[] METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"};
80 private static final String RESPONSE_TEMPLATE = "Method: %s URI: %s Payload: %s";
82 private static ScheduledExecutorService scheduledExecutor;
83 private static RequestDispatcher requestDispatcher;
84 private static BootstrapFactory bootstrapFactory;
85 private static String localAddress;
88 private HttpServerStackGrouping serverConfig;
90 private HttpClientStackGrouping clientConfig;
92 private TransportChannelListener serverTransportListener;
94 private TransportChannelListener clientTransportListener;
97 static void beforeAll() {
98 bootstrapFactory = new BootstrapFactory("IntegrationTest", 0);
99 localAddress = InetAddress.getLoopbackAddress().getHostAddress();
100 scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
102 requestDispatcher = request -> {
103 final var future = SettableFuture.<FullHttpResponse>create();
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 return future.set(response);
117 }, 100, TimeUnit.MILLISECONDS);
123 static void afterAll() {
124 bootstrapFactory.close();
125 scheduledExecutor.shutdown();
128 @ParameterizedTest(name = "TCP with no authorization, HTTP/2: {0}")
129 @ValueSource(booleans = {false, true})
130 void noAuthTcp(final boolean http2) throws Exception {
131 final var localPort = freePort();
132 doReturn(serverTransportTcp(localAddress, localPort)).when(serverConfig).getTransport();
133 doReturn(clientTransportTcp(localAddress, localPort)).when(clientConfig).getTransport();
134 integrationTest(http2);
137 @ParameterizedTest(name = "TCP with Basic authorization, HTTP/2: {0}")
138 @ValueSource(booleans = {false, true})
139 void basicAuthTcp(final boolean http2) throws Exception {
140 final var localPort = freePort();
141 doReturn(serverTransportTcp(localAddress, localPort, USER_HASHES_MAP))
142 .when(serverConfig).getTransport();
143 doReturn(clientTransportTcp(localAddress, localPort, USERNAME, PASSWORD))
144 .when(clientConfig).getTransport();
145 integrationTest(http2);
148 @ParameterizedTest(name = "TLS with no authorization, HTTP/2: {0}")
149 @ValueSource(booleans = {false, true})
150 void noAuthTls(final boolean http2) throws Exception {
151 final var certData = generateX509CertData("RSA");
152 final var localPort = freePort();
153 doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey()))
154 .when(serverConfig).getTransport();
155 doReturn(clientTransportTls(localAddress, localPort, certData.certificate())).when(clientConfig).getTransport();
156 integrationTest(http2);
159 @ParameterizedTest(name = "TLS with Basic authorization, HTTP/2: {0}")
160 @ValueSource(booleans = {false, true})
161 void basicAuthTls(final boolean http2) throws Exception {
162 final var certData = generateX509CertData("EC");
163 final var localPort = freePort();
164 doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey(),
165 USER_HASHES_MAP)).when(serverConfig).getTransport();
166 doReturn(clientTransportTls(localAddress, localPort, certData.certificate(), USERNAME, PASSWORD))
167 .when(clientConfig).getTransport();
168 integrationTest(http2);
171 private void integrationTest(final boolean http2) throws Exception {
172 final var server = HTTPServer.listen(serverTransportListener, bootstrapFactory.newServerBootstrap(),
173 serverConfig, requestDispatcher).get(2, TimeUnit.SECONDS);
175 final var client = HTTPClient.connect(clientTransportListener, bootstrapFactory.newBootstrap(),
176 clientConfig, http2).get(2, TimeUnit.SECONDS);
178 verify(serverTransportListener, timeout(2000)).onTransportChannelEstablished(any());
179 verify(clientTransportListener, timeout(2000)).onTransportChannelEstablished(any());
181 for (var method : METHODS) {
182 final var uri = nextValue("URI");
183 final var payload = nextValue("PAYLOAD");
184 final var request = new DefaultFullHttpRequest(HTTP_1_1, HttpMethod.valueOf(method),
185 uri, wrappedBuffer(payload.getBytes(StandardCharsets.UTF_8)));
186 request.headers().set(CONTENT_TYPE, TEXT_PLAIN)
187 .setInt(CONTENT_LENGTH, request.content().readableBytes())
188 // allow multiple requests on same connections
189 .set(CONNECTION, KEEP_ALIVE);
191 final var response = client.invoke(request).get(2, TimeUnit.SECONDS);
192 assertNotNull(response);
193 assertEquals(OK, response.status());
194 final var expected = RESPONSE_TEMPLATE.formatted(method, uri, payload);
195 assertEquals(expected, response.content().toString(StandardCharsets.UTF_8));
198 client.shutdown().get(2, TimeUnit.SECONDS);
201 server.shutdown().get(2, TimeUnit.SECONDS);
205 private static int freePort() throws IOException {
207 final var socket = new ServerSocket(0);
208 final var localPort = socket.getLocalPort();
213 private static String nextValue(final String prefix) {
214 return prefix + COUNTER.incrementAndGet();
217 private static X509CertData generateX509CertData(final String algorithm) throws Exception {
218 final var keyPairGenerator = KeyPairGenerator.getInstance(algorithm);
219 if (isRSA(algorithm)) {
220 keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4), SECURE_RANDOM);
222 keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"), SECURE_RANDOM);
224 final var keyPair = keyPairGenerator.generateKeyPair();
225 final var certificate = generateCertificate(keyPair, isRSA(algorithm) ? "SHA256withRSA" : "SHA256withECDSA");
226 return new X509CertData(certificate, keyPair.getPrivate());
229 private static X509Certificate generateCertificate(final KeyPair keyPair, final String hashAlgorithm)
231 final var now = Instant.now();
232 final var contentSigner = new JcaContentSignerBuilder(hashAlgorithm).build(keyPair.getPrivate());
234 final var x500Name = new X500Name("CN=TestCertificate");
235 final var certificateBuilder = new JcaX509v3CertificateBuilder(x500Name,
236 BigInteger.valueOf(now.toEpochMilli()),
237 Date.from(now), Date.from(now.plus(Duration.ofDays(365))),
239 keyPair.getPublic());
240 return new JcaX509CertificateConverter()
241 .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
244 private static boolean isRSA(final String algorithm) {
245 return "RSA".equals(algorithm);
248 private record X509CertData(X509Certificate certificate, PrivateKey privateKey) {