Support connecting to NETCONF clients via SSH

libnetconf doesn't have nice APIs for ssh connection, but allows users
to supply the connection by themselves. One way is to use libssh (which
libnetconf uses) and supply that. However, I found that using libssh to
implement an interactive CLI isn't very easy and I'd have to implement a
lot of functionality (like authentication) by myself, attempts were
made, but I was really only imitating the interface of OpenSSH.
Fortunately, libnetconf can also communicate over file descriptors, and
it is easy to get that from OpenSSH, so I fork it and use its
stdin/stdout. On top of that, OpenSSH is very clever and knows that I'm
using it like this, so it still allows entering passwords and accepting
host keys even though its stdin/stdout isn't a terminal.

Change-Id: I27816e038bed0a82a028c8e83c15455fd514c35e
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 39043a0..448b094 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -49,7 +49,7 @@
 option(WITH_DOCS "Create and install internal documentation (needs Doxygen)" ${DOXYGEN_FOUND})
 
 find_package(docopt REQUIRED)
-find_package(Boost REQUIRED)
+find_package(Boost REQUIRED COMPONENTS filesystem)
 find_library(REPLXX_LIBRARY NAMES replxx replxx-d REQUIRED)
 find_path(REPLXX_PATH replxx.hxx)
 if("${REPLXX_PATH}" STREQUAL REPLXX_PATH-NOTFOUND)
@@ -61,9 +61,6 @@
 pkg_check_modules(SYSREPO REQUIRED sysrepo-cpp>=1.4.79 IMPORTED_TARGET sysrepo)
 pkg_check_modules(LIBNETCONF2 REQUIRED libnetconf2>=1.1.32 IMPORTED_TARGET libnetconf2)
 
-# we don't need filename tracking, and we prefer to use header-only Boost
-add_definitions(-DBOOST_SPIRIT_X3_NO_FILESYSTEM)
-
 include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/)
 
 add_library(ast_values STATIC
@@ -165,6 +162,19 @@
     target_link_libraries(yang-cli stdc++fs)
 endif()
 
+add_executable(netconf-cli
+    src/cli.cpp
+    src/cli-netconf.cpp
+    )
+target_compile_definitions(netconf-cli PRIVATE NETCONF_CLI)
+
+# Boost.Process needs linking with threads, but doesn't have special CMake target which would do that for us. So, we
+# need to link manually. This will hopefully change in the future.
+# https://discourse.cmake.org/t/boost-process-target-doesnt-exist-for-thread-linking/2113
+set(THREADS_PREFER_PTHREAD_FLAG ON)
+find_package(Threads)
+target_link_libraries(netconf-cli netconfaccess Threads::Threads Boost::filesystem)
+cli_link_required(netconf-cli)
 
 
 include(CTest)
@@ -372,6 +382,7 @@
 endif()
 
 install(TARGETS
+    netconf-cli
     sysrepo-cli
     yang-cli
     RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/)
diff --git a/src/cli-netconf.cpp b/src/cli-netconf.cpp
new file mode 100644
index 0000000..4f9275a
--- /dev/null
+++ b/src/cli-netconf.cpp
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2020 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Václav Kubernát <kubernat@cesnet.cz>
+ *
+*/
+
+#include <boost/fusion/adapted.hpp>
+#include <boost/spirit/home/x3.hpp>
+#include <optional>
+#include <stdexcept>
+#include <unistd.h>
+#include "cli-netconf.hpp"
+
+SshProcess sshProcess(const std::string& target, const std::string& port)
+{
+    namespace bp = boost::process;
+    bp::pipe in;
+    bp::pipe out;
+    auto sshPath = bp::search_path("ssh");
+    if (sshPath.empty()) {
+        throw std::runtime_error("ssh not found in PATH.");
+    }
+    if (target.front() == '@') {
+        throw std::runtime_error("Invalid username.");
+    }
+    bp::child ssh(sshPath,
+            target,
+            "-p",
+            port,
+            "-s",
+            "netconf",
+            bp::std_out > out, bp::std_in < in);
+
+    return {std::move(ssh), std::move(in), std::move(out)};
+}
diff --git a/src/cli-netconf.hpp b/src/cli-netconf.hpp
new file mode 100644
index 0000000..4849531
--- /dev/null
+++ b/src/cli-netconf.hpp
@@ -0,0 +1,15 @@
+/*
+ * Copyright (C) 2020 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Václav Kubernát <kubernat@cesnet.cz>
+ *
+*/
+#include <boost/process.hpp>
+#include <string>
+
+struct SshProcess {
+    boost::process::child process;
+    boost::process::pipe std_in;
+    boost::process::pipe std_out;
+};
+SshProcess sshProcess(const std::string& target, const std::string& port);
diff --git a/src/cli.cpp b/src/cli.cpp
index 68d4a27..f89c8d6 100644
--- a/src/cli.cpp
+++ b/src/cli.cpp
@@ -13,9 +13,9 @@
 #include "NETCONF_CLI_VERSION.h"
 #include "interpreter.hpp"
 #include "proxy_datastore.hpp"
+#include "yang_schema.hpp"
 #if defined(SYSREPO_CLI)
 #include "sysrepo_access.hpp"
-#include "yang_schema.hpp"
 #define PROGRAM_NAME "sysrepo-cli"
 static const auto usage = R"(CLI interface to sysrepo
 
@@ -47,6 +47,22 @@
   -e <enable_features>  Feature to enable after modules are loaded. This option can be supplied more than once. Format: <module_name>:<feature>
   -i <data_file>        File to import data from
   --configonly          Disable editing of operational data)";
