Switch netconf-server to transport-api 88/106788/14
authorRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Mon, 3 Jul 2023 14:10:19 +0000 (17:10 +0300)
committerRobert Varga <robert.varga@pantheon.tech>
Mon, 11 Sep 2023 17:51:06 +0000 (19:51 +0200)
New transport implementation makes NetconfServerDispatcher outdated.
NetconfServerFactory interface and implementation introduced
as replacement. Netconf subsystem factory added to serve netconf
over ssh.

JIRA: NETCONF-1106
Change-Id: Id74cf3511c1129710b231869c089b4339333d7e8
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
protocol/netconf-server/pom.xml
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfServerDispatcherImpl.java
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfServerFactoryImpl.java [new file with mode: 0644]
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfServerSessionNegotiatorFactory.java
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfSubsystemFactory.java [new file with mode: 0644]
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/api/NetconfServerDispatcher.java
protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/api/NetconfServerFactory.java [new file with mode: 0644]
protocol/netconf-server/src/test/java/org/opendaylight/netconf/server/NetconfServerFactoryImplTest.java [new file with mode: 0644]

index b577064fd42eaab262601dcbd2b55024c94d1751..543a73e943c4e7da630a6baf4db64172e29bb596 100644 (file)
       <groupId>io.netty</groupId>
       <artifactId>netty-codec</artifactId>
     </dependency>
+    <dependency>
+      <groupId>io.netty</groupId>
+      <artifactId>netty-buffer</artifactId>
+    </dependency>
     <dependency>
       <groupId>io.netty</groupId>
       <artifactId>netty-common</artifactId>
       <groupId>org.opendaylight.yangtools</groupId>
       <artifactId>yang-common</artifactId>
     </dependency>
-    <dependency>
-      <groupId>org.opendaylight.yangtools</groupId>
-      <artifactId>yang-model-api</artifactId>
-    </dependency>
     <dependency>
       <groupId>org.opendaylight.mdsal.binding.model.ietf</groupId>
       <artifactId>rfc6991-ietf-inet-types</artifactId>
       <artifactId>netconf-netty-util</artifactId>
     </dependency>
     <dependency>
-      <groupId>com.guicedee.services</groupId>
-      <artifactId>javax.inject</artifactId>
-      <optional>true</optional>
-    </dependency>
-    <dependency>
-      <groupId>org.osgi</groupId>
-      <artifactId>org.osgi.service.component.annotations</artifactId>
+      <groupId>org.opendaylight.netconf</groupId>
+      <artifactId>shaded-sshd</artifactId>
     </dependency>
     <dependency>
       <groupId>org.opendaylight.netconf</groupId>
       <groupId>org.opendaylight.netconf</groupId>
       <artifactId>transport-ssh</artifactId>
     </dependency>
-    <!-- Test dependencies -->
     <dependency>
-      <groupId>io.netty</groupId>
-      <artifactId>netty-buffer</artifactId>
-         <scope>test</scope>
+      <groupId>com.guicedee.services</groupId>
+      <artifactId>javax.inject</artifactId>
+      <optional>true</optional>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>org.osgi.service.component.annotations</artifactId>
     </dependency>
+
+    <!-- Test dependencies -->
     <dependency>
       <groupId>org.opendaylight.yangtools</groupId>
       <artifactId>mockito-configuration</artifactId>
       <groupId>org.opendaylight.netconf</groupId>
       <artifactId>netconf-test-util</artifactId>
     </dependency>
-    <dependency>
-      <groupId>org.opendaylight.netconf</groupId>
-      <artifactId>shaded-sshd</artifactId>
-      <scope>test</scope>
-    </dependency>
     <dependency>
       <groupId>org.xmlunit</groupId>
       <artifactId>xmlunit-legacy</artifactId>
index 1caee81d39b59640725402bc2a90f56952fbc8f1..c1d16e9fd8e3c1960b2ea5738a6cc12b0a1f83a2 100644 (file)
@@ -15,6 +15,7 @@ import java.net.InetSocketAddress;
 import org.opendaylight.netconf.nettyutil.AbstractNetconfDispatcher;
 import org.opendaylight.netconf.server.api.NetconfServerDispatcher;
 
