From 04c3bdac5c4b197a79f39036086bdcefd8e24803 Mon Sep 17 00:00:00 2001 From: mpet Date: Sun, 27 Oct 2024 12:15:58 +0100 Subject: [PATCH] JENKINS-73889: Cannot reuse an open socket (#204) * JENKINS-73889: Cannot reuse an open socket * JENKINS-73889: Cannot reuse an open socket * JENKINS-73889: Cannot reuse an open socket --------- Co-authored-by: mpet --- .gitignore | 3 +- pom.xml | 32 ++++ src/com/trilead/ssh2/Connection.java | 20 +- src/com/trilead/ssh2/transport/Acceptor.java | 67 +++++++ .../ssh2/transport/TransportManager.java | 48 ++++- .../trilead/ssh2/transport/CallHomeTest.java | 119 ++++++++++++ .../ssh2/transport/JULLoggerSetup.java | 42 ++++ .../ssh2/transport/JulLogConsumer.java | 35 ++++ .../MyServerHostKeyVerifierImpl.java | 14 ++ .../transport/Netopeer2TestContainer.java | 181 ++++++++++++++++++ .../ssh2/transport/SshCallHomeClient.java | 111 +++++++++++ test/docker/Dockerfile | 142 ++++++++++++++ test/docker/nacm.xml | 3 + test/docker/ssh_callhome.xml | 33 ++++ test/docker/ssh_listen.xml | 33 ++++ 15 files changed, 871 insertions(+), 12 deletions(-) create mode 100644 src/com/trilead/ssh2/transport/Acceptor.java create mode 100644 test/com/trilead/ssh2/transport/CallHomeTest.java create mode 100644 test/com/trilead/ssh2/transport/JULLoggerSetup.java create mode 100644 test/com/trilead/ssh2/transport/JulLogConsumer.java create mode 100644 test/com/trilead/ssh2/transport/MyServerHostKeyVerifierImpl.java create mode 100644 test/com/trilead/ssh2/transport/Netopeer2TestContainer.java create mode 100644 test/com/trilead/ssh2/transport/SshCallHomeClient.java create mode 100644 test/docker/Dockerfile create mode 100644 test/docker/nacm.xml create mode 100644 test/docker/ssh_callhome.xml create mode 100644 test/docker/ssh_listen.xml diff --git a/.gitignore b/.gitignore index e79ceb27..ab17c7ad 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ nbactions.xml nb-configuration.xml #vscode -.vscode/ \ No newline at end of file +.vscode/* + diff --git a/pom.xml b/pom.xml index 8d2e06e3..d9b43164 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,38 @@ testcontainers 1.20.2 test + + + org.slf4j + slf4j-api + + + + + + org.slf4j + slf4j-jdk14 + 1.7.36 + test + + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + org.xmlunit + xmlunit-assertj3 + 2.10.0 + test + + + org.xmlunit + xmlunit-matchers + 2.10.0 + test diff --git a/src/com/trilead/ssh2/Connection.java b/src/com/trilead/ssh2/Connection.java index 4c87e919..d9107dba 100644 --- a/src/com/trilead/ssh2/Connection.java +++ b/src/com/trilead/ssh2/Connection.java @@ -94,22 +94,22 @@ public static synchronized String[] getAvailableServerHostKeyAlgorithms() private boolean authenticated = false; private ChannelManager cm; - private CryptoWishList cryptoWishList = new CryptoWishList(); + protected CryptoWishList cryptoWishList = new CryptoWishList(); - private DHGexParameters dhgexpara = new DHGexParameters(); + protected DHGexParameters dhgexpara = new DHGexParameters(); - private final String hostname; + protected final String hostname; private final String sourceAddress; - private final int port; + protected final int port; - private TransportManager tm; + protected TransportManager tm; - private boolean tcpNoDelay = false; + protected boolean tcpNoDelay = false; - private ProxyData proxyData = null; + protected ProxyData proxyData = null; - private Vector connectionMonitors = new Vector(); + protected Vector connectionMonitors = new Vector(); /** * Prepares a fresh Connection object which can then be used @@ -150,7 +150,7 @@ public Connection(String hostname, int port, String sourceAddress) this.port = port; this.sourceAddress = sourceAddress; } - + /** * After a successful connect, one has to authenticate oneself. This method * is based on DSA (it uses DSA to sign a challenge sent by the server). @@ -1069,7 +1069,7 @@ public synchronized boolean isAuthMethodAvailable(String user, String method) th return false; } - private final SecureRandom getOrCreateSecureRND() + protected final SecureRandom getOrCreateSecureRND() { if (generator == null) generator = RandomFactory.create(); diff --git a/src/com/trilead/ssh2/transport/Acceptor.java b/src/com/trilead/ssh2/transport/Acceptor.java new file mode 100644 index 00000000..3f559f4e --- /dev/null +++ b/src/com/trilead/ssh2/transport/Acceptor.java @@ -0,0 +1,67 @@ +package com.trilead.ssh2.transport; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.Socket; +import java.net.ServerSocket; + +import com.trilead.ssh2.Connection; +import com.trilead.ssh2.ConnectionInfo; +import com.trilead.ssh2.ServerHostKeyVerifier; + +/** + * This class is similar to {@link Connection} but is + * used to accept incoming connections from clients. + * Example use-cases are 'NETCONF Call Home' or + * 'reverse SSH'. + * + */ +public class Acceptor extends Connection{ + + /** + * Constuctor + * @param hostname is the hostname that this class is running on. + * @param port is the port that is used for incoming connections. + */ + public Acceptor(String hostname,int port){ + super(hostname,port); + } + /** + * This method reuses most of methods for {@link Connection#connect(ServerHostKeyVerifier, int, int, int)}. Parameters and descriptions applies here too. + * The main difference between + * this class and {@link Connection} is that we use {@link ServerSocket} and we bind with the port specified in constructor. The {@link ServerSocket#accept()} + * will wait (blocks) for an incoming connection for max {@param connectTimeout} . If connection is completed a {@link Socket} is returned and we set a timeout of this socket using + * {@param readTimeout}. + * + * @throws SocketTimeoutException If there is no incoming connection within {@param connectTimeout}. + * + */ + public ConnectionInfo accept(ServerHostKeyVerifier verifier, int connectTimeout, int readTimeout, int kexTimeout) throws IOException{ + if (tm != null) { + throw new IOException("Connection to " + hostname + " is already in connected state!"); + } + if (connectTimeout < 0) + throw new IllegalArgumentException("connectTimeout must be non-negative!"); + + if (kexTimeout < 0) + throw new IllegalArgumentException("kexTimeout must be non-negative!"); + + tm = new TransportManager(hostname, port); + tm.setEnabledCallHomeSSH(true); + + tm.setConnectionMonitors(connectionMonitors); + try { + tm.initialize(cryptoWishList, verifier, dhgexpara, connectTimeout, readTimeout, getOrCreateSecureRND(), + proxyData); + } catch (SocketTimeoutException ste) { + throw (SocketTimeoutException) new SocketTimeoutException( + "The accept() operation on the socket timed out.").initCause(ste); + } + + tm.setTcpNoDelay(tcpNoDelay); + + /* Wait until first KEX has finished */ + return tm.getConnectionInfo(1); + } + +} diff --git a/src/com/trilead/ssh2/transport/TransportManager.java b/src/com/trilead/ssh2/transport/TransportManager.java index ad31f8c9..52c9e1df 100644 --- a/src/com/trilead/ssh2/transport/TransportManager.java +++ b/src/com/trilead/ssh2/transport/TransportManager.java @@ -6,6 +6,7 @@ import java.io.OutputStream; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.ServerSocket; import java.net.Socket; import java.net.UnknownHostException; import java.security.SecureRandom; @@ -133,7 +134,7 @@ public void run() final private String sourceAddress; String hostname; int port; - final Socket sock = new Socket(); + Socket sock = new Socket(); final Object connectionSemaphore = new Object(); @@ -151,6 +152,9 @@ public void run() Vector connectionMonitors = new Vector(); boolean monitorsWereInformed = false; private ClientServerHello versions; + private boolean enabledCallHomeSSH = false; + + /** * There were reports that there are JDKs which use @@ -365,6 +369,10 @@ private void establishConnection(ProxyData proxyData, int connectTimeout, int re if (proxyData == null) { + if(enabledCallHomeSSH){ + establishCallHomeConnection(connectTimeout,readTimeout); + return; + } if (sourceAddress != null) { @@ -474,6 +482,27 @@ private void establishConnection(ProxyData proxyData, int connectTimeout, int re throw new IOException("Unsupported ProxyData"); } + + private void establishCallHomeConnection(int connectTimeout,int readTimeout) { + Socket socket = null; + try (ServerSocket serverSocket = new ServerSocket()){ + // Create a ServerSocket bound to a specific hostname and port + serverSocket.bind(new InetSocketAddress(port)); + serverSocket.setSoTimeout(connectTimeout); + + log.log(50,"SSH Call Home Server listen on " + hostname + " at port " + port); + + // Accept a client connection (blocks until a connection is made) + socket = serverSocket.accept(); + log.log(100,"Call Home SSH accepted connection on host "+sock.getLocalAddress().getHostAddress()+" on port "+port); + if(socket != null){ + socket.setSoTimeout(readTimeout); + } + }catch (Exception e){ + log.log(100,"Could not create client socket "+sock.getLocalAddress().getHostAddress()+" on port "+port,e); + } + sock = socket; + } public void initialize(CryptoWishList cwl, ServerHostKeyVerifier verifier, DHGexParameters dhgex, int connectTimeout, SecureRandom rnd, ProxyData proxyData) throws IOException { @@ -820,6 +849,23 @@ public void receiveLoop() throws IOException } } + /** + * Get the value for SSH Call Home enaled. + * + * @return true if SSH Call Home enabled. + */ + public boolean isEnabledCallHomeSSH() { + return enabledCallHomeSSH; + } + /** + * Set SSH Call Home + * + * @param enabledCallHomeSSH if set to true it will enable SSH Call Home. + */ + public void setEnabledCallHomeSSH(boolean enabledCallHomeSSH) { + this.enabledCallHomeSSH = enabledCallHomeSSH; + } + /** * Advertised maximum SSH packet size that the other side can send to us. */ diff --git a/test/com/trilead/ssh2/transport/CallHomeTest.java b/test/com/trilead/ssh2/transport/CallHomeTest.java new file mode 100644 index 00000000..38f926f2 --- /dev/null +++ b/test/com/trilead/ssh2/transport/CallHomeTest.java @@ -0,0 +1,119 @@ +package com.trilead.ssh2.transport; + + +import org.assertj.core.util.xml.XmlStringPrettyFormatter; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; + + +import com.trilead.ssh2.log.Logger; + +import static org.xmlunit.assertj3.XmlAssert.assertThat; + + + +public class CallHomeTest { + private static final Logger LOGGER = Logger.getLogger(CallHomeTest.class); + private SshCallHomeClient sshCallHomeClient; + private GenericContainer netopeer2; + + @Before + public void setup() { + //setup logging from java.util.logger used in this project. + JULLoggerSetup.setupJULLogger(); + } + + /** + * This test creates a NETCONF server ( from Dockerfile) and + * configures it and triggers a SSH Call Home ( server acts as client and vice versa). + * Then we start an SSH Client ( using this library). We wait for incoming + * connection from server in accept(). Then when client and server are connected client send + * NETCONF {@code } message and read the same from NETCONF server. + * For NETCONF server we use Netopeer2. + * + * @see https://github.com/CESNET/netopeer2/a> + * + * @throws Exception if we fail + */ + @Test() + public void triggerCallHome () throws Exception { + + // https://www.testcontainers.org/features/networking/ + Testcontainers.exposeHostPorts(4334); + + //Start server and trigger SSH Call Home. + netopeer2 = new Netopeer2TestContainer().getNetopeer2Container(); + + //Start client and wait for incoming calls from server + sshCallHomeClient = new SshCallHomeClient(); + sshCallHomeClient.accept(); + //Send hello message from client. + sshCallHomeClient.send(clientHelloMsg()); + //Wait to get a hello message from server. + String message = sshCallHomeClient.read(); + LOGGER.log(50,"Message from node "+message); + assertThat(XmlStringPrettyFormatter.xmlPrettyFormat(message)).and(XmlStringPrettyFormatter.xmlPrettyFormat(serverHelloMsg())).areSimilar(); + + + } + + @After + public void cleanUp() { + sshCallHomeClient.disconnect(); + netopeer2.stop(); + netopeer2.close(); + } + + + private String clientHelloMsg(){ + return """ + + + urn:ietf:params:netconf:base:1.0 + + +]]>]]> + """; + } + + private String serverHelloMsg(){ + return """ + + + urn:ietf:params:netconf:base:1.0 + urn:ietf:params:netconf:base:1.1 + urn:ietf:params:netconf:capability:writable-running:1.0 + urn:ietf:params:netconf:capability:candidate:1.0 + urn:ietf:params:netconf:capability:confirmed-commit:1.1 + urn:ietf:params:netconf:capability:rollback-on-error:1.0 + urn:ietf:params:netconf:capability:validate:1.1 + urn:ietf:params:netconf:capability:startup:1.0 + urn:ietf:params:netconf:capability:xpath:1.0 + urn:ietf:params:netconf:capability:with-defaults:1.0?basic-mode=explicit&also-supported=report-all,report-all-tagged,trim,explicit + urn:ietf:params:netconf:capability:notification:1.0 + urn:ietf:params:netconf:capability:interleave:1.0 + urn:ietf:params:netconf:capability:url:1.0?scheme=ftp,ftps,http,https,scp,sftp + urn:ietf:params:xml:ns:yang:ietf-yang-metadata?module=ietf-yang-metadata&revision=2016-08-05 + urn:ietf:params:xml:ns:yang:ietf-inet-types?module=ietf-inet-types&revision=2013-07-15 + urn:ietf:params:xml:ns:yang:ietf-yang-types?module=ietf-yang-types&revision=2013-07-15 + urn:ietf:params:xml:ns:yang:ietf-netconf-acm?module=ietf-netconf-acm&revision=2018-02-14 + urn:ietf:params:netconf:capability:yang-library:1.1?revision=2019-01-04&content-id=2008448144 + urn:sysrepo:plugind?module=sysrepo-plugind&revision=2022-08-26 + urn:ietf:params:xml:ns:netconf:base:1.0?module=ietf-netconf&revision=2013-09-29&features=writable-running,candidate,confirmed-commit,rollback-on-error,validate,startup,url,xpath + urn:ietf:params:xml:ns:yang:ietf-netconf-with-defaults?module=ietf-netconf-with-defaults&revision=2011-06-01 + urn:ietf:params:xml:ns:yang:ietf-netconf-notifications?module=ietf-netconf-notifications&revision=2012-02-06 + urn:ietf:params:xml:ns:netconf:notification:1.0?module=notifications&revision=2008-07-14 + urn:ietf:params:xml:ns:netmod:notification?module=nc-notifications&revision=2008-07-14 + urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring?module=ietf-netconf-monitoring&revision=2010-10-04 + urn:ietf:params:xml:ns:yang:ietf-x509-cert-to-name?module=ietf-x509-cert-to-name&revision=2014-12-10 + urn:ietf:params:xml:ns:yang:iana-crypt-hash?module=iana-crypt-hash&revision=2014-04-04&features=crypt-hash-md5,crypt-hash-sha-256,crypt-hash-sha-512 + + 1 + + """; + } + +} diff --git a/test/com/trilead/ssh2/transport/JULLoggerSetup.java b/test/com/trilead/ssh2/transport/JULLoggerSetup.java new file mode 100644 index 00000000..2ec0cac1 --- /dev/null +++ b/test/com/trilead/ssh2/transport/JULLoggerSetup.java @@ -0,0 +1,42 @@ +package com.trilead.ssh2.transport; + +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + * We have a dependency to bridge logging from + * testcontainers SLF4J logs to java.util.logging (JUL). + * {@code + * + * org.slf4j + * slf4j-jdk14 + * 2.0.0 + * +* } +* This class only setup JUL and then configures logging +* to console. + * + * + */ +class JULLoggerSetup { + public static void setupJULLogger() { + Logger rootLogger = Logger.getLogger(""); + rootLogger.setLevel(Level.ALL); // Set global logging level + + // Remove default handlers + for (var handler : rootLogger.getHandlers()) { + rootLogger.removeHandler(handler); + } + + // Add a ConsoleHandler for JUL to print logs to the console + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setLevel(Level.ALL); // Log everything + consoleHandler.setFormatter(new SimpleFormatter()); // Use simple log format + + // Add the new handler to the root logger + rootLogger.addHandler(consoleHandler); + } + +} diff --git a/test/com/trilead/ssh2/transport/JulLogConsumer.java b/test/com/trilead/ssh2/transport/JulLogConsumer.java new file mode 100644 index 00000000..2c977bfd --- /dev/null +++ b/test/com/trilead/ssh2/transport/JulLogConsumer.java @@ -0,0 +1,35 @@ +package com.trilead.ssh2.transport; + +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.OutputFrame.OutputType; +import com.trilead.ssh2.log.Logger; +import java.util.function.Consumer; + +/** + * Consumer used to get logging for testcontainers. + */ + class JulLogConsumer implements Consumer { + private final Logger logger; + + // Constructor to initialize the JUL Logger + public JulLogConsumer(Logger logger) { + this.logger = logger; + } + + @Override + public void accept(OutputFrame outputFrame) { + if (outputFrame != null) { + String message = outputFrame.getUtf8String().trim(); // Get log message + OutputType type = outputFrame.getType(); // Get output type (STDOUT, STDERR) + + // Map OutputFrame types to appropriate log levels + if (type == OutputType.STDOUT) { + logger.log(800,message); // Standard output as INFO logs + } else if (type == OutputType.STDERR) { + logger.log(900,message); // Standard error as WARNING logs + } else if (type == OutputType.END) { + logger.log(1000,"Container log stream closed."); + } + } + } +} diff --git a/test/com/trilead/ssh2/transport/MyServerHostKeyVerifierImpl.java b/test/com/trilead/ssh2/transport/MyServerHostKeyVerifierImpl.java new file mode 100644 index 00000000..49c65322 --- /dev/null +++ b/test/com/trilead/ssh2/transport/MyServerHostKeyVerifierImpl.java @@ -0,0 +1,14 @@ +package com.trilead.ssh2.transport; + +import com.trilead.ssh2.ServerHostKeyVerifier; + + class MyServerHostKeyVerifierImpl implements ServerHostKeyVerifier{ + + @Override + public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) { + // Always accept any host key (Fake verifier) + System.out.println("Fake HostKeyVerifier: Host " + hostname + " accepted without verification."); + return true; + } + +} diff --git a/test/com/trilead/ssh2/transport/Netopeer2TestContainer.java b/test/com/trilead/ssh2/transport/Netopeer2TestContainer.java new file mode 100644 index 00000000..f1a74179 --- /dev/null +++ b/test/com/trilead/ssh2/transport/Netopeer2TestContainer.java @@ -0,0 +1,181 @@ +package com.trilead.ssh2.transport; + +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + + +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import com.trilead.ssh2.log.Logger; + + +/** + * This class uses testcontainers to create a generic testcontainer for + * Netopeer2 NETCONF server. + */ + class Netopeer2TestContainer { + + private static final Logger LOGGER = Logger.getLogger(Netopeer2TestContainer.class); + private static final String SSH_AUTH_CONFIG_FILE = "docker/ssh_listen.xml"; + private static final String SSH_CALL_HOME_CONFIG_FILE = "docker/ssh_callhome.xml"; + private static final String NACM_FILE = "docker/nacm.xml"; + private static final String DOCKER_IMAGE_NAME = "ghcr.io/jenkinsci/trilead-ssh2:netopeer-3.5.1"; + private static final int SSH_CALL_HOME_PORT = 4334; + public static final String TESTCONTAINERS_HOST_NAME = "host.testcontainers.internal"; + private final Integer [] exposedPorts = {830,4334}; + private GenericContainer netopeer2; + + + + + public Netopeer2TestContainer() throws Exception{ + initialize(); + } + + + /** + * Create dockerContainer + * from Image with Netopeer2 and start 'netopeer2-server'. + * + * @throws Exception if we fail to create and start server. + */ + private void initialize() throws Exception { + + netopeer2 = createContainer().withNetworkAliases(DOCKER_IMAGE_NAME); + netopeer2.start(); + + disableNacm(netopeer2); + enableSshAuthMethod(); + enableSshCallHome(); + triggerSSHCallHome(); + } + + /** + * Get the container with all the methods that can be performed + * on the container using the testcontainer API {@code GenericContainer} + * + * @return netopeer2 running as test container + */ + public GenericContainer getNetopeer2Container() { + return netopeer2; + } + + @SuppressWarnings("resource") + private GenericContainer createContainer() { + assertTrue("Docker is not installed or docker client cannot be created!",DockerClientFactory.instance().isDockerAvailable()); + + final JulLogConsumer julLogConsumer = new JulLogConsumer(LOGGER); + return new GenericContainer<>(DockerImageName.parse(DOCKER_IMAGE_NAME)).withNetwork(null).withLogConsumer(julLogConsumer) + .withExposedPorts(exposedPorts).withAccessToHost(true) + .waitingFor(new LogMessageWaitStrategy().withRegEx(".*Listening on 0.0.0.0:830 for SSH connections.*")); + } + + private void disableNacm(GenericContainer netopeer2) + throws InterruptedException, UnsupportedOperationException, IOException { + MountableFile mountableFile = MountableFile + .forClasspathResource(NACM_FILE); + netopeer2.copyFileToContainer(mountableFile, "/opt/dev/nacm.xml"); + Container.ExecResult nacmFileCreatedRes = netopeer2.execInContainer("/bin/sh", "-c", "test -f /opt/dev/nacm.xml"); + LOGGER.log(50,"Message '"+nacmFileCreatedRes.getStdout()+"' and result '"+nacmFileCreatedRes.getExitCode()+"' of NACM file imported into docker."); + Container.ExecResult nacmConfiguredRes = netopeer2.execInContainer("/bin/sh", "-c", + "/usr/bin/sysrepocfg --import=/opt/dev/nacm.xml --datastore running --module ietf-netconf-acm"); + LOGGER.log(50,"Message '"+nacmConfiguredRes.getStdout()+"' and result '"+nacmConfiguredRes.getExitCode()+"' of NACM file executed in docker."); + + } + + private void enableSshAuthMethod() throws InterruptedException, UnsupportedOperationException, IOException { + MountableFile mountableFile = MountableFile + .forClasspathResource(SSH_AUTH_CONFIG_FILE); + netopeer2.copyFileToContainer(mountableFile, "/opt/dev/ssh_listen.xml"); + Container.ExecResult sshAuthMethodFileCreatedRes = netopeer2.execInContainer("/bin/sh", "-c", + "test -f /opt/dev/ssh_listen.xml"); + LOGGER.log(50,"Message '"+sshAuthMethodFileCreatedRes.getStdout()+"' and result '"+sshAuthMethodFileCreatedRes.getExitCode()+"' of SSH Auth method file imported into docker."); + Container.ExecResult sshAuthMethodConfiguredRes = netopeer2.execInContainer("/bin/sh", "-c", + "/usr/bin/sysrepocfg --import=/opt/dev/ssh_listen.xml --datastore running --module ietf-netconf-server"); + LOGGER.log(50,"Message '"+sshAuthMethodConfiguredRes.getStdout()+"' and result '"+sshAuthMethodConfiguredRes.getExitCode()+"' of SSH Auth method file executed."); + + } + + private void enableSshCallHome() throws InterruptedException, UnsupportedOperationException, IOException{ + MountableFile mountableFile = MountableFile + .forClasspathResource(SSH_CALL_HOME_CONFIG_FILE); + netopeer2.copyFileToContainer(mountableFile, "/opt/dev/ssh_callhome.xml"); + Container.ExecResult sshCallHomeFileCreatedRes = netopeer2.execInContainer("/bin/sh", "-c","test -f /opt/dev/ssh_callhome.xml"); + LOGGER.log(50,"Message '"+sshCallHomeFileCreatedRes.getStdout()+"' and result '"+sshCallHomeFileCreatedRes.getExitCode()+"' of SSH Call Home file imported into docker."); + Container.ExecResult sshCallHomeConfiguredRes = netopeer2.execInContainer("/bin/sh", "-c", + "/usr/bin/sysrepocfg --edit=/opt/dev/ssh_callhome.xml --datastore running --module ietf-netconf-server"); + LOGGER.log(50,"Message '"+sshCallHomeConfiguredRes.getStdout()+"' and result '"+sshCallHomeConfiguredRes.getExitCode()+"' of SSH Call Home file executed."); + } + + private void triggerSSHCallHome() throws InterruptedException, UnsupportedOperationException, IOException{ + String content = triggerSSHCallHomeQuery(TESTCONTAINERS_HOST_NAME,SSH_CALL_HOME_PORT); + File tempFile = createTempFileWithContent(content); + netopeer2.copyFileToContainer(MountableFile.forHostPath(tempFile.getAbsolutePath()), "/opt/dev/enable_callhome.xml"); + Container.ExecResult sshEnableCallHomeFileCreatedRes = netopeer2.execInContainer("/bin/sh", "-c","test -f /opt/dev/enable_callhome.xml"); + LOGGER.log(50,"Message '"+sshEnableCallHomeFileCreatedRes.getStdout()+"' and result '"+sshEnableCallHomeFileCreatedRes.getExitCode()+"' of Enable SSH Call Home Query file imported into docker."); + Container.ExecResult sshEnableCallHomeConfiguredRes = netopeer2.execInContainer("/bin/sh", "-c", "/usr/bin/sysrepocfg --edit=/opt/dev/enable_callhome.xml --datastore running"); + LOGGER.log(50,"Message '"+sshEnableCallHomeConfiguredRes.getStdout()+"' and result '"+sshEnableCallHomeConfiguredRes.getExitCode()+"' of Enable SSH Call Home Query file sent."); + + } + + private static File createTempFileWithContent(String content) throws IOException { + File tempFile = File.createTempFile("testfile", ".txt"); + + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write(content); + } + + return tempFile; + } + + private String triggerSSHCallHomeQuery(String ip, int port) { + String message = """ + + + + default-client + + + default-ssh + + + %s + %d + + + + + default-key + + genkey + + + + + default-ssh + + + + + + + + + + + + """; + return message.formatted(ip, port); + + } + + +} diff --git a/test/com/trilead/ssh2/transport/SshCallHomeClient.java b/test/com/trilead/ssh2/transport/SshCallHomeClient.java new file mode 100644 index 00000000..f0d39398 --- /dev/null +++ b/test/com/trilead/ssh2/transport/SshCallHomeClient.java @@ -0,0 +1,111 @@ +package com.trilead.ssh2.transport; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.SocketTimeoutException; +import java.nio.charset.Charset; + +import java.util.concurrent.TimeUnit; + +import com.trilead.ssh2.ServerHostKeyVerifier; +import com.trilead.ssh2.Session; + +import com.trilead.ssh2.log.Logger; + +/** + * This is a very simple client wrapping this library. + * It supports accepting incoming connections + * from a NETCONF server. Once accept is complete we can + * send a simple NETCONF {@code } message and expect + * to receive the same from server. + * + * This client is only used to test a limited send/read sequence. + * Nor is it a complete NETCONF client. + */ +class SshCallHomeClient { + private static final Logger LOGGER = Logger.getLogger(SshCallHomeClient.class); + private static final String HOSTNAME = "host.testcontainers.internal"; + private static final int SSH_CALL_HOME_PORT = 4334; + private static final String USER = "netconf"; + private static final String PASSWORD = "netconf"; + private static final int BUFFER_SIZE = 9 * 1024; + private static final String NETCONF_PROMPT = "]]>]]>"; + private static final int DEFAULT_SEND_TIME_OUT = 5000; + private static final String SUBSYSTEM = "netconf"; + + private Acceptor acceptor; + private Session session; + + + + void accept() throws IOException { + acceptor = new Acceptor(HOSTNAME,SSH_CALL_HOME_PORT); + // Implementing a fake HostKeyVerifier that always returns true + ServerHostKeyVerifier serverHostKeyVerifier = new MyServerHostKeyVerifierImpl(); + acceptor.accept(serverHostKeyVerifier,400000,400000,40000); + auth(); + session = acceptor.openSession(); + session.startSubSystem(SUBSYSTEM); + + } + + void send(String msg) { + OutputStream stdin = session.getStdin(); + try { + stdin.write(msg.getBytes()); + LOGGER.log(50,"--> Sent message"+msg); + stdin.flush(); + } catch (IOException e) { + LOGGER.log(50,"Could not send message"); + + + } + + + } + + String read()throws IOException{ + InputStream stdout = session.getStdout(); + final char[] buffer = new char[BUFFER_SIZE]; + final StringBuilder rpcReply = new StringBuilder(); + final long startTime = System.nanoTime(); + final Reader in = new InputStreamReader(stdout, Charset.forName("UTF-8")); + boolean timeoutNotExceeded = true; + int promptPosition; + while ((promptPosition = rpcReply.indexOf(NETCONF_PROMPT)) < 0 && + (timeoutNotExceeded = (TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) < DEFAULT_SEND_TIME_OUT))) { + int charsRead = in.read(buffer, 0, buffer.length); + if (charsRead < 0) throw new IOException("Input Stream has been closed during reading."); + rpcReply.append(buffer, 0, charsRead); + } + + if (!timeoutNotExceeded){ + throw new SocketTimeoutException("Command send timeout limit was exceeded: " + DEFAULT_SEND_TIME_OUT ); + } + // fixing the rpc reply by removing device prompt + LOGGER.log(50,"<-- Received message:\n "+rpcReply); + rpcReply.setLength(promptPosition); + return rpcReply.toString(); + + } + + void disconnect(){ + acceptor.close(); + } + + private void auth() throws IOException { + boolean isAuthenticated = acceptor.authenticateWithPassword(USER, + PASSWORD); + if (!isAuthenticated) { + throw new IOException("Authentication failed."); + } + + } + + + + +} diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile new file mode 100644 index 00000000..df49ae1a --- /dev/null +++ b/test/docker/Dockerfile @@ -0,0 +1,142 @@ +FROM ubuntu:22.04 + +# This Docker image is build and push to the repository it takes about 6 min to build +# The reason to pre-build it is because it compiles five libraries and an application everytime we run the test +# docker build -t ghcr.io/jenkinsci/trilead-ssh2:netopeer-3.5.1 . + + +# defaults +RUN \ + apt-get update && apt-get install -y \ + net-tools \ + git \ + wget \ + libssl-dev \ + libtool \ + build-essential \ + vim \ + autogen \ + autoconf \ + automake \ + pkg-config \ + libgtk-3-dev \ + make \ + vim \ + valgrind \ + doxygen \ + libev-dev \ + libpcre3-dev \ + unzip \ + sudo \ + python3 \ + build-essential \ + bison \ + flex \ + swig \ + libcmocka0 \ + libcmocka-dev \ + cmake \ + gcc \ + libpsl-dev \ + supervisor \ + libpam0g-dev \ + && rm -rf /var/lib/apt/lists/* + + + +# Adding netconf user +RUN adduser --system netconf +RUN mkdir -p /home/netconf/.ssh +RUN echo "netconf:netconf" | chpasswd && adduser netconf sudo + + +# Clearing and setting authorized ssh keys +RUN \ + echo '' > /home/netconf/.ssh/authorized_keys && \ + ssh-keygen -A && \ + ssh-keygen -t rsa -b 4096 -P '' -f /home/netconf/.ssh/id_rsa && \ + cat /home/netconf/.ssh/id_rsa.pub >> /home/netconf/.ssh/authorized_keys + + +# Updating shell to bash +RUN sed -i s#/home/netconf:/bin/false#/home/netconf:/bin/bash# /etc/passwd + +RUN mkdir /opt/dev && sudo chown -R netconf /opt/dev + +# set password for user (same as the username) +RUN echo "root:root" | chpasswd + +# libyang +RUN \ + cd /opt/dev && \ + git clone https://github.com/CESNET/libyang.git && \ + cd libyang && git checkout tags/v3.4.2 && mkdir build && cd build && \ + cmake -DCMAKE_BUILD_TYPE:String="Release" -DCMAKE_INSTALL_PREFIX=/usr -DENABLE_BUILD_TESTS=OFF .. && \ + make -j2 && \ + make install && \ + ldconfig + +# sysrepo +RUN \ + cd /opt/dev && \ + git clone https://github.com/sysrepo/sysrepo.git && \ + cd sysrepo && git checkout tags/v2.11.7 && mkdir build && cd build && \ + cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE="Release" .. && \ + make -j2 && \ + make install && \ + ldconfig + +#libssh (for libnetconf2) +RUN \ + cd /opt/dev && \ + git clone http://git.libssh.org/projects/libssh.git && cd libssh && git checkout tags/libssh-0.11.1 &&\ + mkdir build && cd build && \ + cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE="Release" -DWITH_ZLIB=ON -DWITH_NACL=OFF -DWITH_PCAP=OFF .. && \ + make -j2 && \ + make install && \ + ldconfig + +#curl (for libnetconf2) + +RUN \ +cd /opt/dev && \ +git clone https://github.com/curl/curl.git && cd curl && git checkout tags/curl-8_9_1 &&\ +autoreconf -fi && \ +./configure --prefix=/usr --with-openssl && \ +make -j2 && \ +make install && \ +ldconfig + + +# libnetconf2 +RUN \ + cd /opt/dev && \ + git clone https://github.com/CESNET/libnetconf2.git && \ + cd libnetconf2 && git checkout tags/v3.5.1 && mkdir build && cd build && \ + cmake -DCMAKE_FIND_PACKAGE_NO_SYSTEM_PATH=ON -DCMAKE_INSTALL_PREFIX:PATH=/usr -DENABLE_TESTS=OFF .. &&\ + make -j2 && \ + make install && \ + ldconfig + + +# netopeer2 +RUN \ + cd /opt/dev && \ + git clone https://github.com/CESNET/Netopeer2.git && cd Netopeer2 && \ + git checkout tags/v2.2.31 && \ + mkdir build && cd build && \ + cmake -DNACM_RECOVERY_UID=102 -DCMAKE_INSTALL_PREFIX:PATH=/usr .. && \ + make -j2 && \ + make install + + RUN rm -fr /opt/dev + ENV EDITOR vim + EXPOSE 830 + EXPOSE 4334 + +# start netopeer2 server. +CMD ["/usr/sbin/netopeer2-server", "-d", "-v2 3"] + +#Comment above and uncomment below if you want to debug SSH specifc actions on Netopeer2 when running. +#CMD ["/usr/sbin/netopeer2-server", "-d", "-c", "SSH"] + \ No newline at end of file diff --git a/test/docker/nacm.xml b/test/docker/nacm.xml new file mode 100644 index 00000000..10e50bb2 --- /dev/null +++ b/test/docker/nacm.xml @@ -0,0 +1,3 @@ + + false + diff --git a/test/docker/ssh_callhome.xml b/test/docker/ssh_callhome.xml new file mode 100644 index 00000000..6598483a --- /dev/null +++ b/test/docker/ssh_callhome.xml @@ -0,0 +1,33 @@ + + + + default-client + + + ssh-default + + + localhost + + + + + default-key + + genkey + + + + + default-ssh + + + + + + + + + + + \ No newline at end of file diff --git a/test/docker/ssh_listen.xml b/test/docker/ssh_listen.xml new file mode 100644 index 00000000..47e27939 --- /dev/null +++ b/test/docker/ssh_listen.xml @@ -0,0 +1,33 @@ + + + + + default-ssh + + + 0.0.0.0 + + + + + default-key + + genkey + + + + + + + netconf + $0$netconf + + + + + + + + + + \ No newline at end of file