+#elif defined(NETCONF_CLI)
+// FIXME: improve usage
+static const auto usage = R"(CLI interface for NETCONF
+
+Usage:
+  netconf-cli [-v] [-p <port>] <host>
+  netconf-cli (-h | --help)
+  netconf-cli --version
+
+Options:
+  -v         enable verbose mode
+  -p <port>  port number [default: 830]
+)";
+#include "netconf_access.hpp"
+#include "cli-netconf.hpp"
+#define PROGRAM_NAME "netconf-access"
 #else
 #error "Unknown CLI backend"
 #endif
@@ -121,11 +137,27 @@
             datastore->addDataFile(dataFile);
         }
     }
+#elif defined(NETCONF_CLI)
+    auto verbose = args.at("-v").asBool();
+    if (verbose) {
+        NetconfAccess::setNcLogLevel(NC_VERB_DEBUG);
+    }
+
+    SshProcess process;
+    std::shared_ptr<NetconfAccess> datastore;
+
+    try {
+        process = sshProcess(args.at("<host>").asString(), args.at("-p").asString());
+        datastore = std::make_shared<NetconfAccess>(process.std_out.native_source(), process.std_in.native_sink());
+    } catch (std::runtime_error& ex) {
+        std::cerr << "SSH connection failed: " << ex.what() << "\n";
+        return 1;
+    }
 #else
 #error "Unknown CLI backend"
 #endif
 
-#if defined(SYSREPO_CLI)
+#if defined(SYSREPO_CLI) || defined(NETCONF_CLI)
     auto createTemporaryDatastore = [](const std::shared_ptr<DatastoreAccess>& datastore) {
         return std::make_shared<YangAccess>(std::static_pointer_cast<YangSchema>(datastore->schema()));
     };
diff --git a/src/netconf-client.cpp b/src/netconf-client.cpp
index 5b3fa4d..42e9edc 100644
--- a/src/netconf-client.cpp
+++ b/src/netconf-client.cpp
@@ -20,6 +20,14 @@
 
 namespace impl {
 
+static client::LogCb logCallback;
+
+static void logViaCallback(NC_VERB_LEVEL level, const char* message)
+{
+    logCallback(level, message);
+}
+
+
 /** @short Initialization of the libnetconf2 library client
 
 Just a safe wrapper over nc_client_init and nc_client_destroy, really.
@@ -28,7 +36,6 @@
     ClientInit()
     {
         nc_client_init();
-        nc_verbosity(NC_VERB_DEBUG);
     }
 
     ~ClientInit()
@@ -178,6 +185,17 @@
 
 namespace client {
 
+void setLogLevel(NC_VERB_LEVEL level)
+{
+    nc_verbosity(level);
+}
+
+void setLogCallback(const client::LogCb& callback)
+{
+    impl::logCallback = callback;
+    nc_set_print_clb(impl::logViaCallback);
+}
+
 struct nc_session* Session::session_internal()
 {
     return m_session;
diff --git a/src/netconf-client.hpp b/src/netconf-client.hpp
index acce38a..6bf4169 100644
--- a/src/netconf-client.hpp
+++ b/src/netconf-client.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <functional>
+#include <libnetconf2/log.h>
 #include <libnetconf2/messages_client.h>
 #include <memory>
 #include <optional>
@@ -25,6 +26,10 @@
 };
 
 using KbdInteractiveCb = std::function<std::string(const std::string&, const std::string&, const std::string&, bool)>;
+using LogCb = std::function<void(NC_VERB_LEVEL, const char*)>;
+
+void setLogLevel(NC_VERB_LEVEL level);
+void setLogCallback(const LogCb& callback);
 
 class Session {
 public:
diff --git a/src/netconf_access.cpp b/src/netconf_access.cpp
index 32e0f33..307ccf1 100644
--- a/src/netconf_access.cpp
+++ b/src/netconf_access.cpp
@@ -51,6 +51,16 @@
 {
 }
 
+void NetconfAccess::setNcLogLevel(NC_VERB_LEVEL level)
+{
+    libnetconf::client::setLogLevel(level);
+}
+
+void NetconfAccess::setNcLogCallback(const LogCb& callback)
+{
+    libnetconf::client::setLogCallback(callback);
+}
+
 void NetconfAccess::setLeaf(const std::string& path, leaf_data_ value)
 {
     auto lyValue = value.type() == typeid(empty_) ? std::nullopt : std::optional(leafDataToString(value));
diff --git a/src/netconf_access.hpp b/src/netconf_access.hpp
index 29dfd75..45973a6 100644
--- a/src/netconf_access.hpp
+++ b/src/netconf_access.hpp
@@ -7,6 +7,7 @@
 
 #pragma once
 
+#include <libnetconf2/log.h>
 #include <string>
 #include "datastore_access.hpp"
 
@@ -27,6 +28,8 @@
 class Schema;
 class YangSchema;
 
+using LogCb = std::function<void(NC_VERB_LEVEL, const char*)>;
+
 class NetconfAccess : public DatastoreAccess {
 public:
     NetconfAccess(const std::string& hostname, uint16_t port, const std::string& user, const std::string& pubKey, const std::string& privKey);
@@ -35,6 +38,9 @@
     NetconfAccess(std::unique_ptr<libnetconf::client::Session>&& session);
     ~NetconfAccess() override;
     [[nodiscard]] Tree getItems(const std::string& path) const override;
+
+    static void setNcLogLevel(NC_VERB_LEVEL level);
+    static void setNcLogCallback(const LogCb& callback);
     void setLeaf(const std::string& path, leaf_data_ value) override;
     void createItem(const std::string& path) override;
     void deleteItem(const std::string& path) override;