From d804da8881f8eb566834348582fee336dc83b6cf Mon Sep 17 00:00:00 2001 From: oupson Date: Tue, 1 Mar 2022 23:49:26 +0100 Subject: [PATCH] First Commit --- .gitignore | 78 +++++++++++++ .gitmodules | 3 + .idea/.gitignore | 8 ++ .idea/FooTerm.iml | 2 + .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ CMakeLists.txt | 39 +++++++ README.md | 1 + includes/eventloop.hpp | 41 +++++++ includes/ssh.hpp | 58 ++++++++++ includes/term_windows.hpp | 26 +++++ includes/vte.hpp | 25 ++++ src/eventloop.cpp | 69 +++++++++++ src/main.cpp | 9 ++ src/ssh.cpp | 230 +++++++++++++++++++++++++++++++++++++ src/term_windows.cpp | 117 +++++++++++++++++++ src/vte.cpp | 71 ++++++++++++ third-party/CMakeLists.txt | 1 + third-party/libssh2 | 1 + 19 files changed, 791 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .idea/.gitignore create mode 100644 .idea/FooTerm.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 includes/eventloop.hpp create mode 100644 includes/ssh.hpp create mode 100644 includes/term_windows.hpp create mode 100644 includes/vte.hpp create mode 100644 src/eventloop.cpp create mode 100644 src/main.cpp create mode 100644 src/ssh.cpp create mode 100644 src/term_windows.cpp create mode 100644 src/vte.cpp create mode 100644 third-party/CMakeLists.txt create mode 160000 third-party/libssh2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efb628d --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +### C++ template +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml + +# Sensitive or high-churn files: +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +build +cmake-build-debug/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..209ee5d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third-party/libssh2"] + path = third-party/libssh2 + url = https://github.com/libssh2/libssh2.git diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/FooTerm.iml b/.idea/FooTerm.iml new file mode 100644 index 0000000..f08604b --- /dev/null +++ b/.idea/FooTerm.iml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..79b3c94 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5ea02b3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e2396bf --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.21) +project(FooTerm) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_C_STANDARD 90) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +IF (${CMAKE_BUILD_TYPE} MATCHES Debug) + add_definitions(-DDEBUG=1) +ENDIF () + +find_package(PkgConfig) + +pkg_check_modules(GTKMM gtkmm-3.0) +pkg_check_modules(VTE REQUIRED vte-2.91) + +link_directories( + ${GTKMM_LIBRARY_DIRS}) + +include_directories( + ${GTKMM_INCLUDE_DIRS}) + +include_directories( + includes +) + +add_executable(FooTerm src/main.cpp src/term_windows.cpp src/vte.cpp src/ssh.cpp src/eventloop.cpp) + +target_link_libraries(FooTerm + ${GTKMM_LIBRARIES}) + +target_link_libraries(FooTerm ${VTE_LIBRARIES}) +add_definitions(${VTE_CFLAGS} ${VTE_CFLAGS_OTHER}) + + +add_subdirectory(third-party) +find_package(fmt) + +target_link_libraries(FooTerm fmt::fmt ssh2) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..267599a --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# FooTerm diff --git a/includes/eventloop.hpp b/includes/eventloop.hpp new file mode 100644 index 0000000..c63ad08 --- /dev/null +++ b/includes/eventloop.hpp @@ -0,0 +1,41 @@ +// +// Created by oupson on 01/03/2022. +// + +#ifndef FOOTERM_EVENTLOOP_HPP +#define FOOTERM_EVENTLOOP_HPP + +#include +#include +#include +#include + + +struct EventLoopEntry { + enum { + DESCRIPTOR, + CHANNEL + } entryType; + + LIBSSH2_CHANNEL *channel; + int out; +}; + +class EventLoop { +private: + std::vector pfds; + std::vector outs; + bool isClosed{}; +public: + EventLoop(); + + void registerFd(int fdin, EventLoopEntry entry); + + void run(); + + static void start(EventLoop *self) { + self->run(); + } +}; + +#endif //FOOTERM_EVENTLOOP_HPP diff --git a/includes/ssh.hpp b/includes/ssh.hpp new file mode 100644 index 0000000..a794105 --- /dev/null +++ b/includes/ssh.hpp @@ -0,0 +1,58 @@ +// +// Created by oupson on 01/03/2022. +// + +#ifndef FOOTERM_SSH_HPP +#define FOOTERM_SSH_HPP + +#include +#include +#include +#include + +class Session { +private: + LIBSSH2_SESSION *session; + LIBSSH2_CHANNEL *channel; + int sock; + + static int openSocket(const char *addr, int port); + + static bool authWithPublicKey(LIBSSH2_AGENT *agent, const char *username); + + static bool authWithPassword(LIBSSH2_SESSION *session, const char *username, const char *password); + +public: + explicit Session(); + + ~Session(); + + void openConnection(const char *addr, int port, const char *username, const char *password); + + void disconnect(); + + bool isConnected(); + + int getSock() { + return this->sock; + }; + + LIBSSH2_CHANNEL *getChannel() { + return this->channel; + } +}; + +class SessionConnectException : public std::exception { +public: + explicit SessionConnectException(std::string msg) : m_msg(std::move(msg)) { + } + + [[nodiscard]] const char *what() const noexcept override { + return m_msg.c_str(); + } + + const std::string m_msg; +}; + + +#endif //FOOTERM_SSH_HPP diff --git a/includes/term_windows.hpp b/includes/term_windows.hpp new file mode 100644 index 0000000..eb10587 --- /dev/null +++ b/includes/term_windows.hpp @@ -0,0 +1,26 @@ +// +// Created by oupson on 28/02/2022. +// + +#ifndef FOOTERM_TERM_WINDOWS_HPP +#define FOOTERM_TERM_WINDOWS_HPP + + +#include +#include +#include +#include + +#include "eventloop.hpp" + +class FooTermWindow : public Gtk::Window { +private: + Gtk::Notebook notebook; + EventLoop eventLoop; + void on_button_click(); + +public: + FooTermWindow(); +}; + +#endif //FOOTERM_TERM_WINDOWS_HPP diff --git a/includes/vte.hpp b/includes/vte.hpp new file mode 100644 index 0000000..8b6e80c --- /dev/null +++ b/includes/vte.hpp @@ -0,0 +1,25 @@ +// +// Created by oupson on 28/02/2022. +// + +#ifndef FOOTERM_VTE_HPP +#define FOOTERM_VTE_HPP + +#include +#include + +#include "eventloop.hpp" + +class Vte { +public: + Vte(); + + void spawnShell(EventLoop &eventLoop, const char *host, int port, const char *username, const char *password); + + Gtk::Widget *asGtkWidget(); + +private: + GtkWidget *terminal; +}; + +#endif //FOOTERM_VTE_HPP diff --git a/src/eventloop.cpp b/src/eventloop.cpp new file mode 100644 index 0000000..d093720 --- /dev/null +++ b/src/eventloop.cpp @@ -0,0 +1,69 @@ +// +// Created by oupson on 01/03/2022. +// + +#include "eventloop.hpp" +#include +#include + +EventLoop::EventLoop() = default; + +void EventLoop::registerFd(int fdin, EventLoopEntry entry) { + pollfd fd = {0}; + fd.fd = fdin; + fd.events = POLLIN; + this->pfds.emplace_back(fd); + this->outs.emplace_back(entry); +} + +#define BUFFER_SIZE (256) + +void EventLoop::run() { + char buffer[BUFFER_SIZE]; + ssize_t bytesRead; + int res; + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "EndlessLoop" + while (!this->isClosed) { + res = poll(this->pfds.data(), this->pfds.size(), 1000); + + if (res > 0) { + int i = 0; + + while (i < this->pfds.size()) { + pollfd &pfd = this->pfds[i]; + + if (pfd.revents & POLLIN) { + do { + if (this->outs[i].entryType == EventLoopEntry::CHANNEL) { + bytesRead = libssh2_channel_read(this->outs[i].channel, buffer, BUFFER_SIZE); + + if (bytesRead > 0) { + write(this->outs[i].out, buffer, bytesRead); + } else if (bytesRead < 0 && bytesRead != LIBSSH2_ERROR_EAGAIN) { + std::cout << bytesRead << std::endl; + } + + if (libssh2_channel_eof(this->outs[i].channel)) { + this->outs.erase(this->outs.begin() + i); + this->pfds.erase(this->pfds.begin() + i); + continue; + } + } else { + bytesRead = read(pfd.fd, buffer, BUFFER_SIZE); + + + if (bytesRead > 0) { + libssh2_channel_write(this->outs[i].channel, buffer, bytesRead); + } + } + } while (bytesRead > 0); + } + i++; + } + + } + } +#pragma clang diagnostic pop +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..704e12f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,9 @@ +#include "term_windows.hpp" + +#include + +int main() { + auto app = Gtk::Application::create("org.gtkmm.examples.base"); + FooTermWindow window; + return app->run(window); +} diff --git a/src/ssh.cpp b/src/ssh.cpp new file mode 100644 index 0000000..6974653 --- /dev/null +++ b/src/ssh.cpp @@ -0,0 +1,230 @@ +// +// Created by oupson on 01/03/2022. +// + +#include "ssh.hpp" + +#include +#include +#include +#include +#include + +Session::Session() { + this->session = nullptr; + this->channel = nullptr; + this->sock = -1; +} + + +Session::~Session() { + if (this->isConnected()) { + this->disconnect(); + } +} + +int Session::openSocket(const char *addr, int port) { + struct addrinfo hints = {0}; + hints.ai_family = AF_UNSPEC; /* Allow IPv4 or IPv6 */ + hints.ai_flags |= AI_CANONNAME; + hints.ai_protocol = 0; + + struct addrinfo *result; + struct sockaddr *sin; + unsigned int socklen; + + char service[256]; + sprintf(service, "%d", port); + + int s = getaddrinfo(addr, service, &hints, &result); + if (s) { + throw SessionConnectException(fmt::format("failed to resolve {}: {}", addr, gai_strerror(s))); + } + + if (result) { + sin = result->ai_addr; + socklen = result->ai_addrlen; + } else { + // TODO + } + + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock == -1) { + throw SessionConnectException(fmt::format("failed to create socket: {}", strerror(errno))); + } + + + if (connect(sock, sin, socklen) != 0) { + throw SessionConnectException(fmt::format("failed to connect: {}", strerror(errno))); + } + + return sock; +} + +void Session::openConnection(const char *addr, int port, const char *username, const char *password) { + char *error; + int errorLen; + + this->sock = Session::openSocket(addr, port); + this->session = libssh2_session_init(); + + libssh2_session_set_blocking(this->session, 0); + + int rc; + while ((rc = libssh2_session_handshake(this->session, sock)) == LIBSSH2_ERROR_EAGAIN) {} + + if (rc) { + libssh2_session_last_error(session, &error, &errorLen, true); + + throw SessionConnectException(fmt::format("failure establishing SSH session: {}", error)); + } + + + char *userauthlist; + while ((userauthlist = libssh2_userauth_list(session, username, strlen(username))) == nullptr) { + if (libssh2_session_last_errno(session) != LIBSSH2_ERROR_EAGAIN) + break; + } + + + bool usePublicKey = true, usePassword = true; + + +#if DEBUG + fprintf(stderr, "Authentication methods: %s\n", userauthlist); +#endif + if (strstr(userauthlist, "publickey") == nullptr) { +#if DEBUG + fprintf(stderr, "\"publickey\" authentication is not supported\n"); +#endif + usePublicKey = false; + } + + if (strstr(userauthlist, "password") == nullptr || password == nullptr) { +#if DEBUG + fprintf(stderr, "\"password\" authentication is not supported\n"); +#endif + usePassword = false; + } + + + /* Connect to the ssh-agent */ + LIBSSH2_AGENT *agent = libssh2_agent_init(session); + + if (!agent) { + libssh2_session_last_error(session, &error, &errorLen, true); + throw SessionConnectException(fmt::format("failure initializing ssh-agent support: {}", error)); + } + + if (libssh2_agent_connect(agent)) { + libssh2_session_last_error(session, &error, &errorLen, true); + throw SessionConnectException(fmt::format("failure connecting to ssh-agent: {}", error)); + } + + bool isConnected = false; + + if (usePublicKey) + isConnected = Session::authWithPublicKey(agent, username); + + if (!isConnected && usePassword) + isConnected = Session::authWithPassword(session, username, password); + + if (!isConnected) { + libssh2_session_last_error(session, &error, &errorLen, true); + throw SessionConnectException(fmt::format("Failed to authenticate: {}", error)); + } + + do { + this->channel = libssh2_channel_open_session(session); + } while (this->channel == nullptr && libssh2_session_last_errno(this->session) == LIBSSH2_ERROR_EAGAIN); + + if (!channel) { + libssh2_session_last_error(session, &error, &errorLen, true); + throw SessionConnectException(fmt::format("unable to open a session: {}", error)); + } + + do { + rc = libssh2_channel_setenv(this->channel, "TERM", "xterm-256color"); + } while (rc == LIBSSH2_ERROR_EAGAIN); + + do { + rc = libssh2_channel_request_pty(this->channel, "xterm-256color"); + } while (rc == LIBSSH2_ERROR_EAGAIN); + + do { + rc = libssh2_channel_shell(this->channel); + } while (rc == LIBSSH2_ERROR_EAGAIN); +} + +void Session::disconnect() { + libssh2_channel_free(this->channel); + libssh2_session_free(this->session); + + this->channel = nullptr; + this->session = nullptr; + this->sock = -1; +} + +bool Session::isConnected() { + return this->channel != nullptr; +} + +bool Session::authWithPublicKey(LIBSSH2_AGENT *agent, const char *username) { + int rc; + struct libssh2_agent_publickey *identity, *prev_identity = nullptr; + + while ((rc = libssh2_agent_list_identities(agent)) == LIBSSH2_ERROR_EAGAIN); + + if (rc) { + fprintf(stderr, "Failure requesting identities to ssh-agent\n"); + return false; + } + + + while (true) { + do { + rc = libssh2_agent_get_identity(agent, &identity, prev_identity); + } while (rc == LIBSSH2_ERROR_EAGAIN); + + if (rc == 1) + break; + + if (rc < 0) { + fprintf(stderr, + "Failure obtaining identity from ssh-agent support\n"); + return false; + } + + do { + rc = libssh2_agent_userauth(agent, username, identity); + } while (rc == LIBSSH2_ERROR_EAGAIN); + + if (rc) { +#if DEBUG + fprintf(stderr, "\tAuthentication with username %s and " + "public key %s failed!\n", + username, identity->comment); +#endif + } else { +#if DEBUG + fprintf(stderr, "\tAuthentication with username %s and " + "public key %s succeeded!\n", + username, identity->comment); +#endif + break; + } + prev_identity = identity; + } + + return rc == 0; +} + +bool Session::authWithPassword(LIBSSH2_SESSION *session, const char *username, const char *password) { + int rc; + + do { + rc = libssh2_userauth_password(session, username, password); + } while (rc == LIBSSH2_ERROR_EAGAIN); + + return rc == 0; +} diff --git a/src/term_windows.cpp b/src/term_windows.cpp new file mode 100644 index 0000000..fe0a9e5 --- /dev/null +++ b/src/term_windows.cpp @@ -0,0 +1,117 @@ +// +// Created by oupson on 28/02/2022. +// + +#include "vte.hpp" +#include "term_windows.hpp" + +#include +#include +#include +#include +#include +#include +#include + +FooTermWindow::FooTermWindow() { + this->set_default_size(800, 600); + this->add(notebook); + + auto *headerBar = Gtk::make_managed(); + headerBar->set_title("FooTerm"); + headerBar->set_show_close_button(true); + + auto *addButton = Gtk::make_managed(""); + addButton->set_image_from_icon_name("window-new"); + addButton->signal_clicked().connect(sigc::mem_fun(this, &FooTermWindow::on_button_click)); + headerBar->add(*addButton); + this->set_titlebar(*headerBar); + + notebook.set_scrollable(true); + notebook.set_group_name("foo-terminal-window"); + + std::thread backgroundThread(EventLoop::start, &this->eventLoop); + backgroundThread.detach(); + + this->show_all(); +} + +void FooTermWindow::on_button_click() { + Gtk::Dialog m("login", *this); + + m.add_button("Cancel", Gtk::ResponseType::RESPONSE_CANCEL); + m.add_button("Ok", Gtk::ResponseType::RESPONSE_OK); + + Gtk::Box *content = m.get_content_area(); + Gtk::Grid contentGrid; + contentGrid.set_column_spacing(8); + contentGrid.set_row_spacing(8); + contentGrid.set_hexpand(true); + + + contentGrid.set_margin_start(20); + contentGrid.set_margin_end(20); + contentGrid.set_margin_bottom(20); + contentGrid.set_margin_top(20); + + content->add(contentGrid); + + Gtk::Label hostLabel("Host :"); + contentGrid.attach(hostLabel, 0, 0); + + Gtk::Entry hostEntry; + contentGrid.attach(hostEntry, 1, 0); + + Gtk::Label portLabel("Port :"); + contentGrid.attach(portLabel, 2, 0); + + Gtk::Entry portEntry; + contentGrid.attach(portEntry, 3, 0); + + Gtk::Label usernameLabel("Username :"); + contentGrid.attach(usernameLabel, 0, 1, 1, 1); + + Gtk::Entry usernameEntry; + contentGrid.attach(usernameEntry, 1, 1, 3, 1); + + Gtk::Label passwordLabel("Password :"); + contentGrid.attach(passwordLabel, 0, 2, 1, 1); + + Gtk::Entry passwordEntry; + passwordEntry.set_visibility(false); + contentGrid.attach(passwordEntry, 1, 2, 3, 1); + + content->show_all(); + + int result = m.run(); + + std::string host; + int port = 22; + + std::string username; + std::string password; + + if (result == Gtk::RESPONSE_OK) { + host = hostEntry.get_text(); + + if (portEntry.get_text_length() > 0) { + port = std::stoi(portEntry.get_text()); + } + + username = usernameEntry.get_text(); + password = passwordEntry.get_text(); + } else { + return; + } + + try { + Vte *vte = new Vte(); + vte->spawnShell(this->eventLoop, host.c_str(), port, username.c_str(), password.c_str()); + + Gtk::Widget *widget = vte->asGtkWidget(); + this->notebook.append_page(*widget, host, true); + widget->show(); + } catch (std::exception &e) { + std::cerr << e.what() << std::endl; + } +} diff --git a/src/vte.cpp b/src/vte.cpp new file mode 100644 index 0000000..bce9921 --- /dev/null +++ b/src/vte.cpp @@ -0,0 +1,71 @@ +// +// Created by oupson on 28/02/2022. +// + +#include "vte.hpp" +#include "term_windows.hpp" +#include "ssh.hpp" + +#include +#include // std::thread +#include +#include + +static void got_child_exited([[maybe_unused]] VteTerminal *vte, gint status, [[maybe_unused]] Vte *window) { + // TODO WHEN LOCAL TERMINAL +} + +static void +termStateCallback([[maybe_unused]] VteTerminal *terminal, GPid pid, GError *error, + [[maybe_unused]] gpointer windowPtr) { + if (error == nullptr) { + fmt::print("{} started. (PID: {})\n", "foo", pid); + } else { + fmt::print("An error occurred: {}\n", error->message); + g_clear_error(&error); + } +} + + +Vte::Vte() { + this->terminal = vte_terminal_new(); + vte_terminal_set_enable_sixel(VTE_TERMINAL(this->terminal), true); +} + +void Vte::spawnShell(EventLoop &eventLoop, const char *host, int port, const char *username, const char *password) { + int master, slave; + + struct termios settings{}; + cfmakeraw(&settings); + + int error = openpty(&master, &slave, nullptr, &settings, nullptr); // TODO + + fcntl(slave, F_SETFL, FNDELAY); + + VtePty *pty = vte_pty_new_foreign_sync(master, nullptr, nullptr); + vte_terminal_set_pty(VTE_TERMINAL(this->terminal), pty); + + auto *s = new Session(); + s->openConnection(host, port, username, password); + + EventLoopEntry entry1{}; + entry1.out = slave; + entry1.entryType = EventLoopEntry::CHANNEL; + entry1.channel = s->getChannel(); + + eventLoop.registerFd(s->getSock(), entry1); + + + EventLoopEntry entry2{}; + entry2.out = slave; + entry2.entryType = EventLoopEntry::DESCRIPTOR; + entry2.channel = s->getChannel(); + + eventLoop.registerFd(slave, entry2); + + g_signal_connect(G_OBJECT(terminal), "child-exited", G_CALLBACK(got_child_exited), this); +} + +Gtk::Widget *Vte::asGtkWidget() { + return Glib::wrap(this->terminal); +} diff --git a/third-party/CMakeLists.txt b/third-party/CMakeLists.txt new file mode 100644 index 0000000..2d704cb --- /dev/null +++ b/third-party/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(libssh2) \ No newline at end of file diff --git a/third-party/libssh2 b/third-party/libssh2 new file mode 160000 index 0000000..635caa9 --- /dev/null +++ b/third-party/libssh2 @@ -0,0 +1 @@ +Subproject commit 635caa90787220ac3773c1d5ba11f1236c22eae8