+@Deprecated(since = "7.0.0", forRemoval = true)
 public class NetconfServerDispatcherImpl extends AbstractNetconfDispatcher<NetconfServerSession,
         NetconfServerSessionListener> implements NetconfServerDispatcher {
     private final ServerChannelInitializer initializer;
diff --git a/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfServerFactoryImpl.java b/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfServerFactoryImpl.java
new file mode 100644 (file)
index 0000000..c58f056
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2023 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.server;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.EventLoopGroup;
+import io.netty.util.concurrent.DefaultPromise;
+import io.netty.util.concurrent.EventExecutor;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import java.util.List;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.netconf.server.api.NetconfServerFactory;
+import org.opendaylight.netconf.shaded.sshd.server.ServerFactoryManager;
+import org.opendaylight.netconf.shaded.sshd.server.SshServer;
+import org.opendaylight.netconf.transport.api.TransportChannel;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
+import org.opendaylight.netconf.transport.ssh.SSHServer;
+import org.opendaylight.netconf.transport.ssh.ServerFactoryManagerConfigurator;
+import org.opendaylight.netconf.transport.tcp.NettyTransportSupport;
+import org.opendaylight.netconf.transport.tcp.TCPServer;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.SshServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev230417.TcpServerGrouping;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class NetconfServerFactoryImpl implements NetconfServerFactory {
+    private static final Logger LOG = LoggerFactory.getLogger(NetconfServerFactoryImpl.class);
+    private static final TransportChannelListener EMPTY_LISTENER =  new ChannelInitializerListener(null, null);
+
+    private final EventLoopGroup parentGroup;
+    private final EventLoopGroup workerGroup;
+    private final ServerChannelInitializer channelInitializer;
+    private final TransportChannelListener transportChannelListener;
+
+    public NetconfServerFactoryImpl(final ServerChannelInitializer channelInitializer, final EventLoopGroup bossGroup,
+            final EventLoopGroup workerGroup) {
+        this(channelInitializer, bossGroup, workerGroup, GlobalEventExecutor.INSTANCE);
+    }
+
+    public NetconfServerFactoryImpl(final ServerChannelInitializer channelInitializer,
+            final EventLoopGroup parentGroup, final EventLoopGroup workerGroup, final EventExecutor executor) {
+        this.parentGroup = requireNonNull(parentGroup);
+        this.workerGroup = requireNonNull(workerGroup);
+        this.channelInitializer = channelInitializer;
+        transportChannelListener = new ChannelInitializerListener(channelInitializer, executor);
+    }
+
+    @NonNull protected ServerBootstrap createBootstrap() {
+        return NettyTransportSupport.newServerBootstrap().group(parentGroup, workerGroup);
+    }
+
+    @Override
+    public ListenableFuture<TCPServer> createTcpServer(final TcpServerGrouping params)
+            throws UnsupportedConfigurationException {
+        return TCPServer.listen(transportChannelListener, createBootstrap(), params);
+    }
+
+    @Override
+    public ListenableFuture<SSHServer> createSshServer(final TcpServerGrouping tcpParams,
+            final SshServerGrouping sshParams) throws UnsupportedConfigurationException {
+        return SSHServer.listen(EMPTY_LISTENER, createBootstrap(), tcpParams, sshParams);
+    }
+
+    @Override
+    public ListenableFuture<SSHServer> createSshServer(final TcpServerGrouping tcpParams,
+        final SshServerGrouping sshParams, final ServerFactoryManagerConfigurator configurator)
+            throws UnsupportedConfigurationException {
+        return SSHServer.listen(EMPTY_LISTENER, createBootstrap(), tcpParams, sshParams,
+            new FactoryManagerConfigurator(channelInitializer, configurator));
+    }
+
+    private record ChannelInitializerListener(
+            @Nullable ServerChannelInitializer channelInitializer,
+            @Nullable EventExecutor executor) implements TransportChannelListener {
+        @Override
+        public void onTransportChannelEstablished(final TransportChannel channel) {
+            LOG.debug("Transport channel {} established", channel);
+            if (channelInitializer != null && executor != null) {
+                channelInitializer.initialize(channel.channel(), new DefaultPromise<>(executor));
+            }
+        }
+
+        @Override
+        public void onTransportChannelFailed(final Throwable cause) {
+            LOG.error("Transport channel failed", cause);
+        }
+    }
+
+    private record FactoryManagerConfigurator(
+            @NonNull ServerChannelInitializer channelInitializer,
+            @Nullable ServerFactoryManagerConfigurator configurator) implements ServerFactoryManagerConfigurator {
+        FactoryManagerConfigurator {
+            requireNonNull(channelInitializer);
+        }
+
+        @Override
+        public void configureServerFactoryManager(final ServerFactoryManager factoryManager)
+                throws UnsupportedConfigurationException {
+            if (configurator != null) {
+                configurator.configureServerFactoryManager(factoryManager);
+            }
+            if (factoryManager instanceof SshServer server) {
+                server.setSubsystemFactories(List.of(new NetconfSubsystemFactory(channelInitializer)));
+            }
+        }
+    }
+}
index 6a5fde3d18f499e40eb444b47df0a44235c9656b..2e998ada9f464dbb2ab7ac99151a0a5f8ab88966 100644 (file)
@@ -108,10 +108,10 @@ public class NetconfServerSessionNegotiatorFactory
             final NetconfSessionListenerFactory<NetconfServerSessionListener> defunctSessionListenerFactory,
             final Channel channel, final Promise<NetconfServerSession> promise) {
         final var sessionId = idProvider.getNextSessionId();
+        final var socketAddress = channel.parent() == null ? null : channel.parent().localAddress();
 
         return new NetconfServerSessionNegotiator(createHelloMessage(sessionId, monitoringService), sessionId, promise,
-            channel, timer, getListener(sessionId, channel.parent().localAddress()),
-            connectionTimeoutMillis, maximumIncomingChunkSize);
+            channel, timer, getListener(sessionId, socketAddress), connectionTimeoutMillis, maximumIncomingChunkSize);
     }
 
     private NetconfServerSessionListener getListener(final SessionIdType sessionId, final SocketAddress socketAddress) {
diff --git a/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfSubsystemFactory.java b/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/NetconfSubsystemFactory.java
new file mode 100644 (file)
index 0000000..1e6ac1f
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2023 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.server;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.util.concurrent.DefaultPromise;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import org.opendaylight.netconf.api.messages.NetconfHelloMessageAdditionalHeader;
+import org.opendaylight.netconf.shaded.sshd.common.io.IoInputStream;
+import org.opendaylight.netconf.shaded.sshd.common.io.IoOutputStream;
+import org.opendaylight.netconf.shaded.sshd.common.util.buffer.ByteArrayBuffer;
+import org.opendaylight.netconf.shaded.sshd.server.channel.ChannelDataReceiver;
+import org.opendaylight.netconf.shaded.sshd.server.channel.ChannelSession;
+import org.opendaylight.netconf.shaded.sshd.server.channel.ChannelSessionAware;
+import org.opendaylight.netconf.shaded.sshd.server.command.AbstractCommandSupport;
+import org.opendaylight.netconf.shaded.sshd.server.command.AsyncCommand;
+import org.opendaylight.netconf.shaded.sshd.server.command.Command;
+import org.opendaylight.netconf.shaded.sshd.server.subsystem.SubsystemFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class NetconfSubsystemFactory implements SubsystemFactory {
+    private static final String NETCONF = "netconf";
+
+    private final ServerChannelInitializer channelInitializer;
+
+    public NetconfSubsystemFactory(final ServerChannelInitializer channelInitializer) {
+        this.channelInitializer = requireNonNull(channelInitializer);
+    }
+
+    @Override
+    public String getName() {
+        return NETCONF;
+    }
+
+    @Override
+    public Command createSubsystem(ChannelSession channel) throws IOException {
+        return new NetconfSubsystem(channelInitializer);
+    }
+
+    private static class NetconfSubsystem extends AbstractCommandSupport implements AsyncCommand, ChannelSessionAware {
+        private static final Logger LOG = LoggerFactory.getLogger(NetconfSubsystem.class);
+
+        private final ServerChannelInitializer channelInitializer;
+        private Channel innerChannel;
+        private IoOutputStream ioOutputStream;
+        private ChannelSession channelSession;
+
+        NetconfSubsystem(final ServerChannelInitializer channelInitializer) {
+            super(NETCONF, null);
+            this.channelInitializer = channelInitializer;
+        }
+
+        @Override
+        public void setIoInputStream(final IoInputStream in) {
+           // not used
+        }
+
+        @Override
+        public void setIoOutputStream(final IoOutputStream out) {
+            this.ioOutputStream = out;
+        }
+
+        @Override
+        public void setIoErrorStream(final IoOutputStream err) {
+            // not used
+        }
+
+        @Override
+        public void setChannelSession(final ChannelSession channelSession) {
+            this.channelSession = channelSession;
+        }
+
+        @Override
+        public void run() {
+
+            /*
+             * While NETCONF protocol handlers are designed to operate over Netty channel,
+             * the inner channel is used to serve NETCONF over SSH.
+             */
+
+            final var embeddedChannel = new EmbeddedChannel() {
+                @Override
+                protected void handleOutboundMessage(final Object msg) {
+                    if (msg instanceof ByteBuf byteBuf) {
+                        // redirect channel outgoing packets to output stream linked to transport
+                        final byte[] bytes = new byte[byteBuf.readableBytes()];
+                        byteBuf.readBytes(bytes);
+                        try {
+                            ioOutputStream.writeBuffer(new ByteArrayBuffer(bytes))
+                                .addListener(future -> {
+                                    if (future.isWritten()) {
+                                        byteBuf.release(); // report outbound message being handled
+                                    } else if (future.getException() != null) {
+                                        LOG.debug("Error writing buffer", future.getException());
+                                    }
+                                });
+                        } catch (IOException e) {
+                            LOG.error("Error writing buffer", e);
+                        }
+                    } else {
+                        // non-ByteBuf messages are persisted within channel for subsequent handling
+                        super.handleOutboundMessage(msg);
+                    }
+                }
+            };
+
+            this.innerChannel = embeddedChannel;
+
+            // inbound packets handler
+            channelSession.setDataReceiver(new ChannelDataReceiver() {
+                @Override
+                public int data(ChannelSession channel, byte[] buf, int start, int len) throws IOException {
+                    embeddedChannel.writeInbound(Unpooled.copiedBuffer(buf, start, len));
+                    return len;
+                }
+
+                @Override
+                public void close() throws IOException {
+                    embeddedChannel.close();
+                }
+            });
+
+            // inner channel termination handler
+            embeddedChannel.pipeline().addFirst(
+                new ChannelInboundHandlerAdapter() {
+                    @Override
+                    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+                        onExit(0);
+                    }
+                }
+            );
+
+            // NETCONF handlers
+            channelInitializer.initialize(embeddedChannel, new DefaultPromise<>(GlobalEventExecutor.INSTANCE));
+            // trigger negotiation flow
+            embeddedChannel.pipeline().fireChannelActive();
+            // set additional info for netconf session
+            embeddedChannel.writeInbound(Unpooled.wrappedBuffer(getHelloAdditionalMessageBytes()));
+        }
+
+        @Override
+        protected void onExit(int exitValue, String exitMessage) {
+            super.onExit(exitValue, exitMessage);
+            if (innerChannel != null) {
+                innerChannel.close();
+            }
+        }
+
+        private byte[] getHelloAdditionalMessageBytes() {
+            final var session = getServerSession();
+            final var address = (InetSocketAddress) session.getClientAddress();
+            final var header = new NetconfHelloMessageAdditionalHeader(
+                session.getUsername(),
+                address.getAddress().getHostAddress(),
+                String.valueOf(address.getPort()),
+                "ssh", "client").toFormattedString();
+            return header.getBytes(StandardCharsets.UTF_8);
+        }
+    }
+}
index 9153fe946152fe83a6f9aa8707134903b157fd8c..bb1c0ebd4e38f2d04a9763d73eb8e82f642f1d9c 100644 (file)
@@ -11,6 +11,11 @@ import io.netty.channel.ChannelFuture;
 import io.netty.channel.local.LocalAddress;
 import java.net.InetSocketAddress;
 
