tools: add upnpctrl

Change-Id: I4eba598ae849d982d077fce000d0d83f5a4f7762
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f4ce3b7..d787958 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -145,6 +145,12 @@
     target_link_libraries(dnc PRIVATE dhtnet fmt::fmt)
     target_include_directories(dnc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tools)
     install(TARGETS dnc RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+
+    add_executable(upnpctrl
+        tools/upnp/upnpctrl.cpp)
+    target_link_libraries(upnpctrl PRIVATE dhtnet fmt::fmt readline)
+    target_include_directories(upnpctrl PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tools)
+    install(TARGETS upnpctrl RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
 endif()
 
 if (BUILD_TESTING AND NOT MSVC)
diff --git a/tools/upnp/upnpctrl.cpp b/tools/upnp/upnpctrl.cpp
new file mode 100644
index 0000000..586898b
--- /dev/null
+++ b/tools/upnp/upnpctrl.cpp
@@ -0,0 +1,96 @@
+#include "upnp/upnp_control.h"
+#include "upnp/upnp_context.h"
+#include "string_utils.h"
+#include <asio/executor_work_guard.hpp>
+#include <opendht/log.h>
+
+#include <readline/readline.h>
+#include <readline/history.h>
+
+void
+print_help()
+{
+    fmt::print("Commands:\n"
+                    "  help, h, ?\n"
+                    "  quit, exit, q, x\n"
+                    "  ip\n"
+                    "  open <port> <protocol>\n");
+}
+
+std::string to_lower(std::string_view str_v) {
+    std::string str(str_v);
+    std::transform(str.begin(), str.end(), str.begin(),
+                   [](unsigned char c){ return std::tolower(c); }
+                  );
+    return str;
+}
+
+int
+main(int argc, char** argv)
+{
+    auto ioContext  = std::make_shared<asio::io_context>();
+    std::shared_ptr<dht::log::Logger> logger = dht::log::getStdLogger();
+    auto upnpContext = std::make_shared<dhtnet::upnp::UPnPContext>(ioContext, logger);
+
+    auto ioContextRunner = std::make_shared<std::thread>([context = ioContext]() {
+        try {
+            auto work = asio::make_work_guard(*context);
+            context->run();
+        } catch (const std::exception& ex) {
+            // print the error;
+        }
+    });
+
+    auto controller = std::make_shared<dhtnet::upnp::Controller>(upnpContext);
+    std::set<std::shared_ptr<dhtnet::upnp::Mapping>> mappings;
+
+    while (true) {
+        char* l = readline("> ");
+        if (not l)
+            break;
+        std::string_view line{l};
+        if (line.empty())
+            continue;
+        add_history(l);
+        auto args = dhtnet::split_string(line, ' ');
+        auto command = args[0];
+        if (command == "quit" || command == "exit" || command == "q" || command == "x")
+            break;
+        else if (command == "help" || command == "h" || command == "?") {
+            print_help();
+        }
+        else if (command == "ip") {
+            fmt::print("{}\n", controller->getExternalIP().toString());
+        } else if (command == "open") {
+            if (args.size() < 3) {
+                fmt::print("Usage: open <port> <protocol>\n");
+                continue;
+            }
+            auto protocol = to_lower(args[2]) == "udp" ? dhtnet::upnp::PortType::UDP : dhtnet::upnp::PortType::TCP;
+            mappings.emplace(controller->reserveMapping(dhtnet::to_int<in_port_t>(args[1]), protocol));
+        } else if (command == "close") {
+            if (args.size() < 2) {
+                fmt::print("Usage: close <port>\n");
+                continue;
+            }
+            auto port = dhtnet::to_int<in_port_t>(args[1]);
+            for (auto it = mappings.begin(); it != mappings.end(); ) {
+                if ((*it)->getExternalPort() == port) {
+                    controller->releaseMapping(**it);
+                    it = mappings.erase(it);
+                } else {
+                    ++it;
+                }
+            }
+        } else {
+            fmt::print("Unknown command: {}\n", command);
+        }
+    }
+    fmt::print("Stopping...");
+    for (auto c: mappings)
+        controller->releaseMapping(*c);
+    mappings.clear();
+
+    ioContext->stop();
+    ioContextRunner->join();
+}