<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>
--- /dev/null
+/*
+ * 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));
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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"));
+ }
+ }
+ };
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
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;
@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);
}
}
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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());
+ }
+ };
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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) {
+ }
+}