HTTP transport implementation 91/110191/20
authorRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Thu, 8 Feb 2024 11:48:57 +0000 (13:48 +0200)
committerRobert Varga <nite@hq.sk>
Wed, 27 Mar 2024 14:47:35 +0000 (14:47 +0000)
HTTP Client and Server implementation using Netty's
HTTP codecs. Existing TCP and TLS transport layers are used
as underlay. HTTP/2 and Basic Authentication support provided.

JIRA: NETCONF-1248
Change-Id: If02446a24f174663ff497148bde436923f73c8a6
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
19 files changed:
transport/transport-http/pom.xml
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/BasicAuthHandler.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientAuthProvider.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientChannelInitializer.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp1RequestDispatcher.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp2RequestDispatcher.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ConfigUtils.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPClient.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPServer.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportChannel.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportStack.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/Http2Utils.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpChannelInitializer.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpSslHandlerFactory.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/IetfHttpServerFeatureProvider.java
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/RequestDispatcher.java [new file with mode: 0644]
transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ServerChannelInitializer.java [new file with mode: 0644]
transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/BasicAuthHandlerTest.java [new file with mode: 0644]
transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/HttpClientServerTest.java [new file with mode: 0644]

index e1cb04fade8b208d5d84fb9b6731fd0e5211dfde..3ec4ead353cc4ab17fcd426f7cd596339c2e83b3 100644 (file)
     <description>NETCONF HTTP transport</description>
 
     <dependencies>
+        <dependency>
+            <!-- serves iana-crypt-hash incl `rounds` property for SHA algorithms -->
+            <!-- excluded from odl-parent via ODLPARENT-285 -->
+            <groupId>commons-codec</groupId>
+            <artifactId>commons-codec</artifactId>
+            <version>1.15</version>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-buffer</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-common</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-codec-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-codec-http2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-transport</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.kohsuke.metainf-services</groupId>
             <artifactId>metainf-services</artifactId>
             <groupId>org.opendaylight.mdsal.binding.model.ietf</groupId>
             <artifactId>rfc6991-ietf-yang-types</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.opendaylight.netconf</groupId>
+            <artifactId>keystore-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.opendaylight.netconf</groupId>
+            <artifactId>transport-api</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.opendaylight.netconf</groupId>
             <artifactId>transport-tcp</artifactId>
             <groupId>org.opendaylight.netconf</groupId>
             <artifactId>transport-tls</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.opendaylight.netconf</groupId>
+            <artifactId>truststore-api</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.opendaylight.netconf.model</groupId>
             <artifactId>draft-ietf-netconf-crypto-types</artifactId>
             <groupId>org.opendaylight.netconf.model</groupId>
             <artifactId>rfc8341</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcpkix-jdk18on</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcprov-jdk18on</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/BasicAuthHandler.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/BasicAuthHandler.java