+/**
+ * Basic interface for Netconf server dispatcher.
+ * @deprecated Due to design change. Use {@link NetconfServerFactory} to instantiate Netconf server.
+ */
+@Deprecated(since = "7.0.0", forRemoval = true)
 public interface NetconfServerDispatcher {
 
     ChannelFuture createServer(InetSocketAddress address);
diff --git a/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/api/NetconfServerFactory.java b/protocol/netconf-server/src/main/java/org/opendaylight/netconf/server/api/NetconfServerFactory.java
new file mode 100644 (file)
index 0000000..1e7de53
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2023 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.server.api;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
+import org.opendaylight.netconf.transport.ssh.SSHServer;
+import org.opendaylight.netconf.transport.ssh.ServerFactoryManagerConfigurator;
+import org.opendaylight.netconf.transport.tcp.TCPServer;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.SshServerGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev230417.TcpServerGrouping;
+
+/**
+ * Basic interface for Netconf server factory.
+ */
+public interface NetconfServerFactory {
+    /**
+     * Build Netconf server operating over TCP transport.
+     *
+     * @param params - TCP transport configuration
+     * @return server instance as future
+     * @throws UnsupportedConfigurationException if server cannot be started using given configuration
+     * @throws NullPointerException if params is null
+     */
+    ListenableFuture<TCPServer> createTcpServer(TcpServerGrouping params) throws UnsupportedConfigurationException;
+
+    /**
+     * Build SSH Netconf server.
+     *
+     * @param tcpParams TCP transport configuration
+     * @param sshParams SSH overlay configuration
+     * @return server instance as future
+     * @throws UnsupportedConfigurationException if server cannot be started using given configuration
+     * @throws NullPointerException if either tcpParams or sshParams is null
+     */
+    ListenableFuture<SSHServer> createSshServer(TcpServerGrouping tcpParams, SshServerGrouping sshParams)
+        throws UnsupportedConfigurationException;
+
+    /**
+     * Build SSH Netconf server with integration support.
+     *
+     * @param tcpParams TCP transport configuration
+     * @param sshParams SSH overlay configuration
+     * @param configurator explicit server factory configurator (if defined sshParams became optional)
+     * @return server instance as future
+     * @throws UnsupportedConfigurationException if server cannot be started using given configuration
+     * @throws NullPointerException if tcpParams is null
+     * @throws IllegalArgumentException if both sshParams and configurator are null, at least one is expected
+     */
+    ListenableFuture<SSHServer> createSshServer(TcpServerGrouping tcpParams, SshServerGrouping sshParams,
+        ServerFactoryManagerConfigurator configurator) throws UnsupportedConfigurationException;
+}
diff --git a/protocol/netconf-server/src/test/java/org/opendaylight/netconf/server/NetconfServerFactoryImplTest.java b/protocol/netconf-server/src/test/java/org/opendaylight/netconf/server/NetconfServerFactoryImplTest.java
new file mode 100644 (file)
index 0000000..041951e
--- /dev/null
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2023 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.server;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import io.netty.channel.Channel;
+import io.netty.channel.EventLoopGroup;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.security.KeyPairGenerator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.opendaylight.netconf.server.api.NetconfServerFactory;
+import org.opendaylight.netconf.shaded.sshd.server.auth.password.UserAuthPasswordFactory;
+import org.opendaylight.netconf.shaded.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.opendaylight.netconf.transport.api.TransportChannel;
+import org.opendaylight.netconf.transport.api.TransportChannelListener;
+import org.opendaylight.netconf.transport.ssh.SSHClient;
+import org.opendaylight.netconf.transport.ssh.SSHServer;
+import org.opendaylight.netconf.transport.tcp.NettyTransportSupport;
+import org.opendaylight.netconf.transport.tcp.TCPClient;
+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.rev230417.RsaPrivateKeyFormat;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev230417.SubjectPublicKeyInfoFormat;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev230417.asymmetric.key.pair.grouping._private.key.type.CleartextPrivateKeyBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.crypto.types.rev230417.password.grouping.password.type.CleartextPasswordBuilder;
+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.keystore.rev230417.inline.or.keystore.asymmetric.key.grouping.inline.or.keystore.InlineBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.keystore.rev230417.inline.or.keystore.asymmetric.key.grouping.inline.or.keystore.inline.InlineDefinitionBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev230417.netconf.client.initiate.stack.grouping.transport.ssh.ssh.SshClientParameters;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev230417.netconf.client.initiate.stack.grouping.transport.ssh.ssh.SshClientParametersBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev230417.netconf.client.initiate.stack.grouping.transport.ssh.ssh.TcpClientParametersBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.server.rev230417.netconf.server.listen.stack.grouping.transport.ssh.ssh.SshServerParametersBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.server.rev230417.netconf.server.listen.stack.grouping.transport.ssh.ssh.TcpServerParametersBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev230417.ssh.client.grouping.ClientIdentityBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev230417.ssh.client.grouping.client.identity.PasswordBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.ClientAuthenticationBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.ServerIdentity;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.ServerIdentityBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.client.authentication.UsersBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.client.authentication.users.UserBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.server.identity.HostKeyBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417.ssh.server.grouping.server.identity.host.key.host.key.type.PublicKeyBuilder;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.client.rev230417.TcpClientGrouping;
+import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev230417.TcpServerGrouping;
+import org.opendaylight.yangtools.yang.common.Uint16;
+
+@ExtendWith(MockitoExtension.class)
+class NetconfServerFactoryImplTest {
+
+    private static final String USERNAME = "username";
+    private static final String PASSWORD = "pa$$w0rd";
+    private static final String RSA = "RSA";
+
+    private static EventLoopGroup parentGroup;
+    private static EventLoopGroup workerGroup;
+    private static EventLoopGroup clientGroup;
+    @Mock
+    private ServerChannelInitializer serverChannelInitializer;
+    @Mock
+    private TransportChannelListener clientListener;
+
+    private NetconfServerFactory factory;
+    private TcpServerGrouping tcpServerParams;
+    private TcpClientGrouping tcpClientParams;
+
+    @BeforeAll
+    static void beforeAll() {
+        parentGroup = NettyTransportSupport.newEventLoopGroup("parent");
+        workerGroup = NettyTransportSupport.newEventLoopGroup("worker");
+        clientGroup = NettyTransportSupport.newEventLoopGroup("client");
+    }
+
+    @AfterAll
+    static void afterAll() {
+        parentGroup.shutdownGracefully();
+        workerGroup.shutdownGracefully();
+        clientGroup.shutdownGracefully();
+    }
+
+    @BeforeEach
+    void beforeEach() throws Exception {
+        factory = new NetconfServerFactoryImpl(serverChannelInitializer, parentGroup, workerGroup);
+
+        // create temp socket to get available port for test
+        final var socket = new ServerSocket(0);
+        final var address = IetfInetUtil.ipAddressFor(InetAddress.getLoopbackAddress());
+        final var port = new PortNumber(Uint16.valueOf(socket.getLocalPort()));
+        socket.close();
+
+        tcpServerParams = new TcpServerParametersBuilder().setLocalAddress(address).setLocalPort(port).build();
+        tcpClientParams =
+            new TcpClientParametersBuilder().setRemoteAddress(new Host(address)).setRemotePort(port).build();
+    }
+
+    @Test
+    void tcpServer() throws Exception {
+        final var server = factory.createTcpServer(tcpServerParams).get(1, TimeUnit.SECONDS);
+        try {
+            final var client = TCPClient.connect(clientListener,
+                NettyTransportSupport.newBootstrap().group(clientGroup), tcpClientParams).get(1, TimeUnit.SECONDS);
+            try {
+                verify(serverChannelInitializer, timeout(1000L)).initialize(any(Channel.class), any());
+                verify(clientListener, timeout(1000L)).onTransportChannelEstablished(any(TransportChannel.class));
+            } finally {
+                client.shutdown().get(1, TimeUnit.SECONDS);
+            }
+        } finally {
+            server.shutdown().get(1, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    void sshServer() throws Exception {
+        final var user = new UserBuilder().setName(USERNAME).setPassword(new CryptHash("$0$" + PASSWORD)).build();
+        final var sshServerConfig = new SshServerParametersBuilder()
+            .setServerIdentity(buildSshServerIdentityWithKeyPair())
+            .setClientAuthentication(
+                new ClientAuthenticationBuilder().setUsers(
+                    new UsersBuilder().setUser(Map.of(user.key(), user)).build()
+                ).build()
+            ).build();
+
+        assertSshServer(factory.createSshServer(tcpServerParams, sshServerConfig), sshClientParams());
+    }
+
+    @Test
+    void sshServerExtInitializer() throws Exception {
+        assertSshServer(factory.createSshServer(tcpServerParams, null, factoryManager -> {
+            factoryManager.setUserAuthFactories(List.of(new UserAuthPasswordFactory()));
+            factoryManager.setPasswordAuthenticator((username, password, session) -> true);
+            factoryManager.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
+        }), sshClientParams());
+    }
+
+    void assertSshServer(final ListenableFuture<SSHServer> serverFuture, final SshClientParameters sshClientParams)
+        throws Exception {
+        final var server = serverFuture.get(2, TimeUnit.SECONDS);
+        try {
+            final var client = SSHClient.connect(clientListener,
+                    NettyTransportSupport.newBootstrap().group(clientGroup), tcpClientParams, sshClientParams)
+                .get(2, TimeUnit.SECONDS);
+            try {
+                // FIXME commented line requires netconf client to trigger netconf subsystem initialization on server
+                // verify(serverChannelInitializer, timeout(10_000L)).initialize(any(Channel.class), any());
+                verify(clientListener, timeout(10_000L)).onTransportChannelEstablished(any(TransportChannel.class));
+            } finally {
+                client.shutdown().get(2, TimeUnit.SECONDS);
+            }
+        } finally {
+            server.shutdown().get(2, TimeUnit.SECONDS);
+        }
+    }
+
+    private static ServerIdentity buildSshServerIdentityWithKeyPair() throws Exception {
+        final var keyPair = KeyPairGenerator.getInstance(RSA).generateKeyPair();
+        final var inlineDef = new InlineDefinitionBuilder()
+            .setPublicKeyFormat(SubjectPublicKeyInfoFormat.VALUE)
+            .setPublicKey(keyPair.getPublic().getEncoded())
+            .setPrivateKeyFormat(RsaPrivateKeyFormat.VALUE)
+            .setPrivateKeyType(
+                new CleartextPrivateKeyBuilder().setCleartextPrivateKey(
+                    keyPair.getPrivate().getEncoded()
+                ).build()
+            ).build();
+        final var inline = new InlineBuilder().setInlineDefinition(inlineDef).build();
+        var publicKey = new PublicKeyBuilder().setPublicKey(
+            new org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.server.rev230417
+                .ssh.server.grouping.server.identity.host.key.host.key.type._public.key
+                .PublicKeyBuilder().setInlineOrKeystore(inline).build()
+        ).build();
+        return new ServerIdentityBuilder().setHostKey(
+            List.of(new HostKeyBuilder().setName("test-name").setHostKeyType(publicKey).build())
+        ).build();
+    }
+
+    private static SshClientParameters sshClientParams() {
+        return new SshClientParametersBuilder().setClientIdentity(
+            new ClientIdentityBuilder().setUsername(USERNAME).setPassword(
+                new PasswordBuilder().setPasswordType(
+                    new CleartextPasswordBuilder().setCleartextPassword(PASSWORD).build()
+                ).build()
+            ).build()
+        ).build();
+    }
+}