new file mode 100644 (file)
index 0000000..d560acd
--- /dev/null
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static java.util.Objects.requireNonNull;
+import static org.opendaylight.netconf.transport.http.Http2Utils.copyStreamId;
+
+import com.google.common.collect.ImmutableMap;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpMessage;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.util.ReferenceCountUtil;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.regex.Pattern;
+import org.apache.commons.codec.digest.Crypt;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.client.authentication.users.user.auth.type.Basic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Server side Basic Authorization handler.
+ */
+final class BasicAuthHandler extends SimpleChannelInboundHandler<HttpMessage> {
+    @NonNullByDefault
+    private record CryptHash(String salt, String hash) {
+        CryptHash {
+            requireNonNull(salt);
+            requireNonNull(hash);
+        }
+    }
+
+    private static final Logger LOG = LoggerFactory.getLogger(BasicAuthHandler.class);
+    private static final Pattern CRYPT_HASH_PATTERN = Pattern.compile(
+        """
+            \\$0\\$.*\
+            |\\$1\\$[a-zA-Z0-9./]{1,8}\\$[a-zA-Z0-9./]{22}\
+            |\\$5\\$(rounds=\\d+\\$)?[a-zA-Z0-9./]{1,16}\\$[a-zA-Z0-9./]{43}\
+            |\\$6\\$(rounds=\\d+\\$)?[a-zA-Z0-9./]{1,16}\\$[a-zA-Z0-9./]{86}""");
+    private static final String DEFAULT_SALT = "$5$rounds=3500$default";
+
+    public static final String BASIC_AUTH_PREFIX = "Basic ";
+    public static final int BASIC_AUTH_CUT_INDEX = BASIC_AUTH_PREFIX.length();
+
+    private final ImmutableMap<String, CryptHash> knownHashes;
+
+    private BasicAuthHandler(final ImmutableMap<String, CryptHash> knownHashes) {
+        this.knownHashes = requireNonNull(knownHashes);
+    }
+
+    static @Nullable BasicAuthHandler ofNullable(final HttpServerGrouping httpParams) {
+        if (httpParams == null) {
+            return null;
+        }
+        final var clientAuth = httpParams.getClientAuthentication();
+        if (clientAuth == null) {
+            return null;
+        }
+
+        // Basic authorization handler
+        final var builder = ImmutableMap.<String, CryptHash>builder();
+        clientAuth.nonnullUsers().nonnullUser().forEach((ignored, user) -> {
+            if (user.getAuthType() instanceof Basic basicAuth) {
+                final var basic = basicAuth.nonnullBasic();
+                final var hashedPassword = basic.nonnullPassword().requireHashedPassword().getValue();
+                if (!CRYPT_HASH_PATTERN.matcher(hashedPassword).matches()) {
+                    throw new IllegalArgumentException("Invalid crypt hash string \"" + hashedPassword + '"');
+                }
+                final var cryptHash = hashedPassword.startsWith("$0$")
+                    ? new CryptHash(DEFAULT_SALT, Crypt.crypt(hashedPassword.substring(3), DEFAULT_SALT))
+                    : new CryptHash(hashedPassword.substring(0, hashedPassword.lastIndexOf('$')), hashedPassword);
+                builder.put(basic.requireUsername(), cryptHash);
+            }
+        });
+        final var knownHashes = builder.build();
+        return knownHashes.isEmpty() ? null : new BasicAuthHandler(knownHashes);
+    }
+
+    @Override
+    protected void channelRead0(final ChannelHandlerContext ctx, final HttpMessage msg) throws Exception {
+        if (isAuthorized(msg.headers().get(HttpHeaderNames.AUTHORIZATION))) {
+            ReferenceCountUtil.retain(msg);
+            ctx.fireChannelRead(msg);
+        } else {
+            final var error = new DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.UNAUTHORIZED,
+                Unpooled.EMPTY_BUFFER);
+            copyStreamId(msg, error);
+            ctx.writeAndFlush(error);
+        }
+    }
+
+    private boolean isAuthorized(final String authHeader) {
+        if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+            LOG.debug("UNAUTHORIZED: No Authorization (Basic) header");
+            return false;
+        }
+        final String[] credentials;
+        try {
+            final var decoded = Base64.getDecoder().decode(authHeader.substring(BASIC_AUTH_CUT_INDEX));
+            credentials = new String(decoded, StandardCharsets.UTF_8).split(":");
+        } catch (IllegalArgumentException e) {
+            LOG.debug("UNAUTHORIZED: Error decoding credentials", e);
+            return false;
+        }
+        final var found = credentials.length == 2 ? knownHashes.get(credentials[0]) : null;
+        return found != null && found.hash.equals(Crypt.crypt(credentials[1], found.salt));
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientAuthProvider.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientAuthProvider.java
new file mode 100644 (file)
index 0000000..b88cd8f
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static org.opendaylight.netconf.transport.http.BasicAuthHandler.BASIC_AUTH_PREFIX;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelOutboundHandlerAdapter;
+import io.netty.channel.ChannelPromise;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpRequest;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.password.grouping.password.type.CleartextPassword;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.http.client.identity.grouping.client.identity.auth.type.Basic;
+
+/**
+ * A client-side channel handler adding HTTP headers.
+ */
+abstract sealed class ClientAuthProvider extends ChannelOutboundHandlerAdapter {
+    private static final class ClientBasicAuthProvider extends ClientAuthProvider {
+        private final String authHeader;
+
+        ClientBasicAuthProvider(final String username, final String password) {
+            authHeader = BASIC_AUTH_PREFIX + Base64.getEncoder().encodeToString(
+                (username + ":" + password).getBytes(StandardCharsets.UTF_8));
+        }
+
+        @Override
+        public void write(final ChannelHandlerContext ctx, final Object msg, final ChannelPromise promise)
+                throws Exception {
+            if (msg instanceof HttpRequest request) {
+                request.headers().set(HttpHeaderNames.AUTHORIZATION, authHeader);
+            }
+            super.write(ctx, msg, promise);
+        }
+    }
+
+    private ClientAuthProvider() {
+        // Hidden on purpose
+    }
+
+    static @Nullable ClientAuthProvider ofNullable(final HttpClientGrouping httpParams) {
+        if (httpParams == null) {
+            return null;
+        }
+        final var clientIdentity = httpParams.getClientIdentity();
+        if (clientIdentity == null) {
+            return null;
+        }
+        final var authType = clientIdentity.getAuthType();
+        if (authType instanceof Basic basicAuth) {
+            // Basic authorization handler, sets authorization header on outgoing requests
+            final var basic = basicAuth.nonnullBasic();
+            return new ClientBasicAuthProvider(basic.getUserId(),
+                basic.getPasswordType() instanceof CleartextPassword clearText ? clearText.requireCleartextPassword()
+                    : "");
+        }
+        return null;
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientChannelInitializer.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientChannelInitializer.java
new file mode 100644 (file)
index 0000000..6ec4041
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static io.netty.buffer.Unpooled.EMPTY_BUFFER;
+import static io.netty.handler.codec.http.HttpMethod.GET;
+import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelPipeline;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
+import io.netty.handler.codec.http.HttpClientCodec;
+import io.netty.handler.codec.http.HttpClientUpgradeHandler;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http2.Http2ClientUpgradeCodec;
+import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
+import io.netty.handler.ssl.SslHandler;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientGrouping;
+
+/**
+ * Netty channel initializer for Http Client.
+ */
+final class ClientChannelInitializer extends ChannelInitializer<Channel> implements HttpChannelInitializer {
+    private static final int MAX_HTTP_CONTENT_LENGTH = 16 * 1024;
+
+    private final SettableFuture<Void> completeFuture = SettableFuture.create();
+    private final ChannelHandler dispatcherHandler;
+    private final ClientAuthProvider authProvider;
+    private final boolean http2;
+
+    ClientChannelInitializer(final HttpClientGrouping httpParams, final ChannelHandler dispatcherHandler,
+            final boolean http2) {
+        this.dispatcherHandler = requireNonNull(dispatcherHandler);
+        authProvider = ClientAuthProvider.ofNullable(httpParams);
+        this.http2 = http2;
+    }
+
+    @Override
+    public ListenableFuture<Void> completeFuture() {
+        return completeFuture;
+    }
+
+    @Override
+    protected void initChannel(final Channel channel) throws Exception {
+        final var pipeline = channel.pipeline();
+        final boolean ssl = pipeline.get(SslHandler.class) != null;
+
+        if (http2) {
+            // External HTTP 2 to internal HTTP 1.1 adapter handler
+            final var connectionHandler = Http2Utils.connectionHandler(false, MAX_HTTP_CONTENT_LENGTH);
+            if (ssl) {
+                // Application protocol negotiator over TLS
+                pipeline.addLast(apnHandler(connectionHandler));
+            } else {
+                // Cleartext upgrade flow
+                final var sourceCodec = new HttpClientCodec();
+                final var upgradeHandler = new HttpClientUpgradeHandler(sourceCodec,
+                    new Http2ClientUpgradeCodec(connectionHandler), MAX_HTTP_CONTENT_LENGTH);
+                pipeline.addLast(sourceCodec, upgradeHandler, upgradeRequestHandler());
+            }
+
+        } else {
+            // HTTP 1.1
+            pipeline.addLast(new HttpClientCodec(), new HttpObjectAggregator(MAX_HTTP_CONTENT_LENGTH));
+            configureEndOfPipeline(pipeline);
+        }
+    }
+
+    private void configureEndOfPipeline(final ChannelPipeline pipeline) {
+        if (http2) {
+            pipeline.addLast(Http2Utils.clientSettingsHandler());
+        }
+        if (authProvider != null) {
+            pipeline.addLast(authProvider);
+        }
+        pipeline.addLast(dispatcherHandler);
+
+        // signal client transport is ready to send requests
+        // NB. while server signals readiness on exit from initChannel(),
+        // client needs additional confirmation for upgrade completion in case of HTTP/2 cleartext flow
+        completeFuture.set(null);
+    }
+
+    private ChannelHandler apnHandler(final ChannelHandler connectionHandler) {
+        return new ApplicationProtocolNegotiationHandler("") {
+            @Override
+            protected void configurePipeline(final ChannelHandlerContext ctx, final String protocol) {
+                if (ApplicationProtocolNames.HTTP_2.equals(protocol)) {
+                    final var pipeline = ctx.pipeline();
+                    pipeline.addLast(connectionHandler);
+                    configureEndOfPipeline(pipeline);
+                    return;
+                }
+                ctx.close();
+                throw new IllegalStateException("unknown protocol: " + protocol);
+            }
+        };
+    }
+
+    protected ChannelHandler upgradeRequestHandler() {
+        return new ChannelInboundHandlerAdapter() {
+            @Override
+            public void channelActive(final ChannelHandlerContext ctx) throws Exception {
+                // trigger upgrade by simple GET request;
+                // required headers and flow will be handled by HttpClientUpgradeHandler
+                ctx.writeAndFlush(new DefaultFullHttpRequest(HTTP_1_1, GET, "/", EMPTY_BUFFER));
+                ctx.fireChannelActive();
+            }
+
+            @Override
+            public void userEventTriggered(final ChannelHandlerContext ctx, final Object evt) throws Exception {
+                // process upgrade result
+                if (evt == HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_SUCCESSFUL) {
+                    configureEndOfPipeline(ctx.pipeline());
+                    ctx.pipeline().remove(this);
+                } else if (evt == HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_REJECTED) {
+                    completeFuture.setException(new IllegalStateException("Server rejected HTTP/2 upgrade request"));
+                }
+            }
+        };
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp1RequestDispatcher.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp1RequestDispatcher.java
new file mode 100644 (file)
index 0000000..e6a59f9
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Client side {@link RequestDispatcher} implementation for HTTP 1.1.
+ *
+ * <p>
+ * Serves as gateway to Netty {@link Channel}, performs sending requests to server, returns server responses associated.
+ * Uses request to response mapping via queue -- first accepted response is associated with first request sent.
+ */
+class ClientHttp1RequestDispatcher extends SimpleChannelInboundHandler<FullHttpResponse> implements RequestDispatcher {
+    private static final Logger LOG = LoggerFactory.getLogger(ClientHttp1RequestDispatcher.class);
+
+    private final Queue<SettableFuture<FullHttpResponse>> queue = new ConcurrentLinkedQueue<>();
+    private Channel channel = null;
+
+    ClientHttp1RequestDispatcher() {
+        super(true); // auto-release
+    }
+
+    @Override
+    public void handlerAdded(final ChannelHandlerContext ctx) throws Exception {
+        channel = ctx.channel();
+        super.handlerAdded(ctx);
+    }
+
+    @Override
+    public ListenableFuture<FullHttpResponse> dispatch(final FullHttpRequest request) {
+        if (channel == null) {
+            throw new IllegalStateException("Connection is not established yet");
+        }
+        final var future = SettableFuture.<FullHttpResponse>create();
+        channel.writeAndFlush(request).addListener(sent -> {
+            final var cause = sent.cause();
+            if (cause == null) {
+                queue.add(future);
+            } else {
+                future.setException(cause);
+            }
+        });
+        return future;
+    }
+
+    @Override
+    protected void channelRead0(final ChannelHandlerContext ctx, final FullHttpResponse response) {
+        final var future = queue.poll();
+        if (future == null) {
+            LOG.warn("Unexpected response while no future associated -- Dropping response object {}", response);
+            return;
+        }
+
+        if (!future.isDone()) {
+            // NB using response' copy to disconnect the content data from channel's buffer allocated.
+            // this prevents the content data became inaccessible once byte buffer of original message is released
+            // on exit of current method
+            future.set(response.copy());
+        } else {
+            LOG.warn("Future is already in Done state -- Dropping response object {}", response);
+        }
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp2RequestDispatcher.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ClientHttp2RequestDispatcher.java
new file mode 100644 (file)
index 0000000..3d5089e
--- /dev/null
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static io.netty.handler.codec.http2.HttpConversionUtil.ExtensionHeaderNames.SCHEME;
+import static io.netty.handler.codec.http2.HttpConversionUtil.ExtensionHeaderNames.STREAM_ID;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpScheme;
+import io.netty.handler.ssl.SslHandler;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Client side {@link RequestDispatcher} implementation for HTTP 2.
+ *
+ * <p>
+ * Serves as gateway to Netty {@link Channel}, performs sending requests to server, returns server responses associated.
+ * Uses request to response mapping by stream identifier.
+ */
+class ClientHttp2RequestDispatcher extends SimpleChannelInboundHandler<FullHttpResponse> implements RequestDispatcher {
+    private static final Logger LOG = LoggerFactory.getLogger(ClientHttp2RequestDispatcher.class);
+
+    private final Map<Integer, SettableFuture<FullHttpResponse>> map = new ConcurrentHashMap<>();
+    private final AtomicInteger streamIdCounter = new AtomicInteger(3);
+
+    private Channel channel = null;
+    private boolean ssl = false;
+
+    ClientHttp2RequestDispatcher() {
+        super(true); // auto-release
+    }
+
+    private Integer nextStreamId() {
+        // identifier for streams initiated from client require to be odd-numbered, 1 is reserved
+        // see https://datatracker.ietf.org/doc/html/rfc7540#section-5.1.1
+        return streamIdCounter.getAndAdd(2);
+    }
+
+    @Override
+    public void handlerAdded(final ChannelHandlerContext ctx) throws Exception {
+        channel = ctx.channel();
+        ssl = ctx.pipeline().get(SslHandler.class) != null;
+        super.handlerAdded(ctx);
+    }
+
+    @Override
+    public ListenableFuture<FullHttpResponse> dispatch(final FullHttpRequest request) {
+        if (channel == null) {
+            throw new IllegalStateException("Connection is not established yet");
+        }
+        final var streamId = nextStreamId();
+        request.headers().setInt(STREAM_ID.text(), streamId);
+        request.headers().set(SCHEME.text(), ssl ? HttpScheme.HTTPS.name() : HttpScheme.HTTP.name());
+
+        final var future = SettableFuture.<FullHttpResponse>create();
+        channel.writeAndFlush(request).addListener(sent -> {
+            if (sent.cause() == null) {
+                map.put(streamId, future);
+            } else {
+                future.setException(sent.cause());
+            }
+        });
+        return future;
+    }
+
+    @Override
+    protected void channelRead0(final ChannelHandlerContext ctx, final FullHttpResponse response) {
+        final var streamId = response.headers().getInt(STREAM_ID.text());
+        if (streamId == null) {
+            LOG.warn("Unexpected response with no stream ID -- Dropping response object {}", response);
+            return;
+        }
+        final var future = map.remove(streamId);
+        if (future == null) {
+            LOG.warn("Unexpected response with unknown or expired stream ID {} -- Dropping response object {}",
+                streamId, response);
+            return;
+        }
+        if (!future.isDone()) {
+            // NB using response' copy to disconnect the content data from channel's buffer allocated.
+            // this prevents the content data became inaccessible once byte buffer of original message is released
+            // on exit of current method
+            future.set(response.copy());
+        } else {
+            LOG.warn("Future is already in Done state -- Dropping response object {}", response);
+        }
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ConfigUtils.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ConfigUtils.java
new file mode 100644 (file)
index 0000000..831d96d
--- /dev/null
@@ -0,0 +1,385 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static java.util.Objects.requireNonNull;
+
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.iana.crypt.hash.rev140806.CryptHash;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.EcPrivateKeyFormat;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.EndEntityCertCms;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.RsaPrivateKeyFormat;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.SubjectPublicKeyInfoFormat;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.TrustAnchorCertCms;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208._private.key.grouping._private.key.type.CleartextPrivateKeyBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev240208.password.grouping.password.type.CleartextPasswordBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.http.client.identity.grouping.ClientIdentity;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.http.client.identity.grouping.ClientIdentityBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.ClientAuthentication;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.ClientAuthenticationBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.client.authentication.users.user.auth.type.basic.basic.PasswordBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.Host;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.IetfInetUtil;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.PortNumber;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.client.rev240208.tls.client.grouping.ServerAuthentication;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.client.rev240208.tls.client.grouping.ServerAuthenticationBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.client.rev240208.tls.client.grouping.server.authentication.EeCertsBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208.tls.server.grouping.ServerIdentity;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208.tls.server.grouping.ServerIdentityBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.truststore.rev240208.inline.or.truststore.certs.grouping.inline.or.truststore.inline.inline.definition.CertificateBuilder;
+import org.opendaylight.yangtools.yang.common.Uint16;
+
+/**
+ * Collection of methods to simplify HTTP transport configuration building.
+ */
+public final class ConfigUtils {
+
+    private ConfigUtils() {
+        // utility class
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TCP transport underlay with no authorization.
+     *
+     * @param host local address
+     * @param port local port
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTcp(final @NonNull String host, final int port) {
+        return serverTransportTcp(host, port, null);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TCP transport underlay with Basic Authorization.
+     *
+     * @param host local address
+     * @param port local port
+     * @param userCryptHashMap user credentials map for Basic Authorization where key is username and value is a
+     *      {@link CryptHash} value for user password
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTcp(final @NonNull String host, final int port,
+            final @Nullable Map<String, String> userCryptHashMap) {
+
+        final var tcpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tcp.tcp.TcpServerParametersBuilder()
+            .setLocalAddress(IetfInetUtil.ipAddressFor(requireNonNull(host)))
+            .setLocalPort(new PortNumber(Uint16.valueOf(port))).build();
+        final var httpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tcp.tcp.HttpServerParametersBuilder()
+            .setClientAuthentication(clientAuthentication(userCryptHashMap)).build();
+        return serverTransportTcp(tcpParams, httpParams);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TCP transport underlay.
+     *
+     * @param tcpParams TCP layer configuration
+     * @param httpParams HTTP layer configuration
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTcp(
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.stack.grouping.transport.tcp.tcp.@NonNull TcpServerParameters tcpParams,
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.stack.grouping.transport.tcp.tcp.@Nullable HttpServerParameters httpParams) {
+
+        final var tcp = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tcp.TcpBuilder()
+            .setTcpServerParameters(tcpParams).setHttpServerParameters(httpParams).build();
+        return new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.TcpBuilder().setTcp(tcp).build();
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TCP transport underlay with no authorization.
+     *
+     * @param host remote address
+     * @param port remote port
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTcp(final @NonNull String host, final int port) {
+        return clientTransportTcp(host, port, null, null);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TCP transport underlay with Basic Authorization.
+     *
+     * @param host remote address
+     * @param port remote port
+     * @param username username
+     * @param password password
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTcp(final @NonNull String host, final int port,
+            final @Nullable String username, final @Nullable String password) {
+
+        final var tcpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tcp.tcp.TcpClientParametersBuilder()
+            .setRemoteAddress(new Host(IetfInetUtil.ipAddressFor(requireNonNull(host))))
+            .setRemotePort(new PortNumber(Uint16.valueOf(port))).build();
+        final var httpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tcp.tcp.HttpClientParametersBuilder()
+            .setClientIdentity(clientIdentity(username, password)).build();
+        return clientTransportTcp(tcpParams, httpParams);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TCP transport underlay with no authorization.
+     *
+     * @param tcpParams TCP layer configuration
+     * @param httpParams HTTP layer configuration
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTcp(
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+                .http.client.stack.grouping.transport.tcp.tcp.@NonNull TcpClientParameters tcpParams,
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+                .http.client.stack.grouping.transport.tcp.tcp.@Nullable HttpClientParameters httpParams) {
+
+        final var tcp = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tcp.TcpBuilder()
+            .setTcpClientParameters(tcpParams).setHttpClientParameters(httpParams).build();
+        return new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.TcpBuilder().setTcp(tcp).build();
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TLS transport underlay with no authorization.
+     *
+     * @param host local address
+     * @param port local port
+     * @param certificate server X509 certificate
+     * @param privateKey server private key
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTls(final @NonNull String host, final int port,
+            final @NonNull Certificate certificate, final @NonNull PrivateKey privateKey) {
+        return serverTransportTls(host, port, certificate, privateKey, null);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TLS transport underlay with Basic Authorization.
+     *
+     * @param host local address
+     * @param port local port
+     * @param certificate server X509 certificate
+     * @param privateKey server private key
+     * @param userCryptHashMap user credentials map for Basic Authorization where key is username and value is a
+     *      {@link CryptHash} value for user password
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTls(final @NonNull String host, final int port,
+            final @NonNull Certificate certificate, final @NonNull PrivateKey privateKey,
+            final @Nullable Map<String, String> userCryptHashMap) {
+
+        final var tcpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tls.tls.TcpServerParametersBuilder()
+            .setLocalAddress(IetfInetUtil.ipAddressFor(requireNonNull(host)))
+            .setLocalPort(new PortNumber(Uint16.valueOf(port))).build();
+        final var tlsParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tls.tls.TlsServerParametersBuilder()
+            .setServerIdentity(serverIdentity(requireNonNull(certificate), requireNonNull(privateKey))).build();
+        final var httpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tls.tls.HttpServerParametersBuilder()
+            .setClientAuthentication(clientAuthentication(userCryptHashMap)).build();
+        return serverTransportTls(tcpParams, tlsParams, httpParams);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPServer} using TLS transport underlay.
+     *
+     * @param tcpParams TCP layer configuration
+     * @param tlsParams TLS layer configuration
+     * @param httpParams HTTP layer configuration
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+        .http.server.stack.grouping.Transport serverTransportTls(
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.stack.grouping.transport.tls.tls.@NonNull TcpServerParameters tcpParams,
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.stack.grouping.transport.tls.tls.@NonNull TlsServerParameters tlsParams,
+            final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.stack.grouping.transport.tls.tls.@Nullable HttpServerParameters httpParams) {
+
+        final var tls = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.tls.TlsBuilder()
+            .setTcpServerParameters(tcpParams)
+            .setTlsServerParameters(tlsParams)
+            .setHttpServerParameters(httpParams).build();
+        return new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+            .http.server.stack.grouping.transport.TlsBuilder().setTls(tls).build();
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TLS transport underlay with no authorization.
+     *
+     * @param host remote address
+     * @param port remote port
+     * @param certificate server certificate
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTls(@NonNull final String host, final int port,
+            @NonNull final Certificate certificate) {
+        return clientTransportTls(host, port, certificate, null, null);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TLS transport underlay with Basic Authorization.
+     *
+     * @param host remote address
+     * @param port remote port
+     * @param certificate server certificate
+     * @param username username
+     * @param password password
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTls(@NonNull final String host, final int port,
+            @NonNull final Certificate certificate, @Nullable final String username, @Nullable final String password) {
+
+        final var tcpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.TcpClientParametersBuilder()
+            .setRemoteAddress(new Host(IetfInetUtil.ipAddressFor(requireNonNull(host))))
+            .setRemotePort(new PortNumber(Uint16.valueOf(port))).build();
+        final var tlsParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.TlsClientParametersBuilder()
+            .setServerAuthentication(serverAuthentication(requireNonNull(certificate))).build();
+        final var httpParams = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.HttpClientParametersBuilder()
+            .setClientIdentity(clientIdentity(username, password)).build();
+        return clientTransportTls(tcpParams, tlsParams, httpParams);
+    }
+
+    /**
+     * Builds transport configuration for {@link HTTPClient} using TLS transport.
+     *
+     * @param tcpParams TCP layer configuration
+     * @param tlsParams TLS layer configuration
+     * @param httpParams HTTP layer configuration
+     * @return transport configuration
+     */
+    public static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+        .http.client.stack.grouping.Transport clientTransportTls(
+        final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.@NonNull TcpClientParameters tcpParams,
+        final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.@NonNull TlsClientParameters tlsParams,
+        final org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.tls.@Nullable HttpClientParameters httpParams) {
+
+        final var tls = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.tls.TlsBuilder()
+            .setTcpClientParameters(tcpParams).setTlsClientParameters(tlsParams)
+            .setHttpClientParameters(httpParams).build();
+        return new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+            .http.client.stack.grouping.transport.TlsBuilder().setTls(tls).build();
+    }
+
+    private static @Nullable ClientAuthentication clientAuthentication(
+            final @Nullable Map<String, String> userCryptHashMap) {
+        if (userCryptHashMap == null || userCryptHashMap.isEmpty()) {
+            return null;
+        }
+        final var userMap = userCryptHashMap.entrySet().stream()
+            .map(entry -> new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.grouping.client.authentication.users.UserBuilder()
+                .setUserId(entry.getKey())
+                .setAuthType(
+                    new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                        .http.server.grouping.client.authentication.users.user.auth.type.BasicBuilder().setBasic(
+                            new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                                .http.server.grouping.client.authentication.users.user.auth.type.basic.BasicBuilder()
+                                .setUsername(entry.getKey())
+                                .setPassword(new PasswordBuilder()
+                                    .setHashedPassword(new CryptHash(entry.getValue())).build()).build()
+                    ).build()).build())
+            .collect(Collectors.toMap(user -> user.key(), user -> user));
+        return new ClientAuthenticationBuilder()
+            .setUsers(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                .http.server.grouping.client.authentication.UsersBuilder().setUser(userMap).build()).build();
+    }
+
+    private static @Nullable ClientIdentity clientIdentity(final @Nullable String username,
+            final @Nullable String password) {
+        if (username == null || password == null) {
+            return null;
+        }
+        return new ClientIdentityBuilder().setAuthType(
+            new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+                .http.client.identity.grouping.client.identity.auth.type.BasicBuilder()
+                .setBasic(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208
+                    .http.client.identity.grouping.client.identity.auth.type.basic.BasicBuilder().setUserId(username)
+                    .setPasswordType(new CleartextPasswordBuilder().setCleartextPassword(password).build())
+                    .build()).build()).build();
+    }
+
+    private static ServerIdentity serverIdentity(final Certificate certificate, final PrivateKey privateKey) {
+        final var privateKeyFormat = switch (privateKey.getAlgorithm()) {
+            case "RSA" -> RsaPrivateKeyFormat.VALUE;
+            case "EC" -> EcPrivateKeyFormat.VALUE;
+            default -> throw new IllegalArgumentException("Only RSA and EC algorithms are supported for private key");
+        };
+        final var cert = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208
+            .tls.server.grouping.server.identity.auth.type.certificate.CertificateBuilder()
+            .setInlineOrKeystore(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.keystore.rev240208
+                .inline.or.keystore.end.entity.cert.with.key.grouping.inline.or.keystore.InlineBuilder()
+                .setInlineDefinition(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.keystore
+                    .rev240208.inline.or.keystore.end.entity.cert.with.key.grouping.inline.or.keystore.inline
+                    .InlineDefinitionBuilder()
+                    .setPublicKeyFormat(SubjectPublicKeyInfoFormat.VALUE)
+                    .setPublicKey(certificate.getPublicKey().getEncoded())
+                    .setPrivateKeyFormat(privateKeyFormat)
+                    .setPrivateKeyType(new CleartextPrivateKeyBuilder()
+                        .setCleartextPrivateKey(privateKey.getEncoded()).build())
+                    .setCertData(new EndEntityCertCms(certificateBytes(certificate)))
+                    .build())
+                .build())
+            .build();
+        return new ServerIdentityBuilder().setAuthType(
+            new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208
+            .tls.server.grouping.server.identity.auth.type.CertificateBuilder()
+                .setCertificate(cert).build()).build();
+    }
+
+    private static ServerAuthentication serverAuthentication(final Certificate certificate) {
+        final var cert = new CertificateBuilder().setName("certificate")
+            .setCertData(new TrustAnchorCertCms(certificateBytes(certificate))).build();
+        final var inline = new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.truststore.rev240208
+            .inline.or.truststore.certs.grouping.inline.or.truststore.InlineBuilder()
+            .setInlineDefinition(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.truststore.rev240208
+                .inline.or.truststore.certs.grouping.inline.or.truststore.inline.InlineDefinitionBuilder()
+                .setCertificate(Map.of(cert.key(), cert)).build()).build();
+        return new ServerAuthenticationBuilder().setEeCerts(
+            new EeCertsBuilder().setInlineOrTruststore(inline).build()).build();
+    }
+
+    private static byte[] certificateBytes(final Certificate certificate) {
+        try {
+            return certificate.getEncoded();
+        } catch (CertificateEncodingException e) {
+            throw new IllegalArgumentException("Certificate bytes are ", e);
+        }
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPClient.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPClient.java
new file mode 100644 (file)
index 0000000..45a3a36
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
+import org.opendaylight.netconf.transport.tcp.TCPClient;
+import org.opendaylight.netconf.transport.tls.TLSClient;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientStackGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.http.client.stack.grouping.transport.Tcp;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.http.client.stack.grouping.transport.Tls;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.client.rev240208.TcpClientGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.client.rev240208.TlsClientGrouping;
+
+/**
+ * A {@link HTTPTransportStack} acting as a client.
+ */
+public final class HTTPClient extends HTTPTransportStack {
+
+    private final RequestDispatcher dispatcher;
+
+    private HTTPClient(final TransportChannelListener listener, final HttpChannelInitializer channelInitializer,
+            final RequestDispatcher dispatcher) {
+        super(listener, channelInitializer);
+        this.dispatcher = dispatcher;
+    }
+
+    /**
+     * Invokes the HTTP request over established connection.
+     *
+     * @param request the full http request object
+     * @return a future providing full http response or cause in case of error
+     */
+    public ListenableFuture<FullHttpResponse> invoke(final FullHttpRequest request) {
+        return dispatcher.dispatch(requireNonNull(request));
+    }
+
+    /**
+     * Attempt to establish a {@link HTTPClient} by connecting to a remote address.
+     *
+     * @param listener {@link TransportChannelListener} to notify when the session is established
+     * @param bootstrap Client {@link Bootstrap} to use for the underlying Netty channel
+     * @param connectParams Connection parameters
+     * @param http2 indicates HTTP/2 protocol to be used
+     * @return A future
+     * @throws UnsupportedConfigurationException when {@code connectParams} contains an unsupported options
+     * @throws NullPointerException if any argument is {@code null}
+     */
+    public static ListenableFuture<HTTPClient> connect(final TransportChannelListener listener,
+            final Bootstrap bootstrap, final HttpClientStackGrouping connectParams, final boolean http2)
+            throws UnsupportedConfigurationException {
+        final HttpClientGrouping httpParams;
+        final TcpClientGrouping tcpParams;
+        final TlsClientGrouping tlsParams;
+        final var transport = requireNonNull(connectParams).getTransport();
+        if (transport instanceof Tcp tcp) {
+            httpParams = tcp.getTcp().getHttpClientParameters();
+            tcpParams = tcp.getTcp().nonnullTcpClientParameters();
+            tlsParams = null;
+        } else if (transport instanceof Tls tls) {
+            httpParams = tls.getTls().getHttpClientParameters();
+            tcpParams = tls.getTls().nonnullTcpClientParameters();
+            tlsParams = tls.getTls().nonnullTlsClientParameters();
+        } else {
+            throw new UnsupportedConfigurationException("Unsupported transport: " + transport);
+        }
+        final var dispatcher = http2 ? new ClientHttp2RequestDispatcher() : new ClientHttp1RequestDispatcher();
+        final var client = new HTTPClient(listener, new ClientChannelInitializer(httpParams, dispatcher, http2),
+            dispatcher);
+        final var underlay = tlsParams == null
+            ? TCPClient.connect(client.asListener(), bootstrap, tcpParams)
+            : TLSClient.connect(client.asListener(), bootstrap, tcpParams, new HttpSslHandlerFactory(tlsParams, http2));
+        return transformUnderlay(client, underlay);
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPServer.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPServer.java
new file mode 100644 (file)
index 0000000..ea8f06e
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.bootstrap.ServerBootstrap;
+import org.eclipse.jdt.annotation.NonNull;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
+import org.opendaylight.netconf.transport.tcp.TCPServer;
+import org.opendaylight.netconf.transport.tls.TLSServer;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerStackGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.stack.grouping.transport.Tcp;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.stack.grouping.transport.Tls;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev240208.TcpServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208.TlsServerGrouping;
+
+/**
+ * A {@link HTTPTransportStack} acting as a server.
+ */
+public final class HTTPServer extends HTTPTransportStack {
+
+    private HTTPServer(final TransportChannelListener listener, final HttpChannelInitializer channelInitializer) {
+        super(listener, channelInitializer);
+    }
+
+    /**
+     * Attempt to establish a {@link HTTPServer} on a local address.
+     *
+     * @param listener {@link TransportChannelListener} to notify when the session is established
+     * @param bootstrap {@link ServerBootstrap} to use for the underlying Netty server channel
+     * @param listenParams Listening parameters
+     * @return A future
+     * @throws UnsupportedConfigurationException when {@code listenParams} contains an unsupported options
+     * @throws NullPointerException if any argument is {@code null}
+     */
+    public static @NonNull ListenableFuture<HTTPServer> listen(final TransportChannelListener listener,
+            final ServerBootstrap bootstrap, final HttpServerStackGrouping listenParams,
+            final RequestDispatcher dispatcher) throws UnsupportedConfigurationException {
+        final HttpServerGrouping httpParams;
+        final TcpServerGrouping tcpParams;
+        final TlsServerGrouping tlsParams;
+        final var transport = requireNonNull(listenParams).getTransport();
+        if (transport instanceof Tcp tcp) {
+            httpParams = tcp.getTcp().getHttpServerParameters();
+            tcpParams = tcp.getTcp().nonnullTcpServerParameters();
+            tlsParams = null;
+        } else if (transport instanceof Tls tls) {
+            httpParams = tls.getTls().getHttpServerParameters();
+            tcpParams = tls.getTls().nonnullTcpServerParameters();
+            tlsParams = tls.getTls().nonnullTlsServerParameters();
+        } else {
+            throw new UnsupportedConfigurationException("Unsupported transport: " + transport);
+        }
+        final var server = new HTTPServer(listener,
+            new ServerChannelInitializer(httpParams, requireNonNull(dispatcher)));
+        final var underlay = tlsParams == null
+            ? TCPServer.listen(server.asListener(), bootstrap, tcpParams)
+            : TLSServer.listen(server.asListener(), bootstrap, tcpParams, new HttpSslHandlerFactory(tlsParams));
+        return transformUnderlay(server, underlay);
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportChannel.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportChannel.java
new file mode 100644 (file)
index 0000000..34ea12e
--- /dev/null
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import org.opendaylight.netconf.transport.api.AbstractOverlayTransportChannel;
+import org.opendaylight.netconf.transport.api.TransportChannel;
+
+public class HTTPTransportChannel extends AbstractOverlayTransportChannel {
+    public HTTPTransportChannel(final TransportChannel transportChannel) {
+        super(transportChannel);
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportStack.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HTTPTransportStack.java
new file mode 100644 (file)
index 0000000..482a4af
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import org.eclipse.jdt.annotation.NonNull;
+import org.opendaylight.netconf.transport.api.AbstractOverlayTransportStack;
+import org.opendaylight.netconf.transport.api.TransportChannel;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+
+public abstract sealed class HTTPTransportStack extends AbstractOverlayTransportStack<HTTPTransportChannel>
+        permits HTTPClient, HTTPServer {
+    final HttpChannelInitializer channelInitializer;
+
+    public HTTPTransportStack(final TransportChannelListener listener, final HttpChannelInitializer handler) {
+        super(listener);
+        this.channelInitializer = handler;
+    }
+
+    @Override
+    protected void onUnderlayChannelEstablished(final @NonNull TransportChannel underlayChannel) {
+        underlayChannel.channel().pipeline().addLast(channelInitializer);
+        Futures.addCallback(channelInitializer.completeFuture(), new FutureCallback<>() {
+            @Override
+            public void onSuccess(final Void result) {
+                addTransportChannel(new HTTPTransportChannel(underlayChannel));
+            }
+
+            @Override
+            public void onFailure(Throwable cause) {
+                notifyTransportChannelFailed(cause);
+            }
+        }, MoreExecutors.directExecutor());
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/Http2Utils.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/Http2Utils.java
new file mode 100644 (file)
index 0000000..70979a8
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static io.netty.handler.codec.http2.HttpConversionUtil.ExtensionHeaderNames.STREAM_ID;
+
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.HttpMessage;
+import io.netty.handler.codec.http2.DefaultHttp2Connection;
+import io.netty.handler.codec.http2.DelegatingDecompressorFrameListener;
+import io.netty.handler.codec.http2.Http2ConnectionHandler;
+import io.netty.handler.codec.http2.Http2FrameLogger;
+import io.netty.handler.codec.http2.Http2Settings;
+import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder;
+import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder;
+import io.netty.handler.logging.LogLevel;
+
+/**
+ * Collection of utility methods building HTTP/2 handlers.
+ */
+final class Http2Utils {
+
+    private static final Http2FrameLogger CLIENT_FRAME_LOGGER = new Http2FrameLogger(LogLevel.INFO, "Client");
+    private static final Http2FrameLogger SERVER_FRAME_LOGGER = new Http2FrameLogger(LogLevel.INFO, "Server");
+
+    private Http2Utils() {
+        // utility class
+    }
+
+    /**
+     * Build external HTTP/2 to internal Http 1.1. adaptor handler.
+     *
+     * @param server true for server, false for client
+     * @param maxContentLength max content length for http messages
+     * @return connection handler instance
+     */
+    static Http2ConnectionHandler connectionHandler(final boolean server, final int maxContentLength) {
+        final var connection = new DefaultHttp2Connection(server);
+        return new HttpToHttp2ConnectionHandlerBuilder()
+            .frameListener(new DelegatingDecompressorFrameListener(
+                connection,
+                new InboundHttp2ToHttpAdapterBuilder(connection)
+                    .maxContentLength(maxContentLength)
+                    .propagateSettings(true)
+                    .build()))
+            .connection(connection)
+            .frameLogger(server ? SERVER_FRAME_LOGGER : CLIENT_FRAME_LOGGER)
+            .gracefulShutdownTimeoutMillis(0L)
+            .build();
+    }
+
+    /**
+     * Build a handler consuming Http2Settings message.
+     *
+     * @return handler instance
+     */
+    static ChannelHandler clientSettingsHandler() {
+        return new SimpleChannelInboundHandler<Http2Settings>() {
+            @Override
+            protected void channelRead0(final ChannelHandlerContext ctx, final Http2Settings msg) throws Exception {
+                // the HTTP 2 Settings message is expected once, just consume it then remove itself
+                ctx.pipeline().remove(this);
+            }
+        };
+    }
+
+    /**
+     * Copies HTTP/2 associated stream id value (if exists) from one HTTP 1.1 message to another.
+     *
+     * @param from the message object to copy value from
+     * @param to the message object to copy value to
+     */
+    static void copyStreamId(final HttpMessage from, final HttpMessage to) {
+        final var streamId = from.headers().getInt(STREAM_ID.text());
+        if (streamId != null) {
+            to.headers().setInt(STREAM_ID.text(), streamId);
+        }
+    }
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpChannelInitializer.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpChannelInitializer.java
new file mode 100644 (file)
index 0000000..5e53505
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.channel.ChannelHandler;
+
+/**
+ * HTTP Channel initializer interface.
+ */
+interface HttpChannelInitializer extends ChannelHandler {
+
+    /**
+     * Returns future indicating channel initialization completion.
+     *
+     * @return listenable future associated with this channel initializer.
+     */
+    ListenableFuture<Void> completeFuture();
+
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpSslHandlerFactory.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/HttpSslHandlerFactory.java
new file mode 100644 (file)
index 0000000..12e07f7
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import io.netty.handler.ssl.ApplicationProtocolConfig;
+import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.SslContext;
+import java.net.SocketAddress;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
+import org.opendaylight.netconf.transport.tls.SslHandlerFactory;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.client.rev240208.TlsClientGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tls.server.rev240208.TlsServerGrouping;
+
+class HttpSslHandlerFactory extends SslHandlerFactory {
+
+    private static final ApplicationProtocolConfig APN = new ApplicationProtocolConfig(
+        ApplicationProtocolConfig.Protocol.ALPN,
+        ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
+        ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
+        ApplicationProtocolNames.HTTP_2,
+        ApplicationProtocolNames.HTTP_1_1);
+
+    private final SslContext sslContext;
+
+    HttpSslHandlerFactory(final @NonNull TlsServerGrouping params) throws UnsupportedConfigurationException {
+        sslContext = createSslContext(params, APN);
+    }
+
+    HttpSslHandlerFactory(final @NonNull TlsClientGrouping params, final boolean http2)
+            throws UnsupportedConfigurationException {
+        sslContext = http2 ? createSslContext(params, APN) : createSslContext(params);
+    }
+
+    @Override
+    protected @Nullable SslContext getSslContext(SocketAddress remoteAddress) {
+        return sslContext;
+    }
+}
index 003636836b704f3112d1a50ae91f00dda09b40d6..a3ee8dd5ee85afc78cc400dbfcfe9534d09d0ded 100644 (file)
@@ -10,6 +10,7 @@ package org.opendaylight.netconf.transport.http;
 import java.util.Set;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.kohsuke.MetaInfServices;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.BasicAuth;
 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.IetfHttpServerData;
 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.TcpSupported;
 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.TlsSupported;
@@ -29,7 +30,6 @@ public final class IetfHttpServerFeatureProvider implements YangFeatureProvider<
 
     @Override
     public Set<? extends YangFeature<?, IetfHttpServerData>> supportedFeatures() {
-        // FIXME: BasicAuth?
-        return Set.of(TcpSupported.VALUE, TlsSupported.VALUE);
+        return Set.of(BasicAuth.VALUE, TcpSupported.VALUE, TlsSupported.VALUE);
     }
 }
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/RequestDispatcher.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/RequestDispatcher.java
new file mode 100644 (file)
index 0000000..7373f0c
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+
+/**
+ * Functional interface for HTTP request dispatcher.
+ */
+@FunctionalInterface
+public interface RequestDispatcher {
+
+    /**
+     * Performs {@link FullHttpRequest} processing. Any error occurred is expected either to be returned within
+     * {@link FullHttpResponse} with appropriate HTTP status code or set as future cause.
+     *
+     * @param request http request
+     * @return future providing http response or cause in case of error.
+     */
+    ListenableFuture<FullHttpResponse> dispatch(FullHttpRequest request);
+}
diff --git a/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ServerChannelInitializer.java b/transport/transport-http/src/main/java/org/opendaylight/netconf/transport/http/ServerChannelInitializer.java
new file mode 100644 (file)
index 0000000..92e3832
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static io.netty.buffer.Unpooled.EMPTY_BUFFER;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
+import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN;
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static org.opendaylight.netconf.transport.http.Http2Utils.copyStreamId;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpMessage;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http.HttpServerCodec;
+import io.netty.handler.codec.http.HttpServerKeepAliveHandler;
+import io.netty.handler.codec.http.HttpServerUpgradeHandler;
+import io.netty.handler.codec.http2.CleartextHttp2ServerUpgradeHandler;
+import io.netty.handler.codec.http2.Http2CodecUtil;
+import io.netty.handler.codec.http2.Http2ConnectionHandler;
+import io.netty.handler.codec.http2.Http2ServerUpgradeCodec;
+import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
+import io.netty.handler.ssl.SslHandler;
+import io.netty.util.AsciiString;
+import io.netty.util.ReferenceCountUtil;
+import java.nio.charset.StandardCharsets;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerGrouping;
+
+/**
+ * Netty channel initializer for Http Server.
+ */
+class ServerChannelInitializer extends ChannelInitializer<Channel> implements HttpChannelInitializer {
+    private static final int MAX_HTTP_CONTENT_LENGTH = 16 * 1024;
+
+    private final SettableFuture<Void> completeFuture = SettableFuture.create();
+    private final ChannelHandler authHandler;
+    private final RequestDispatcher dispatcher;
+
+    ServerChannelInitializer(final HttpServerGrouping httpParams, final RequestDispatcher dispatcher) {
+        super();
+        authHandler = BasicAuthHandler.ofNullable(httpParams);
+        this.dispatcher = dispatcher;
+    }
+
+    @Override
+    public ListenableFuture<Void> completeFuture() {
+        return completeFuture;
+    }
+
+    @Override
+    protected void initChannel(final Channel channel) throws Exception {
+        final var pipeline = channel.pipeline();
+        final var ssl = pipeline.get(SslHandler.class) != null;
+
+        // External HTTP 2 to internal HTTP 1.1 adapter handler
+        final var connectionHandler = Http2Utils.connectionHandler(true, MAX_HTTP_CONTENT_LENGTH);
+        if (ssl) {
+            // Application protocol negotiator over TLS
+            pipeline.addLast(apnHandler(connectionHandler));
+        } else {
+            // Cleartext upgrade flow
+            final var sourceCodec = new HttpServerCodec();
+            final var upgradeHandler =
+                new HttpServerUpgradeHandler(sourceCodec, upgradeCodecFactory(connectionHandler));
+            pipeline.addLast(new CleartextHttp2ServerUpgradeHandler(sourceCodec, upgradeHandler, connectionHandler),
+                upgradeResultHandler());
+        }
+
+        // signal server transport is ready to accept requests
+        completeFuture.set(null);
+    }
+
+    private void configureEndOfPipeline(final ChannelPipeline pipeline) {
+        if (authHandler != null) {
+            pipeline.addLast(authHandler);
+        }
+        pipeline.addLast(serverHandler(dispatcher));
+    }
+
+    private ChannelHandler apnHandler(final ChannelHandler connectionHandler) {
+        return new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_1_1) {
+            @Override
+            protected void configurePipeline(final ChannelHandlerContext ctx, final String protocol) throws Exception {
+                final var pipeline = ctx.pipeline();
+                if (ApplicationProtocolNames.HTTP_2.equals(protocol)) {
+                    pipeline.addLast(connectionHandler);
+                    configureEndOfPipeline(pipeline);
+                    return;
+                }
+                if (ApplicationProtocolNames.HTTP_1_1.equals(protocol)) {
+                    pipeline.addLast(new HttpServerCodec(),
+                        new HttpServerKeepAliveHandler(),
+                        new HttpObjectAggregator(MAX_HTTP_CONTENT_LENGTH));
+                    configureEndOfPipeline(pipeline);
+                    return;
+                }
+                throw new IllegalStateException("unknown protocol: " + protocol);
+            }
+        };
+    }
+
+    private ChannelHandler upgradeResultHandler() {
+        // the handler processes cleartext upgrade result
+
+        return new SimpleChannelInboundHandler<HttpMessage>() {
+            @Override
+            protected void channelRead0(final ChannelHandlerContext ctx, final HttpMessage request) throws Exception {
+                // if there was no upgrade to HTTP/2 the incoming message is accepted via channel read;
+                // configure HTTP 1.1 flow, pass the message further the pipeline, remove self as no longer required
+                final var pipeline = ctx.pipeline();
+                pipeline.addLast(new HttpServerKeepAliveHandler(), new HttpObjectAggregator(MAX_HTTP_CONTENT_LENGTH));
+                configureEndOfPipeline(pipeline);
+                ctx.fireChannelRead(ReferenceCountUtil.retain(request));
+                pipeline.remove(this);
+            }
+
+            @Override
+            public void userEventTriggered(final ChannelHandlerContext ctx, final Object event) throws Exception {
+                // if there was upgrade to HTTP/2 the upgrade event is fired further the pipeline;
+                // on event occurrence it's only required to complete the configuration for future requests,
+                // then remove self as no longer required
+                if (event instanceof HttpServerUpgradeHandler.UpgradeEvent) {
+                    final var pipeline = ctx.pipeline();
+                    configureEndOfPipeline(pipeline);
+                    pipeline.remove(this);
+                }
+            }
+        };
+    }
+
+    private static HttpServerUpgradeHandler.UpgradeCodecFactory upgradeCodecFactory(
+            final Http2ConnectionHandler connectionHandler) {
+        return protocol -> {
+            if (AsciiString.contentEquals(Http2CodecUtil.HTTP_UPGRADE_PROTOCOL_NAME, protocol)) {
+                return new Http2ServerUpgradeCodec(connectionHandler);
+            } else {
+                return null;
+            }
+        };
+    }
+
+    private static ChannelHandler serverHandler(final RequestDispatcher dispatcher) {
+        return new SimpleChannelInboundHandler<FullHttpRequest>() {
+            @Override
+            protected void channelRead0(final ChannelHandlerContext ctx, final FullHttpRequest request)
+                    throws Exception {
+                Futures.addCallback(dispatcher.dispatch(request.retain()),
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(final FullHttpResponse response) {
+                            copyStreamId(request, response);
+                            request.release();
+                            ctx.writeAndFlush(response);
+                        }
+
+                        @Override
+                        public void onFailure(final Throwable throwable) {
+                            final var message = throwable.getMessage();
+                            final var content = message == null ? EMPTY_BUFFER
+                                : Unpooled.wrappedBuffer(message.getBytes(StandardCharsets.UTF_8));
+                            final var response = new DefaultFullHttpResponse(request.protocolVersion(),
+                                INTERNAL_SERVER_ERROR, content);
+                            response.headers().set(CONTENT_TYPE, TEXT_PLAIN)
+                                .setInt(CONTENT_LENGTH, response.content().readableBytes());
+                            copyStreamId(request, response);
+                            request.release();
+                            ctx.writeAndFlush(response);
+                        }
+                    }, MoreExecutors.directExecutor());
+            }
+        };
+    }
+}
diff --git a/transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/BasicAuthHandlerTest.java b/transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/BasicAuthHandlerTest.java
new file mode 100644 (file)
index 0000000..aeb006b
--- /dev/null
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.opendaylight.netconf.transport.http.BasicAuthHandler.BASIC_AUTH_PREFIX;
+
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http.DefaultHttpRequest;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpRequest;
+import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpVersion;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.stream.Stream;
+import org.apache.commons.codec.digest.Crypt;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.iana.crypt.hash.rev140806.CryptHash;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.ClientAuthentication;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.ClientAuthenticationBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.client.authentication.users.UserBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.client.authentication.users.user.auth.type.basic.BasicBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.http.server.grouping.client.authentication.users.user.auth.type.basic.basic.PasswordBuilder;
+import org.opendaylight.yangtools.yang.binding.util.BindingMap;
+
+public class BasicAuthHandlerTest {
+    private static final String USERNAME1 = "username-1";
+    private static final String USERNAME2 = "username-2";
+    private static final String PASSWORD1 = "pa$$W0rd!1";
+    private static final String PASSWORD2 = "pa$$W0rd#2";
+    private static final String HASHED_PASSWORD2 = Crypt.crypt(PASSWORD2, "$6$rounds=4500$sha512salt");
+
+    private EmbeddedChannel channel;
+
+    @BeforeEach
+    void beforeEach() {
+        final var authHandler = BasicAuthHandler.ofNullable(new HttpServerGrouping() {
+            @Override
+            public Class<? extends HttpServerGrouping> implementedInterface() {
+                throw new UnsupportedOperationException();
+            }
+
+            @Override
+            public String getServerName() {
+                return null;
+            }
+
+            @Override
+            public ClientAuthentication getClientAuthentication() {
+                final var user1 = new UserBuilder()
+                    .setUserId(USERNAME1)
+                    .setAuthType(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                            .http.server.grouping.client.authentication.users.user.auth.type.BasicBuilder()
+                        .setBasic(new BasicBuilder()
+                            .setUsername(USERNAME1)
+                            .setPassword(new PasswordBuilder()
+                                .setHashedPassword(new CryptHash("$0$" + PASSWORD1))
+                                .build())
+                            .build())
+                        .build())
+                    .build();
+                final var user2 = new UserBuilder()
+                    .setUserId(USERNAME2)
+                    .setAuthType(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                            .http.server.grouping.client.authentication.users.user.auth.type.BasicBuilder()
+                        .setBasic(new BasicBuilder()
+                            .setUsername(USERNAME2)
+                            .setPassword(new PasswordBuilder()
+                                .setHashedPassword(new CryptHash(HASHED_PASSWORD2))
+                                .build())
+                            .build())
+                        .build())
+                    .build();
+
+                return new ClientAuthenticationBuilder()
+                    .setUsers(new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208
+                        .http.server.grouping.client.authentication.UsersBuilder()
+                        .setUser(BindingMap.of(user1, user2)).build()).build();
+            }
+        });
+        assertNotNull(authHandler);
+
+        channel = new EmbeddedChannel(authHandler);
+    }
+
+    @ParameterizedTest(name = "BasicAuth success: {0} password configured")
+    @MethodSource("authSuccessArgs")
+    void authSuccess(final String testDesc, final String username, final String password) {
+        final String authHeader = authHeader(BASIC_AUTH_PREFIX, username, password);
+        final var request = newHttpRequest(authHeader);
+        channel.writeInbound(request);
+        // nonnull read indicates the message is passed for next handler
+        assertEquals(request, channel.readInbound());
+    }
+
+    private static Stream<Arguments> authSuccessArgs() {
+        return Stream.of(
+            // test descriptor, username, password
+            Arguments.of("unencrypted", USERNAME1, PASSWORD1),
+            Arguments.of("sha512 encrypted", USERNAME2, PASSWORD2));
+    }
+
+    @ParameterizedTest(name = "BasicAuth failure: {0}")
+    @MethodSource("authFailureArgs")
+    void authFailure(final String testDesc, final String authHeader) {
+        channel.writeInbound(newHttpRequest(authHeader));
+        // null indicates the request is consumed and not passed to next handler
+        assertNull(channel.readInbound());
+        // verify response
+        final var outbound = channel.readOutbound();
+        assertNotNull(outbound);
+        final var response = assertInstanceOf(HttpResponse.class, outbound);
+        assertEquals(HttpResponseStatus.UNAUTHORIZED, response.status());
+    }
+
+    private static Stream<Arguments> authFailureArgs() {
+        return Stream.of(
+            // test descriptor, auth header
+            Arguments.of("no Authorization header", null),
+            Arguments.of("Authorization header does not start with `Basic`", "Bearer ABCD+"),
+            Arguments.of("Base64 decode failure", BASIC_AUTH_PREFIX + "cannot-decode-this"),
+            Arguments.of("No expected username:password",
+                BASIC_AUTH_PREFIX + Base64.getEncoder().encodeToString("abcd".getBytes(StandardCharsets.UTF_8))),
+            Arguments.of("Unknown user", authHeader(BASIC_AUTH_PREFIX, "unknown", "user")),
+            Arguments.of("Wrong password", authHeader(BASIC_AUTH_PREFIX, USERNAME1, PASSWORD2)));
+    }
+
+    private static String authHeader(final String prefix, final String username, final String password) {
+        return prefix + Base64.getEncoder()
+            .encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static HttpRequest newHttpRequest(final String authHeader) {
+        final var request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/uri");
+        if (authHeader != null) {
+            request.headers().add(HttpHeaderNames.AUTHORIZATION, authHeader);
+        }
+        return request;
+    }
+}
diff --git a/transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/HttpClientServerTest.java b/transport/transport-http/src/test/java/org/opendaylight/netconf/transport/http/HttpClientServerTest.java
new file mode 100644 (file)
index 0000000..cc600b2
--- /dev/null
@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech s.r.o. and others. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.netconf.transport.http;
+
+import static io.netty.buffer.Unpooled.wrappedBuffer;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONNECTION;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
+import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
+import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
+import static io.netty.handler.codec.http.HttpHeaderValues.TEXT_PLAIN;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTcp;
+import static org.opendaylight.netconf.transport.http.ConfigUtils.clientTransportTls;
+import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTcp;
+import static org.opendaylight.netconf.transport.http.ConfigUtils.serverTransportTls;
+
+import com.google.common.util.concurrent.SettableFuture;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpMethod;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.RSAKeyGenParameterSpec;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+import org.opendaylight.netconf.transport.tcp.BootstrapFactory;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.client.rev240208.HttpClientStackGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.http.server.rev240208.HttpServerStackGrouping;
+
+@ExtendWith(MockitoExtension.class)
+public class HttpClientServerTest {
+
+    private static final String USERNAME = "username";
+    private static final String PASSWORD = "pa$$W0rd";
+    private static final Map<String, String> USER_HASHES_MAP = Map.of(USERNAME, "$0$" + PASSWORD);
+    private static final AtomicInteger COUNTER = new AtomicInteger(0);
+    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+    private static final String[] METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"};
+    private static final String RESPONSE_TEMPLATE = "Method: %s URI: %s Payload: %s";
+
+    private static ScheduledExecutorService scheduledExecutor;
+    private static RequestDispatcher requestDispatcher;
+    private static BootstrapFactory bootstrapFactory;
+    private static String localAddress;
+
+    @Mock
+    private HttpServerStackGrouping serverConfig;
+    @Mock
+    private HttpClientStackGrouping clientConfig;
+    @Mock
+    private TransportChannelListener serverTransportListener;
+    @Mock
+    private TransportChannelListener clientTransportListener;
+
+    @BeforeAll
+    static void beforeAll() {
+        bootstrapFactory = new BootstrapFactory("IntegrationTest", 0);
+        localAddress = InetAddress.getLoopbackAddress().getHostAddress();
+        scheduledExecutor = Executors.newSingleThreadScheduledExecutor();
+
+        requestDispatcher = request -> {
+            final var future = SettableFuture.<FullHttpResponse>create();
+            // emulate asynchronous server request processing - run in separate thread with 100 millis delay
+            scheduledExecutor.schedule(() -> {
+                // return 200 response with a content built from request parameters
+                final var method = request.method().name();
+                final var uri = request.uri();
+                final var payload = request.content().readableBytes() > 0
+                    ? request.content().toString(StandardCharsets.UTF_8) : "";
+                final var responseMessage = RESPONSE_TEMPLATE.formatted(method, uri, payload);
+                final var response = new DefaultFullHttpResponse(request.protocolVersion(), OK,
+                    wrappedBuffer(responseMessage.getBytes(StandardCharsets.UTF_8)));
+                response.headers().set(CONTENT_TYPE, TEXT_PLAIN)
+                    .setInt(CONTENT_LENGTH, response.content().readableBytes());
+                return future.set(response);
+            }, 100, TimeUnit.MILLISECONDS);
+            return future;
+        };
+    }
+
+    @AfterAll
+    static void afterAll() {
+        bootstrapFactory.close();
+        scheduledExecutor.shutdown();
+    }
+
+    @ParameterizedTest(name = "TCP with no authorization, HTTP/2: {0}")
+    @ValueSource(booleans = {false, true})
+    void noAuthTcp(final boolean http2) throws Exception {
+        final var localPort = freePort();
+        doReturn(serverTransportTcp(localAddress, localPort)).when(serverConfig).getTransport();
+        doReturn(clientTransportTcp(localAddress, localPort)).when(clientConfig).getTransport();
+        integrationTest(http2);
+    }
+
+    @ParameterizedTest(name = "TCP with Basic authorization, HTTP/2: {0}")
+    @ValueSource(booleans = {false, true})
+    void basicAuthTcp(final boolean http2) throws Exception {
+        final var localPort = freePort();
+        doReturn(serverTransportTcp(localAddress, localPort, USER_HASHES_MAP))
+            .when(serverConfig).getTransport();
+        doReturn(clientTransportTcp(localAddress, localPort, USERNAME, PASSWORD))
+            .when(clientConfig).getTransport();
+        integrationTest(http2);
+    }
+
+    @ParameterizedTest(name = "TLS with no authorization, HTTP/2: {0}")
+    @ValueSource(booleans = {false, true})
+    void noAuthTls(final boolean http2) throws Exception {
+        final var certData = generateX509CertData("RSA");
+        final var localPort = freePort();
+        doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey()))
+            .when(serverConfig).getTransport();
+        doReturn(clientTransportTls(localAddress, localPort, certData.certificate())).when(clientConfig).getTransport();
+        integrationTest(http2);
+    }
+
+    @ParameterizedTest(name = "TLS with Basic authorization, HTTP/2: {0}")
+    @ValueSource(booleans = {false, true})
+    void basicAuthTls(final boolean http2) throws Exception {
+        final var certData = generateX509CertData("EC");
+        final var localPort = freePort();
+        doReturn(serverTransportTls(localAddress, localPort, certData.certificate(), certData.privateKey(),
+            USER_HASHES_MAP)).when(serverConfig).getTransport();
+        doReturn(clientTransportTls(localAddress, localPort, certData.certificate(), USERNAME, PASSWORD))
+            .when(clientConfig).getTransport();
+        integrationTest(http2);
+    }
+
+    private void integrationTest(final boolean http2) throws Exception {
+        final var server = HTTPServer.listen(serverTransportListener, bootstrapFactory.newServerBootstrap(),
+            serverConfig, requestDispatcher).get(2, TimeUnit.SECONDS);
+        try {
+            final var client = HTTPClient.connect(clientTransportListener, bootstrapFactory.newBootstrap(),
+                    clientConfig, http2).get(2, TimeUnit.SECONDS);
+            try {
+                verify(serverTransportListener, timeout(2000)).onTransportChannelEstablished(any());
+                verify(clientTransportListener, timeout(2000)).onTransportChannelEstablished(any());
+
+                for (var method : METHODS) {
+                    final var uri = nextValue("URI");
+                    final var payload = nextValue("PAYLOAD");
+                    final var request = new DefaultFullHttpRequest(HTTP_1_1, HttpMethod.valueOf(method),
+                        uri, wrappedBuffer(payload.getBytes(StandardCharsets.UTF_8)));
+                    request.headers().set(CONTENT_TYPE, TEXT_PLAIN)
+                        .setInt(CONTENT_LENGTH, request.content().readableBytes())
+                        // allow multiple requests on same connections
+                        .set(CONNECTION, KEEP_ALIVE);
+
+                    final var response = client.invoke(request).get(2, TimeUnit.SECONDS);
+                    assertNotNull(response);
+                    assertEquals(OK, response.status());
+                    final var expected = RESPONSE_TEMPLATE.formatted(method, uri, payload);
+                    assertEquals(expected, response.content().toString(StandardCharsets.UTF_8));
+                }
+            } finally {
+                client.shutdown().get(2, TimeUnit.SECONDS);
+            }
+        } finally {
+            server.shutdown().get(2, TimeUnit.SECONDS);
+        }
+    }
+
+    private static int freePort() throws IOException {
+        // find free port
+        final var socket = new ServerSocket(0);
+        final var localPort = socket.getLocalPort();
+        socket.close();
+        return localPort;
+    }
+
+    private static String nextValue(final String prefix) {
+        return prefix + COUNTER.incrementAndGet();
+    }
+
+    private static X509CertData generateX509CertData(final String algorithm) throws Exception {
+        final var keyPairGenerator = KeyPairGenerator.getInstance(algorithm);
+        if (isRSA(algorithm)) {
+            keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4), SECURE_RANDOM);
+        } else {
+            keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"), SECURE_RANDOM);
+        }
+        final var keyPair = keyPairGenerator.generateKeyPair();
+        final var certificate = generateCertificate(keyPair, isRSA(algorithm) ? "SHA256withRSA" : "SHA256withECDSA");
+        return new X509CertData(certificate, keyPair.getPrivate());
+    }
+
+    private static X509Certificate generateCertificate(final KeyPair keyPair, final String hashAlgorithm)
+            throws Exception {
+        final var now = Instant.now();
+        final var contentSigner = new JcaContentSignerBuilder(hashAlgorithm).build(keyPair.getPrivate());
+
+        final var x500Name = new X500Name("CN=TestCertificate");
+        final var certificateBuilder = new JcaX509v3CertificateBuilder(x500Name,
+            BigInteger.valueOf(now.toEpochMilli()),
+            Date.from(now), Date.from(now.plus(Duration.ofDays(365))),
+            x500Name,
+            keyPair.getPublic());
+        return new JcaX509CertificateConverter()
+            .setProvider(new BouncyCastleProvider()).getCertificate(certificateBuilder.build(contentSigner));
+    }
+
+    private static boolean isRSA(final String algorithm) {
+        return "RSA".equals(algorithm);
+    }
+
+    private record X509CertData(X509Certificate certificate, PrivateKey privateKey) {
+    }
+}