diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4332285993..d05644a930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ env: -DENABLE_CRYPTO=ON -DENABLE_NETSSL=ON -DENABLE_JWT=ON -DENABLE_ENCODINGS=ON -DENABLE_PDF=ON -DENABLE_ZIP=ON -DENABLE_SEVENZIP=ON - -DENABLE_REDIS=ON -DENABLE_MONGODB=ON + -DENABLE_REDIS=ON -DENABLE_MONGODB=ON -DENABLE_SSH=ON -DENABLE_DATA=ON -DENABLE_DATA_SQLITE=ON -DENABLE_PROMETHEUS=ON -DENABLE_ACTIVERECORD=ON -DENABLE_ACTIVERECORD_COMPILER=ON @@ -59,7 +59,7 @@ env: -DENABLE_XML=ON -DENABLE_JSON=ON -DENABLE_NET=ON -DENABLE_UTIL=ON -DENABLE_CRYPTO=ON -DENABLE_NETSSL=ON -DENABLE_NETSSL_WIN=ON -DENABLE_JWT=ON -DENABLE_DATA=ON -DENABLE_DATA_SQLITE=ON -DENABLE_DATA_ODBC=ON - -DENABLE_ZIP=ON -DENABLE_ENCODINGS=ON + -DENABLE_ZIP=ON -DENABLE_ENCODINGS=ON -DENABLE_SSH=ON -DENABLE_PDF=ON -DENABLE_PROMETHEUS=ON -DENABLE_ACTIVERECORD=ON -DENABLE_ACTIVERECORD_COMPILER=ON -DENABLE_CPPPARSER=ON @@ -183,7 +183,7 @@ jobs: - run: sudo sysctl vm.mmap_rnd_bits - run: sudo sysctl -w vm.mmap_rnd_bits=28 - run: >- - sudo apt -y update && sudo apt -y install libssl-dev libltdl-dev apache2-dev libapr1-dev libaprutil1-dev libavahi-client-dev + sudo apt -y update && sudo apt -y install libssl-dev libssh-dev libltdl-dev apache2-dev libapr1-dev libaprutil1-dev libavahi-client-dev libsqlite3-dev redis-server - uses: supercharge/mongodb-github-action@1.12.1 - run: >- @@ -450,9 +450,9 @@ jobs: ASAN_OPTIONS: ${{ matrix.asan_options }} steps: - uses: actions/checkout@v5 - - name: Install OpenSSL, Redis, MongoDB + - name: Install OpenSSL, libssh, Redis, MongoDB run: | - brew install openssl@3 redis + brew install openssl@3 libssh redis brew tap mongodb/brew brew install mongodb-community - name: Start Redis and MongoDB @@ -567,9 +567,9 @@ jobs: TSAN_OPTIONS: ${{ matrix.name == 'tsan' && format('suppressions={0}/tsan.suppress,second_deadlock_stack=1', github.workspace) || '' }} steps: - uses: actions/checkout@v5 - - name: Install OpenSSL, Redis, MongoDB + - name: Install OpenSSL, libssh, Redis, MongoDB run: | - brew install openssl@3 redis + brew install openssl@3 libssh redis brew tap mongodb/brew brew install mongodb-community - name: Start Redis and MongoDB @@ -611,64 +611,101 @@ jobs: - uses: TheMrMilchmann/setup-msvc-dev@v4 with: arch: x64 - - name: Install OpenSSL (FireDaemon portable zip) + - name: Cache vcpkg binary archives + # vcpkg's default file-based binary cache at %LOCALAPPDATA%\vcpkg\archives + # stores each built port as a content-addressed .zip keyed by ABI hash. + # Caching that directory via actions/cache persists the archives across + # runner VMs. New builds add new archives; the key rotates on run_id + # so every run writes a fresh cache entry, and restore-keys picks up + # the most recent prefix match. ABI hash changes (e.g. vcpkg port + # update for libssh or openssl) naturally miss and trigger rebuild. + # Replaces the removed x-gha provider from VCPKG_BINARY_SOURCES. + uses: actions/cache@v4 + with: + path: ~\AppData\Local\vcpkg\archives + key: vcpkg-archives-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + vcpkg-archives-${{ runner.os }}- + - name: Uninstall pre-existing OpenSSL / libssh shell: pwsh - env: - # Major.minor track. 3.6 = latest, 3.5 = LTS. Latest patch is scraped - # from the KB page; falls back to OPENSSL_ZIP_FALLBACK if scrape fails. - OPENSSL_TRACK: "3.6" - OPENSSL_ZIP_FALLBACK: openssl-3.6.2.zip + # Runner images carry preinstalled OpenSSL/OpenSSH and cached vcpkg + # ports. Scrub all three so only our vcpkg install remains. vcpkg is + # the single source of OpenSSL (pulled in as a transitive dep of + # libssh), matching the rule: no second OpenSSL in CI. run: | $ProgressPreference = 'SilentlyContinue' - # FireDaemon ships a portable OpenSSL zip with the legacy provider - # included (ossl-modules/legacy.dll) -- needed for DES-ECB and PKCS12 - # tests that exercise RC2/3DES PBE. No winget, no registry, no admin. - # Detect latest patch version from the KB page. - $kb = "https://kb.firedaemon.com/support/solutions/articles/4000121705-openssl-binary-distributions-for-microsoft-windows" - $zipName = $env:OPENSSL_ZIP_FALLBACK - try { - $html = Invoke-WebRequest -Uri $kb -UseBasicParsing -TimeoutSec 30 - $pattern = "openssl-" + [regex]::Escape($env:OPENSSL_TRACK) + "\.(\d+)([a-z]?)\.zip" - $found = [regex]::Matches($html.Content, $pattern) | - ForEach-Object { $_.Value } | Select-Object -Unique - if ($found.Count -gt 0) { - $zipName = $found | Sort-Object { - if ($_ -match "openssl-[\d\.]+\.(\d+)([a-z]?)\.zip") { - $patch = [int]$Matches[1] - $suffix = if ($Matches[2]) { [int][char]$Matches[2] } else { 0 } - $patch * 100 + $suffix - } else { 0 } - } -Descending | Select-Object -First 1 - } - } catch { - Write-Host "Version scrape failed ($_); using fallback $zipName" - } - Write-Host "Selected: $zipName" - # Install into the job sandbox so every job gets a clean copy. - $url = "https://download.firedaemon.com/FireDaemon-OpenSSL/$zipName" - $zip = Join-Path $env:RUNNER_TEMP $zipName - $installDir = Join-Path $env:RUNNER_TEMP "openssl" - Write-Host "Downloading $url" - Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing - Expand-Archive -Path $zip -DestinationPath $installDir -Force - $root = Join-Path $installDir "x64" - if (-not (Test-Path "$root\bin\libssl-3-x64.dll")) { - Write-Error "libssl-3-x64.dll missing under $root" - exit 1 + vcpkg --vcpkg-root="$env:VCPKG_INSTALLATION_ROOT" remove libssh:x64-windows openssl:x64-windows --recurse 2>&1 | Out-Host + $drop = @( + 'C:\Program Files\OpenSSL\bin', + 'C:\Program Files\OpenSSL-Win64\bin', + 'C:\OpenSSL-Win64\bin', + 'C:\Windows\System32\OpenSSH' + ) + $clean = ($env:PATH -split ';' | Where-Object { + $p = $_.TrimEnd('\'); $drop -notcontains $p + }) -join ';' + "PATH=$clean" | Out-File -FilePath $env:GITHUB_ENV -Append + foreach ($d in 'C:\OpenSSL-Win64','C:\Program Files\OpenSSL','C:\Program Files\OpenSSL-Win64') { + if (Test-Path $d) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $d } } - if (-not (Test-Path "$root\lib\ossl-modules\legacy.dll")) { - Write-Error "legacy.dll missing under $root\lib\ossl-modules" - exit 1 + - name: Install OpenSSL + libssh (vcpkg) + shell: pwsh + # libssh drags in openssl as a transitive dep -- we deliberately use + # that single copy for the whole build. Explicit Test-Path assertions + # fail fast on missing files. + run: | + $ProgressPreference = 'SilentlyContinue' + vcpkg --vcpkg-root="$env:VCPKG_INSTALLATION_ROOT" install libssh:x64-windows + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $root = "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows" + # Fixed paths -- no recursive globbing. vcpkg's x64-windows triplet + # places runtime DLLs flat under $root\bin\, including OpenSSL + # provider modules (legacy.dll, default.dll). If this ever diverges, + # we want a hard fail, not a silent pick of a wrong copy. + $providerDir = "$root\bin" + $required = @( + "$root\bin\libcrypto-3-x64.dll", + "$root\bin\libssl-3-x64.dll", + "$root\bin\ssh.dll", + "$root\lib\libcrypto.lib", + "$root\lib\libssl.lib", + "$root\lib\ssh.lib", + "$root\include\openssl\ssl.h", + "$root\include\libssh\libssh.h", + "$providerDir\legacy.dll" + ) + foreach ($p in $required) { + if (-not (Test-Path $p)) { Write-Error "Missing: $p"; exit 1 } } - "OPENSSL_ROOT_DIR=$root" | Out-File -FilePath $env:GITHUB_ENV -Append + "OPENSSL_ROOT_DIR=$root" | Out-File -FilePath $env:GITHUB_ENV -Append + "LIBSSH_ROOT_DIR=$root" | Out-File -FilePath $env:GITHUB_ENV -Append + "OPENSSL_MODULES=$providerDir" | Out-File -FilePath $env:GITHUB_ENV -Append Add-Content $env:GITHUB_PATH "$root\bin" - "OPENSSL_MODULES=$root\lib\ossl-modules" | Out-File -FilePath $env:GITHUB_ENV -Append Write-Host "OPENSSL_ROOT_DIR=$root" + Write-Host "LIBSSH_ROOT_DIR=$root" + Write-Host "OPENSSL_MODULES=$providerDir" + - name: Verify OpenSSL / libssh DLL resolution + shell: pwsh + # Prove the loader will pick DLLs from the vcpkg tree, not from + # residue elsewhere on the runner. Fails the job on any mismatch. + run: | + foreach ($dll in 'libcrypto-3-x64.dll','libssl-3-x64.dll','ssh.dll') { + $cmd = Get-Command $dll -ErrorAction SilentlyContinue + if ($null -eq $cmd) { Write-Error "$dll not resolvable on PATH"; exit 1 } + $resolved = [System.IO.Path]::GetFullPath($cmd.Source) + $expected = [System.IO.Path]::GetFullPath("$env:LIBSSH_ROOT_DIR\bin\$dll") + Write-Host "$dll -> $resolved" + if ($resolved -ne $expected) { + Write-Error "$dll resolves to $resolved, expected $expected" + exit 1 + } + } - run: >- cmake -S. -Bcmake-build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON ${{ env.POCO_CMAKE_WINDOWS_FULL }} -DOPENSSL_ROOT_DIR="${{ env.OPENSSL_ROOT_DIR }}" + -DCMAKE_PREFIX_PATH="${{ env.LIBSSH_ROOT_DIR }}" - run: cmake --build cmake-build --parallel $env:NUMBER_OF_PROCESSORS - uses: ./.github/actions/retry-action env: @@ -681,6 +718,11 @@ jobs: cd cmake-build; ctest ${{ env.POCO_CTEST_COMMON }} --parallel $env:NUMBER_OF_PROCESSORS -E "(DataMySQL)|(DataODBC)|(Redis)|(MongoDB)" - uses: ./.github/actions/upload-test-report + - name: Uninstall OpenSSL / libssh + if: always() + shell: pwsh + run: | + vcpkg --vcpkg-root="$env:VCPKG_INSTALLATION_ROOT" remove libssh:x64-windows openssl:x64-windows --recurse 2>&1 | Out-Host # clang-cl toolchain exception: reduced scope (Foundation + Util smoke). windows-2025-clang-cmake: diff --git a/CMakeLists.txt b/CMakeLists.txt index 44d5a97da4..bf231e583a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -184,6 +184,7 @@ option(ENABLE_DATA "Enable Data" ${_enable_default}) option(ENABLE_DATA_SQLITE "Enable Data SQlite" ${_enable_default}) option(ENABLE_MONGODB "Enable MongoDB" ${_enable_default}) option(ENABLE_REDIS "Enable Redis" ${_enable_default}) +option(ENABLE_SSH "Enable SSH" OFF) option(ENABLE_PROMETHEUS "Enable Prometheus" ${_enable_default}) option(ENABLE_UTIL "Enable Util" ${_enable_default}) option(ENABLE_NET "Enable Net" ${_enable_default}) @@ -446,6 +447,14 @@ if(ENABLE_MONGODB OR ENABLE_REDIS OR ENABLE_PROMETHEUS) set(ENABLE_NET ON CACHE BOOL "Enable Net" FORCE) endif() +if(ENABLE_SSH) + find_package(libssh CONFIG) + if(NOT libssh_FOUND) + message(STATUS "libssh not found - disabling SSH") + set(ENABLE_SSH OFF CACHE BOOL "Enable SSH" FORCE) + endif() +endif() + if(ENABLE_NETSSL) set(ENABLE_CRYPTO ON CACHE BOOL "Enable Crypto" FORCE) set(ENABLE_NET ON CACHE BOOL "Enable Net" FORCE) @@ -571,6 +580,11 @@ if(EXISTS ${PROJECT_SOURCE_DIR}/Redis AND ENABLE_REDIS) list(APPEND Poco_COMPONENTS "Redis") endif() +if(EXISTS ${PROJECT_SOURCE_DIR}/SSH AND ENABLE_SSH) + add_subdirectory(SSH) + list(APPEND Poco_COMPONENTS "SSH") +endif() + if(ENABLE_DNSSD) add_subdirectory(DNSSD) list(APPEND Poco_COMPONENTS "DNSSD") diff --git a/SSH/CMakeLists.txt b/SSH/CMakeLists.txt new file mode 100644 index 0000000000..68b71b501c --- /dev/null +++ b/SSH/CMakeLists.txt @@ -0,0 +1,46 @@ +# Sources +file(GLOB SRCS_G "src/*.cpp") +POCO_SOURCES_AUTO(SRCS ${SRCS_G}) + +# Headers +file(GLOB_RECURSE HDRS_G "include/*.h") +POCO_HEADERS_AUTO(SRCS ${HDRS_G}) + +# Version Resource +if(MSVC AND BUILD_SHARED_LIBS) + source_group("Resources" FILES ${PROJECT_SOURCE_DIR}/DLLVersion.rc) + list(APPEND SRCS ${PROJECT_SOURCE_DIR}/DLLVersion.rc) +endif() + +add_library(SSH ${SRCS}) +add_library(Poco::SSH ALIAS SSH) +set_target_properties(SSH + PROPERTIES + VERSION ${SHARED_LIBRARY_VERSION} SOVERSION ${SHARED_LIBRARY_VERSION} + OUTPUT_NAME PocoSSH + DEFINE_SYMBOL SSH_EXPORTS +) + +target_link_libraries(SSH PUBLIC Poco::Foundation) +target_link_libraries(SSH PRIVATE ssh) +if(WIN32) + target_link_libraries(SSH PUBLIC ws2_32) +endif() +target_include_directories(SSH + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +POCO_INSTALL(SSH) +POCO_GENERATE_PACKAGE(SSH) + +if(ENABLE_TESTS) + add_subdirectory(testsuite) +endif() + +if(ENABLE_SAMPLES) + add_subdirectory(samples) +endif() diff --git a/SSH/Makefile b/SSH/Makefile new file mode 100644 index 0000000000..26181cef7a --- /dev/null +++ b/SSH/Makefile @@ -0,0 +1,22 @@ +# +# Makefile +# +# Makefile for Poco SSH Library +# + +include $(POCO_BASE)/build/rules/global + +ifeq ($(OSNAME),Darwin) +SYSFLAGS += $(shell pkg-config --cflags libssh 2>/dev/null) +SYSLIBS += $(shell pkg-config --libs libssh 2>/dev/null || echo -lssh) +else +SYSLIBS += -lssh +endif + +objects = SSHChannelStream SSHClient SSHException SSHHostKeyManager SSHServer SSHSession + +target = PocoSSH +target_version = 1 +target_libs = PocoFoundation + +include $(POCO_BASE)/build/rules/lib diff --git a/SSH/cmake/PocoSSHConfig.cmake b/SSH/cmake/PocoSSHConfig.cmake new file mode 100644 index 0000000000..c765cb6b06 --- /dev/null +++ b/SSH/cmake/PocoSSHConfig.cmake @@ -0,0 +1,3 @@ +include(CMakeFindDependencyMacro) +find_dependency(PocoFoundation) +include("${CMAKE_CURRENT_LIST_DIR}/PocoSSHTargets.cmake") diff --git a/SSH/dependencies b/SSH/dependencies new file mode 100644 index 0000000000..2e8175e4e1 --- /dev/null +++ b/SSH/dependencies @@ -0,0 +1 @@ +Foundation diff --git a/SSH/include/Poco/SSH/SSH.h b/SSH/include/Poco/SSH/SSH.h new file mode 100644 index 0000000000..cedd771e9a --- /dev/null +++ b/SSH/include/Poco/SSH/SSH.h @@ -0,0 +1,62 @@ +// +// SSH.h +// +// Library: SSH +// Package: SSH +// Module: SSH +// +// Basic definitions for the Poco SSH library. +// This file must be the first file included by every other SSH +// header file. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSH_INCLUDED +#define SSH_SSH_INCLUDED + + +#include "Poco/Foundation.h" + + +// +// The following block is the standard way of creating macros which make exporting +// from a DLL simpler. All files within this DLL are compiled with the SSH_EXPORTS +// symbol defined on the command line. this symbol should not be defined on any project +// that uses this DLL. This way any other project whose source files include this file see +// SSH_API functions as being imported from a DLL, whereas this DLL sees symbols +// defined with this macro as being exported. +// +#if defined(_WIN32) && defined(POCO_DLL) + #if defined(SSH_EXPORTS) + #define SSH_API __declspec(dllexport) + #else + #define SSH_API __declspec(dllimport) + #endif +#endif + + +#if !defined(SSH_API) + #if !defined(POCO_NO_GCC_API_ATTRIBUTE) && defined (__GNUC__) && (__GNUC__ >= 4) + #define SSH_API __attribute__ ((visibility ("default"))) + #else + #define SSH_API + #endif +#endif + + +// +// Automatically link SSH library. +// +#if defined(_MSC_VER) + #if !defined(POCO_NO_AUTOMATIC_LIBS) && !defined(SSH_EXPORTS) + #pragma comment(lib, "PocoSSH" POCO_LIB_SUFFIX) + #endif +#endif + + +#endif // SSH_SSH_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHChannelStream.h b/SSH/include/Poco/SSH/SSHChannelStream.h new file mode 100644 index 0000000000..1730b91ec3 --- /dev/null +++ b/SSH/include/Poco/SSH/SSHChannelStream.h @@ -0,0 +1,69 @@ +// +// SSHChannelStream.h +// +// Library: SSH +// Package: SSH +// Module: SSHChannelStream +// +// I/O bridge between libssh channels and C++ streams. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHChannelStream_INCLUDED +#define SSH_SSHChannelStream_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include +#include +#include +#include + + +namespace Poco { +namespace SSH { + + +class SSH_API SSHChannelStreamBuf: public std::streambuf + /// A streambuf that writes to an SSH channel via ssh_channel_write(). +{ +public: + explicit SSHChannelStreamBuf(ssh_channel channel); + ~SSHChannelStreamBuf(); + +protected: + int overflow(int c) override; + std::streamsize xsputn(const char* s, std::streamsize n) override; + +private: + ssh_channel _channel; +}; + + +class SSH_API SSHChannelStream: public std::ostream + /// An ostream that writes to an SSH channel. +{ +public: + explicit SSHChannelStream(ssh_channel channel); + ~SSHChannelStream(); + +private: + SSHChannelStreamBuf _buf; +}; + + +SSH_API bool sshReadLine(ssh_channel channel, std::string& line); + /// Reads one line from the SSH channel with character echo. + /// Handles backspace, Ctrl+C, and CR/LF line termination. + /// Returns true if a line was read, false on EOF or error. + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHChannelStream_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHClient.h b/SSH/include/Poco/SSH/SSHClient.h new file mode 100644 index 0000000000..d198cc764d --- /dev/null +++ b/SSH/include/Poco/SSH/SSHClient.h @@ -0,0 +1,92 @@ +// +// SSHClient.h +// +// Library: SSH +// Package: SSH +// Module: SSHClient +// +// Definition of the SSHClient class. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHClient_INCLUDED +#define SSH_SSHClient_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "Poco/Types.h" +#include +#include + + +namespace Poco { +namespace SSH { + + +class SSH_API SSHClient + /// SSH client for connecting to an SSH server. + /// + /// Provides connect, authenticate (password or public key), + /// and channel management. RAII — disconnects on destruction. +{ +public: + SSHClient(); + ~SSHClient(); + + void connect(const std::string& host, Poco::UInt16 port = 22); + /// Connects to the SSH server at host:port. + /// Performs key exchange automatically. + + bool authenticatePassword(const std::string& user, const std::string& password); + /// Authenticates using username and password. + /// Returns true on success. + + bool authenticatePublicKey(const std::string& user, const std::string& keyFile = ""); + /// Authenticates using public key. + /// If keyFile is empty, tries default keys from ~/.ssh/. + /// Returns true on success. + + ssh_channel openShellChannel(); + /// Opens a session channel and requests a shell. + /// Returns the channel on success, nullptr on failure. + /// Caller owns the channel and must free it. + + void disconnect(); + /// Disconnects from the server. + + bool isConnected() const; + /// Returns true if connected. + + ssh_session session() const; + /// Returns the underlying ssh_session. + +private: + SSHClient(const SSHClient&) = delete; + SSHClient& operator=(const SSHClient&) = delete; + + ssh_session _session; + bool _connected; +}; + + +inline bool SSHClient::isConnected() const +{ + return _connected; +} + + +inline ssh_session SSHClient::session() const +{ + return _session; +} + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHClient_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHException.h b/SSH/include/Poco/SSH/SSHException.h new file mode 100644 index 0000000000..85a9bb3271 --- /dev/null +++ b/SSH/include/Poco/SSH/SSHException.h @@ -0,0 +1,39 @@ +// +// SSHException.h +// +// Library: SSH +// Package: SSH +// Module: SSHException +// +// Definition of SSHException. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHException_INCLUDED +#define SSH_SSHException_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "Poco/Exception.h" + + +namespace Poco { +namespace SSH { + + +POCO_DECLARE_EXCEPTION(SSH_API, SSHException, Poco::IOException) +POCO_DECLARE_EXCEPTION(SSH_API, SSHKeyExchangeException, SSHException) +POCO_DECLARE_EXCEPTION(SSH_API, SSHAuthenticationException, SSHException) +POCO_DECLARE_EXCEPTION(SSH_API, SSHChannelException, SSHException) +POCO_DECLARE_EXCEPTION(SSH_API, SSHConnectionException, SSHException) + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHException_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHHostKeyManager.h b/SSH/include/Poco/SSH/SSHHostKeyManager.h new file mode 100644 index 0000000000..49d631bacb --- /dev/null +++ b/SSH/include/Poco/SSH/SSHHostKeyManager.h @@ -0,0 +1,52 @@ +// +// SSHHostKeyManager.h +// +// Library: SSH +// Package: SSH +// Module: SSHHostKeyManager +// +// SSH host key generation and loading. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHHostKeyManager_INCLUDED +#define SSH_SSHHostKeyManager_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include + + +namespace Poco { +namespace SSH { + + +class SSH_API SSHHostKeyManager + /// Manages SSH host key lifecycle. + /// + /// Ensures a host key exists at the configured path, + /// generating an Ed25519 key if none is found. +{ +public: + static std::string ensureHostKey( + const std::string& keyDir, + const std::string& keyName = "ssh_host_ed25519_key"); + /// Ensures a host key exists at keyDir/keyName. + /// Generates an Ed25519 key if missing. + /// Returns the full path to the key file. + + static void generateKey(const std::string& path); + /// Generates an Ed25519 private key and writes it to the given path. + /// Sets file permissions to 0600. +}; + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHHostKeyManager_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHServer.h b/SSH/include/Poco/SSH/SSHServer.h new file mode 100644 index 0000000000..aad8b09d62 --- /dev/null +++ b/SSH/include/Poco/SSH/SSHServer.h @@ -0,0 +1,100 @@ +// +// SSHServer.h +// +// Library: SSH +// Package: SSH +// Module: SSHServer +// +// Definition of the SSHServer class. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHServer_INCLUDED +#define SSH_SSHServer_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "Poco/SSH/SSHServerConfig.h" +#include "Poco/Logger.h" +#include "Poco/Thread.h" +#include "Poco/ThreadPool.h" +#include "Poco/RunnableAdapter.h" +#include "Poco/Mutex.h" +#include "Poco/Event.h" +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +using ssh_socket_t = SOCKET; +#define SSH_INVALID_FD INVALID_SOCKET +#else +using ssh_socket_t = int; +#define SSH_INVALID_FD (-1) +#endif + + +namespace Poco { +namespace SSH { + + +class SSHSession; + + +class SSH_API SSHServer + /// SSH server that listens for connections and delegates + /// each session to an SSHSession subclass via a factory. + /// + /// The server handles the ssh_bind lifecycle, the accept loop, + /// and a thread pool for concurrent sessions. +{ +public: + using SessionFactory = std::function; + /// Factory function that creates an SSHSession for each accepted connection. + + SSHServer(const SSHServerConfig& config, SessionFactory factory); + ~SSHServer(); + + void start(); + /// Starts listening for SSH connections. + + void stop(); + /// Stops the server and waits for active sessions to finish. + + void registerSession(ssh_session session); + /// Register an active session for tracking. + + void unregisterSession(ssh_session session); + /// Unregister a session on exit. + +private: + void acceptLoop(); + void disconnectAllSessions(); + + SSHServerConfig _config; + SessionFactory _sessionFactory; + ssh_bind _sshBind; + std::atomic _listenFd; + Poco::RunnableAdapter _acceptAdapter; + Poco::Thread _acceptThread; + Poco::ThreadPool _threadPool; + std::atomic _stopped; + Poco::FastMutex _sessionsMutex; + std::set _activeSessions; + Poco::Event _noSessions; + Poco::Logger& _logger; +}; + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHServer_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHServerConfig.h b/SSH/include/Poco/SSH/SSHServerConfig.h new file mode 100644 index 0000000000..842361a05d --- /dev/null +++ b/SSH/include/Poco/SSH/SSHServerConfig.h @@ -0,0 +1,74 @@ +// +// SSHServerConfig.h +// +// Library: SSH +// Package: SSH +// Module: SSHServerConfig +// +// Configuration for the SSH server. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHServerConfig_INCLUDED +#define SSH_SSHServerConfig_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "Poco/Types.h" +#include +#include + + +namespace Poco { +namespace SSH { + + +struct SSH_API SSHServerConfig + /// Configuration for the SSH server. +{ + std::string bindAddress = "localhost"; + /// Network interface to listen on. + + Poco::UInt16 port = 22023; + /// SSH listen port. + + std::string hostKeyPath; + /// Path to the SSH host key file (Ed25519). + /// Generated automatically if missing. + + std::string authorizedKeysDir; + /// Directory containing per-user authorized_keys files. + /// Each file is named by username (e.g., "admin"). + /// Empty string disables public key authentication. + + int maxConnections = 4; + /// Maximum concurrent SSH sessions. + + int maxAuthAttempts = 3; + /// Maximum failed authentication attempts before disconnect. + + bool enablePasswordAuth = true; + /// Allow SSH password authentication. + + bool enablePubkeyAuth = true; + /// Allow SSH public key authentication. + + using PasswordAuthenticator = std::function; + /// Callback for password authentication. + /// Returns true if credentials are valid. + + PasswordAuthenticator passwordAuthenticator; + /// The password authenticator callback. + /// If not set, password auth is rejected even if enablePasswordAuth is true. +}; + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHServerConfig_INCLUDED diff --git a/SSH/include/Poco/SSH/SSHSession.h b/SSH/include/Poco/SSH/SSHSession.h new file mode 100644 index 0000000000..013e586cb9 --- /dev/null +++ b/SSH/include/Poco/SSH/SSHSession.h @@ -0,0 +1,89 @@ +// +// SSHSession.h +// +// Library: SSH +// Package: SSH +// Module: SSHSession +// +// Definition of the SSHSession class. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSH_SSHSession_INCLUDED +#define SSH_SSHSession_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "Poco/SSH/SSHServerConfig.h" +#include "Poco/Logger.h" +#include "Poco/Runnable.h" +#include +#include + + +namespace Poco { +namespace SSH { + + +class SSHServer; + + +class SSH_API SSHSession: public Poco::Runnable + /// Abstract base class for SSH session handlers. + /// + /// Subclasses implement handleSession() which is called after + /// successful SSH authentication with an open channel. + /// + /// The base class handles the SSH lifecycle: key exchange, + /// authentication, channel negotiation, and cleanup. + /// + /// Instances are created by SSHServer and run in a thread pool. + /// Self-deletes at the end of run(). +{ +public: + SSHSession( + ssh_session session, + const SSHServerConfig& config, + SSHServer& server); + + virtual ~SSHSession(); + + void run() override; + +protected: + virtual void handleSession(ssh_channel channel, const std::string& username) = 0; + /// Called after successful SSH authentication and channel setup. + /// The channel is open and ready for I/O. + /// The username is the authenticated user. + /// Override in subclasses to implement session logic. + + const SSHServerConfig& config() const; + +private: + bool authenticate(); + ssh_channel negotiateChannel(); + bool checkAuthorizedKey(const std::string& username, ssh_key clientKey); + + ssh_session _session; + SSHServerConfig _config; + SSHServer& _server; + std::string _authenticatedUser; + Poco::Logger& _logger; +}; + + +inline const SSHServerConfig& SSHSession::config() const +{ + return _config; +} + + +} } // namespace Poco::SSH + + +#endif // SSH_SSHSession_INCLUDED diff --git a/SSH/samples/CMakeLists.txt b/SSH/samples/CMakeLists.txt new file mode 100644 index 0000000000..beddd7fa06 --- /dev/null +++ b/SSH/samples/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(pocossh) diff --git a/SSH/samples/pocossh/CMakeLists.txt b/SSH/samples/pocossh/CMakeLists.txt new file mode 100644 index 0000000000..f110941446 --- /dev/null +++ b/SSH/samples/pocossh/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(pocossh src/pocossh.cpp) +target_link_libraries(pocossh Poco::SSH Poco::Foundation ssh) diff --git a/SSH/samples/pocossh/Makefile b/SSH/samples/pocossh/Makefile new file mode 100644 index 0000000000..59eee748ba --- /dev/null +++ b/SSH/samples/pocossh/Makefile @@ -0,0 +1,22 @@ +# +# Makefile +# +# Makefile for pocossh sample +# + +include $(POCO_BASE)/build/rules/global + +objects = pocossh + +target = pocossh +target_version = 1 +target_libs = PocoSSH PocoFoundation + +ifeq ($(OSNAME),Darwin) +SYSFLAGS += $(shell pkg-config --cflags libssh 2>/dev/null) +SYSLIBS += $(shell pkg-config --libs libssh 2>/dev/null || echo -lssh) +else +SYSLIBS += -lssh +endif + +include $(POCO_BASE)/build/rules/exec diff --git a/SSH/samples/pocossh/src/pocossh.cpp b/SSH/samples/pocossh/src/pocossh.cpp new file mode 100644 index 0000000000..121a68e9f1 --- /dev/null +++ b/SSH/samples/pocossh/src/pocossh.cpp @@ -0,0 +1,234 @@ +// +// pocossh.cpp +// +// A simple standalone SSH client for connecting to an OSP shell +// or any SSH server. +// +// Usage: pocossh [-H host] [-P port] [-u user] [-p password] [-k keyfile] +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHClient.h" +#include "Poco/SSH/SSHChannelStream.h" +#include "Poco/Exception.h" +#include +#include +#include +#include + +#if !defined(POCO_OS_FAMILY_WINDOWS) +#include +#include +#include +#include +#endif + + +namespace +{ + struct TerminalRawMode + { +#if !defined(POCO_OS_FAMILY_WINDOWS) + struct termios _saved; + bool _active = false; + + void enable() + { + if (tcgetattr(STDIN_FILENO, &_saved) == 0) + { + struct termios raw = _saved; + raw.c_lflag &= ~(ECHO | ICANON | ISIG); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 1; // 100ms timeout + tcsetattr(STDIN_FILENO, TCSANOW, &raw); + _active = true; + } + } + + void disable() + { + if (_active) + { + tcsetattr(STDIN_FILENO, TCSANOW, &_saved); + _active = false; + } + } + + ~TerminalRawMode() { disable(); } +#else + void enable() {} + void disable() {} +#endif + }; + + + void printUsage(const char* progName) + { + std::cerr << "Usage: " << progName << " [-H host] [-P port] [-u user] [-p password] [-k keyfile]\n" + << " -H host SSH server hostname (default: localhost)\n" + << " -P port SSH server port (default: 22023)\n" + << " -u user Username (default: admin)\n" + << " -p password Password (prompts if not given and no key)\n" + << " -k keyfile Path to private key file (default: auto from ~/.ssh/)\n" + << std::endl; + } +} + + +int main(int argc, char* argv[]) +{ + std::string host = "localhost"; + int port = 22023; + std::string user = "admin"; + std::string password; + std::string keyFile; + bool hasPassword = false; + + // Parse arguments + for (int i = 1; i < argc; ++i) + { + if (std::strcmp(argv[i], "-H") == 0 && i + 1 < argc) + host = argv[++i]; + else if (std::strcmp(argv[i], "-P") == 0 && i + 1 < argc) + port = std::stoi(argv[++i]); + else if (std::strcmp(argv[i], "-u") == 0 && i + 1 < argc) + user = argv[++i]; + else if (std::strcmp(argv[i], "-p") == 0 && i + 1 < argc) + { + password = argv[++i]; + hasPassword = true; + } + else if (std::strcmp(argv[i], "-k") == 0 && i + 1 < argc) + keyFile = argv[++i]; + else if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) + { + printUsage(argv[0]); + return 0; + } + else + { + std::cerr << "Unknown option: " << argv[i] << std::endl; + printUsage(argv[0]); + return 1; + } + } + + try + { + Poco::SSH::SSHClient client; + client.connect(host, port); + + // Try key auth first, then password + bool authenticated = false; + if (!keyFile.empty()) + { + authenticated = client.authenticatePublicKey(user, keyFile); + } + else if (!hasPassword) + { + // Try auto key auth + authenticated = client.authenticatePublicKey(user); + } + + if (!authenticated) + { + if (!hasPassword) + { + std::cerr << "Password: " << std::flush; +#if !defined(POCO_OS_FAMILY_WINDOWS) + struct termios oldt{}; + struct termios newt{}; + tcgetattr(STDIN_FILENO, &oldt); + newt = oldt; + newt.c_lflag &= ~ECHO; + tcsetattr(STDIN_FILENO, TCSANOW, &newt); + std::getline(std::cin, password); + tcsetattr(STDIN_FILENO, TCSANOW, &oldt); + std::cerr << std::endl; +#else + std::getline(std::cin, password); +#endif + } + + authenticated = client.authenticatePassword(user, password); + } + + if (!authenticated) + { + std::cerr << "Authentication failed." << std::endl; + return 1; + } + + ssh_channel channel = client.openShellChannel(); + if (!channel) + { + std::cerr << "Failed to open shell channel." << std::endl; + return 1; + } + + // Interactive loop: stdin -> channel, channel -> stdout + TerminalRawMode rawMode; + rawMode.enable(); + +#if !defined(POCO_OS_FAMILY_WINDOWS) + struct pollfd fds[1]{}; + fds[0].fd = STDIN_FILENO; + fds[0].events = POLLIN; + + while (ssh_channel_is_eof(channel) == 0 && ssh_channel_is_closed(channel) == 0) + { + // Read from channel (non-blocking) + char buf[4096]; + int nbytes = ssh_channel_read_nonblocking(channel, buf, sizeof(buf), 0); + if (nbytes > 0) + { + const ssize_t written = write(STDOUT_FILENO, buf, nbytes); + (void)written; // best-effort echo; channel may disappear next loop + } + else if (nbytes < 0) + { + break; + } + + // Read from stdin + if (poll(fds, 1, 50) > 0 && (fds[0].revents & POLLIN) != 0) + { + const ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n > 0) + { + ssh_channel_write(channel, buf, static_cast(n)); + } + else if (n == 0) + { + break; // EOF on stdin + } + } + } +#else + std::cerr << "Interactive mode not supported on Windows in this sample." << std::endl; +#endif + + rawMode.disable(); + + ssh_channel_send_eof(channel); + ssh_channel_close(channel); + ssh_channel_free(channel); + + return 0; + } + catch (Poco::Exception& exc) + { + std::cerr << "Error: " << exc.displayText() << std::endl; + return 1; + } + catch (std::exception& exc) + { + std::cerr << "Error: " << exc.what() << std::endl; + return 1; + } +} diff --git a/SSH/src/SSHChannelStream.cpp b/SSH/src/SSHChannelStream.cpp new file mode 100644 index 0000000000..1b9929608f --- /dev/null +++ b/SSH/src/SSHChannelStream.cpp @@ -0,0 +1,117 @@ +// +// SSHChannelStream.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHChannelStream +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHChannelStream.h" + + +namespace Poco { +namespace SSH { + + +SSHChannelStreamBuf::SSHChannelStreamBuf(ssh_channel channel): + _channel(channel) +{ +} + + +SSHChannelStreamBuf::~SSHChannelStreamBuf() = default; + + +int SSHChannelStreamBuf::overflow(int c) +{ + if (c != EOF) + { + char ch = static_cast(c); + if (ssh_channel_write(_channel, &ch, 1) < 0) + return EOF; + } + return c; +} + + +std::streamsize SSHChannelStreamBuf::xsputn(const char* s, std::streamsize n) +{ + if (n <= 0) return 0; + + static const uint32_t MAX_CHUNK = 0x7FFFFFFF; // max safe size for ssh_channel_write + std::streamsize totalWritten = 0; + while (totalWritten < n) + { + std::streamsize remaining = n - totalWritten; + uint32_t chunk = (remaining > MAX_CHUNK) ? MAX_CHUNK : static_cast(remaining); + int written = ssh_channel_write(_channel, s + totalWritten, chunk); + if (written <= 0) + break; + totalWritten += written; + } + return totalWritten; +} + + +SSHChannelStream::SSHChannelStream(ssh_channel channel): + std::ostream(&_buf), + _buf(channel) +{ +} + + +SSHChannelStream::~SSHChannelStream() = default; + + +bool sshReadLine(ssh_channel channel, std::string& line) +{ + line.clear(); + char ch = 0; + while (true) + { + const int nbytes = ssh_channel_read(channel, &ch, 1, 0); + if (nbytes <= 0) + return false; // EOF or error + + if (ch == '\r' || ch == '\n') + { + // Echo newline back to client + ssh_channel_write(channel, "\r\n", 2); + // Strip trailing CR if present + if (!line.empty() && line.back() == '\r') + line.pop_back(); + return true; + } + if (ch == '\x7f' || ch == '\b') + { + // Backspace: erase last character + if (!line.empty()) + { + line.pop_back(); + ssh_channel_write(channel, "\b \b", 3); + } + } + else if (ch == '\x03') + { + // Ctrl+C: discard line + ssh_channel_write(channel, "^C\r\n", 4); + line.clear(); + return true; + } + else if (ch >= ' ') + { + // Echo printable character back + line += ch; + ssh_channel_write(channel, &ch, 1); + } + } +} + + +} } // namespace Poco::SSH diff --git a/SSH/src/SSHClient.cpp b/SSH/src/SSHClient.cpp new file mode 100644 index 0000000000..8ad28ddbd8 --- /dev/null +++ b/SSH/src/SSHClient.cpp @@ -0,0 +1,145 @@ +// +// SSHClient.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHClient +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHClient.h" +#include "Poco/SSH/SSHException.h" + + +namespace Poco { +namespace SSH { + + +SSHClient::SSHClient(): + _session(ssh_new()), + _connected(false) +{ + if (!_session) + throw SSHException("Failed to create SSH session"); +} + + +SSHClient::~SSHClient() +{ + disconnect(); + if (_session) + { + ssh_free(_session); + _session = nullptr; + } +} + + +void SSHClient::connect(const std::string& host, Poco::UInt16 port) +{ + if (_connected) + disconnect(); + + int p = port; + ssh_options_set(_session, SSH_OPTIONS_HOST, host.c_str()); + ssh_options_set(_session, SSH_OPTIONS_PORT, &p); + ssh_options_set(_session, SSH_OPTIONS_KNOWNHOSTS, "/dev/null"); + ssh_options_set(_session, SSH_OPTIONS_GLOBAL_KNOWNHOSTS, "/dev/null"); + + int rc = ssh_connect(_session); + if (rc != SSH_OK) + { + std::string err = ssh_get_error(_session); + throw SSHConnectionException("SSH connect failed: " + err); + } + + _connected = true; +} + + +bool SSHClient::authenticatePassword(const std::string& user, const std::string& password) +{ + if (!_connected) + throw SSHConnectionException("Not connected"); + + int rc = ssh_userauth_password(_session, user.c_str(), password.c_str()); + return rc == SSH_AUTH_SUCCESS; +} + + +bool SSHClient::authenticatePublicKey(const std::string& user, const std::string& keyFile) +{ + if (!_connected) + throw SSHConnectionException("Not connected"); + + int rc = SSH_AUTH_ERROR; + if (keyFile.empty()) + { + rc = ssh_userauth_publickey_auto(_session, user.c_str(), nullptr); + } + else + { + ssh_key key = nullptr; + // Passphrase-protected keys are not supported here: the passphrase + // callback is nullptr, so ssh_pki_import_privkey_file fails fast + // rather than blocking on interactive prompting. + const int importRc = ssh_pki_import_privkey_file(keyFile.c_str(), nullptr, nullptr, nullptr, &key); + if (importRc != SSH_OK) + return false; + rc = ssh_userauth_publickey(_session, user.c_str(), key); + ssh_key_free(key); + } + + return rc == SSH_AUTH_SUCCESS; +} + + +ssh_channel SSHClient::openShellChannel() +{ + if (!_connected) + throw SSHConnectionException("Not connected"); + + ssh_channel channel = ssh_channel_new(_session); + if (!channel) + return nullptr; + + if (ssh_channel_open_session(channel) != SSH_OK) + { + ssh_channel_free(channel); + return nullptr; + } + + if (ssh_channel_request_pty(channel) != SSH_OK) + { + ssh_channel_close(channel); + ssh_channel_free(channel); + return nullptr; + } + + if (ssh_channel_request_shell(channel) != SSH_OK) + { + ssh_channel_close(channel); + ssh_channel_free(channel); + return nullptr; + } + + return channel; +} + + +void SSHClient::disconnect() +{ + if (_connected) + { + ssh_disconnect(_session); + _connected = false; + } +} + + +} } // namespace Poco::SSH diff --git a/SSH/src/SSHException.cpp b/SSH/src/SSHException.cpp new file mode 100644 index 0000000000..3fb0868461 --- /dev/null +++ b/SSH/src/SSHException.cpp @@ -0,0 +1,29 @@ +// +// SSHException.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHException +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHException.h" + + +namespace Poco { +namespace SSH { + + +POCO_IMPLEMENT_EXCEPTION(SSHException, Poco::IOException, "SSH Exception") +POCO_IMPLEMENT_EXCEPTION(SSHKeyExchangeException, SSHException, "SSH Key Exchange Failed") +POCO_IMPLEMENT_EXCEPTION(SSHAuthenticationException, SSHException, "SSH Authentication Failed") +POCO_IMPLEMENT_EXCEPTION(SSHChannelException, SSHException, "SSH Channel Error") +POCO_IMPLEMENT_EXCEPTION(SSHConnectionException, SSHException, "SSH Connection Error") + + +} } // namespace Poco::SSH diff --git a/SSH/src/SSHHostKeyManager.cpp b/SSH/src/SSHHostKeyManager.cpp new file mode 100644 index 0000000000..e48d8829ae --- /dev/null +++ b/SSH/src/SSHHostKeyManager.cpp @@ -0,0 +1,105 @@ +// +// SSHHostKeyManager.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHHostKeyManager +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHHostKeyManager.h" +#include "Poco/SSH/SSHException.h" +#include "Poco/Path.h" +#include "Poco/File.h" +#include +#include + +#if !defined(POCO_OS_FAMILY_WINDOWS) +#include +#endif + + +namespace Poco { +namespace SSH { + + +std::string SSHHostKeyManager::ensureHostKey(const std::string& keyDir, const std::string& keyName) +{ + Poco::Path keyPath(keyDir); + keyPath.append(keyName); + std::string fullPath = keyPath.toString(); + + Poco::File keyFile(fullPath); + if (keyFile.exists() && keyFile.getSize() > 0) + { + // Verify the key loads successfully + ssh_key key = nullptr; + int rc = ssh_pki_import_privkey_file(fullPath.c_str(), nullptr, nullptr, nullptr, &key); + if (rc == SSH_OK && key) + { + ssh_key_free(key); + return fullPath; + } + if (key) ssh_key_free(key); + // Key file is corrupt — regenerate + } + + // Ensure directory exists + Poco::File dir(keyDir); + dir.createDirectories(); + + generateKey(fullPath); + return fullPath; +} + + +namespace +{ +#if !defined(POCO_OS_FAMILY_WINDOWS) + /// RAII guard that restores the process umask on scope exit. + /// Ensures the umask is restored even if ssh_pki_* throws or exits early. + class UmaskGuard + { + public: + explicit UmaskGuard(mode_t newMask): _oldMask(umask(newMask)) {} + ~UmaskGuard() { umask(_oldMask); } + UmaskGuard(const UmaskGuard&) = delete; + UmaskGuard& operator=(const UmaskGuard&) = delete; + private: + mode_t _oldMask; + }; +#endif +} + + +void SSHHostKeyManager::generateKey(const std::string& path) +{ +#if !defined(POCO_OS_FAMILY_WINDOWS) + // Set restrictive umask before writing private key to ensure + // the file is never accessible by group/others, even briefly. + UmaskGuard umaskGuard(0077); +#endif + + ssh_key key = nullptr; +#if LIBSSH_VERSION_INT >= SSH_VERSION_INT(0, 12, 0) + int rc = ssh_pki_generate_key(SSH_KEYTYPE_ED25519, nullptr, &key); +#else + int rc = ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key); +#endif + if (rc != SSH_OK || key == nullptr) + throw SSHException("Failed to generate SSH host key"); + + rc = ssh_pki_export_privkey_file(key, nullptr, nullptr, nullptr, path.c_str()); + ssh_key_free(key); + + if (rc != SSH_OK) + throw SSHException("Failed to write SSH host key to " + path); +} + + +} } // namespace Poco::SSH diff --git a/SSH/src/SSHServer.cpp b/SSH/src/SSHServer.cpp new file mode 100644 index 0000000000..06f1186f5b --- /dev/null +++ b/SSH/src/SSHServer.cpp @@ -0,0 +1,262 @@ +// +// SSHServer.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHServer +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "Poco/SSH/SSHServer.h" +#include "Poco/SSH/SSHSession.h" +#include "Poco/SSH/SSHException.h" +#include + +#if !defined(POCO_OS_FAMILY_WINDOWS) +#include +#include +#include +#include +#else +#include +#endif + + +namespace Poco { +namespace SSH { + + +SSHServer::SSHServer(const SSHServerConfig& config, SessionFactory factory): + _config(config), + _sessionFactory(std::move(factory)), + _sshBind(nullptr), + _listenFd(SSH_INVALID_FD), + _acceptAdapter(*this, &SSHServer::acceptLoop), + _threadPool(1, config.maxConnections), + _stopped(false), + _logger(Poco::Logger::get("Poco.SSH.SSHServer")) +{ +} + + +SSHServer::~SSHServer() +{ + stop(); +} + + +void SSHServer::start() +{ + _sshBind = ssh_bind_new(); + if (!_sshBind) + throw SSHException("Failed to create SSH bind"); + + int port = _config.port; + ssh_bind_options_set(_sshBind, SSH_BIND_OPTIONS_BINDADDR, _config.bindAddress.c_str()); + ssh_bind_options_set(_sshBind, SSH_BIND_OPTIONS_BINDPORT, &port); + ssh_bind_options_set(_sshBind, SSH_BIND_OPTIONS_HOSTKEY, _config.hostKeyPath.c_str()); + + if (ssh_bind_listen(_sshBind) < 0) + { + std::string err = ssh_get_error(_sshBind); + ssh_bind_free(_sshBind); + _sshBind = nullptr; + throw SSHConnectionException("SSH bind listen failed: " + err); + } + + _listenFd = ssh_bind_get_fd(_sshBind); + + _logger.information("SSH server listening on %s:%d", _config.bindAddress, (int)_config.port); + + _stopped = false; + _acceptThread.start(_acceptAdapter); +} + + +void SSHServer::stop() +{ + if (_stopped.exchange(true)) + return; + + _logger.information("SSH server stopping"); + + // 1. Close the listening socket to unblock poll()/accept() + ssh_socket_t fd = _listenFd.exchange(SSH_INVALID_FD); + if (fd != SSH_INVALID_FD) + { +#if defined(POCO_OS_FAMILY_WINDOWS) + ::closesocket(fd); +#else + ::close(fd); +#endif + // Prevent ssh_bind_free from double-closing the fd + ssh_bind_set_fd(_sshBind, SSH_INVALID_SOCKET); + } + + // 2. Wait for the accept loop thread to exit + if (_acceptThread.isRunning()) + { + _acceptThread.join(); + } + + // 3. Force-close active session sockets so session threads unblock + disconnectAllSessions(); + + // 4. Wait for all session threads to finish cleanup and unregister + { + bool hasSessions = false; + { + Poco::FastMutex::ScopedLock lock(_sessionsMutex); + hasSessions = !_activeSessions.empty(); + if (hasSessions) + _noSessions.reset(); + } + if (hasSessions) + _noSessions.wait(); + } + + // 5. Wait for thread pool threads to return + _threadPool.joinAll(); + + // 6. Free the bind (safe — no threads reference it) + if (_sshBind) + { + ssh_bind_free(_sshBind); + _sshBind = nullptr; + } + + _logger.information("SSH server stopped"); +} + + +void SSHServer::registerSession(ssh_session session) +{ + Poco::FastMutex::ScopedLock lock(_sessionsMutex); + _activeSessions.insert(session); +} + + +void SSHServer::unregisterSession(ssh_session session) +{ + Poco::FastMutex::ScopedLock lock(_sessionsMutex); + _activeSessions.erase(session); + if (_activeSessions.empty()) + _noSessions.set(); +} + + +void SSHServer::disconnectAllSessions() +{ + Poco::FastMutex::ScopedLock lock(_sessionsMutex); + if (!_activeSessions.empty()) + _logger.information("Disconnecting %z active SSH session(s)", _activeSessions.size()); + for (ssh_session session : _activeSessions) + { + const socket_t fd = ssh_get_fd(session); + if (fd != SSH_INVALID_SOCKET) + { +#if defined(POCO_OS_FAMILY_WINDOWS) + ::shutdown(fd, SD_BOTH); + ::closesocket(fd); +#else + ::shutdown(fd, SHUT_RDWR); +#endif + } + } +} + + +void SSHServer::acceptLoop() +{ + if (_logger.trace()) + _logger.trace("Accept loop started"); + + while (!_stopped) + { + // Poll the listening socket so we can check _stopped periodically + ssh_socket_t fd = _listenFd.load(); + if (fd == SSH_INVALID_FD) + break; + +#if defined(POCO_OS_FAMILY_WINDOWS) + WSAPOLLFD pfd{}; +#else + struct pollfd pfd{}; +#endif + pfd.fd = fd; + pfd.events = POLLIN; + +#if defined(POCO_OS_FAMILY_WINDOWS) + int pollRc = WSAPoll(&pfd, 1, 500); +#else + int pollRc = ::poll(&pfd, 1, 500); +#endif + if (pollRc == 0) // timeout + continue; + if (pollRc < 0) + { +#if !defined(POCO_OS_FAMILY_WINDOWS) + if (errno == EINTR) continue; // restart on signal +#endif + break; + } + if (_stopped) + break; + if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) + break; + + ssh_session session = ssh_new(); + if (!session) + { + if (!_stopped) + _logger.error("Failed to create SSH session"); + break; + } + + if (_logger.trace()) + _logger.trace("Waiting for connection"); + + int rc = ssh_bind_accept(_sshBind, session); + if (rc != SSH_OK) + { + ssh_free(session); + if (!_stopped) + _logger.error("SSH accept failed: %s", std::string(ssh_get_error(_sshBind))); + break; + } + + if (_logger.trace()) + _logger.trace("Connection accepted"); + + SSHSession* pSession = nullptr; + try + { + pSession = _sessionFactory(session, _config, *this); + _threadPool.start(*pSession); + } + catch (Poco::NoThreadAvailableException&) + { + _logger.warning("SSH connection rejected: max connections reached"); + delete pSession; // SSHSession destructor does not free ssh_session (run() does) + ssh_disconnect(session); + ssh_free(session); + } + catch (...) + { + delete pSession; + ssh_disconnect(session); + ssh_free(session); + } + } + + if (_logger.trace()) + _logger.trace("Accept loop ended"); +} + + +} } // namespace Poco::SSH diff --git a/SSH/src/SSHSession.cpp b/SSH/src/SSHSession.cpp new file mode 100644 index 0000000000..b75de006a5 --- /dev/null +++ b/SSH/src/SSHSession.cpp @@ -0,0 +1,340 @@ +// +// SSHSession.cpp +// +// Library: SSH +// Package: SSH +// Module: SSHSession +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +// Suppress deprecation warnings for libssh message-based auth API +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable: 4996) // deprecated +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + + +#include "Poco/SSH/SSHSession.h" +#include "Poco/SSH/SSHServer.h" +#include "Poco/SSH/SSHException.h" +#include "Poco/Path.h" +#include "Poco/File.h" +#include +#include +#include + + +namespace Poco { +namespace SSH { + + +SSHSession::SSHSession( + ssh_session session, + const SSHServerConfig& config, + SSHServer& server): + _session(session), + _config(config), + _server(server), + _logger(Poco::Logger::get("Poco.SSH.SSHSession")) +{ +} + + +SSHSession::~SSHSession() = default; + + +void SSHSession::run() +{ + _server.registerSession(_session); + + if (_logger.trace()) + _logger.trace("Session started"); + + try + { + if (_logger.trace()) + _logger.trace("Starting key exchange"); + + if (ssh_handle_key_exchange(_session) == SSH_OK) + { + if (_logger.trace()) + _logger.trace("Key exchange succeeded, authenticating"); + + if (authenticate()) + { + _logger.information("Authenticated user: %s", _authenticatedUser); + + ssh_channel channel = negotiateChannel(); + if (channel) + { + if (_logger.trace()) + _logger.trace("Channel open, starting session handler"); + + handleSession(channel, _authenticatedUser); + } + else + { + if (_logger.trace()) + _logger.trace("Channel negotiation failed"); + } + } + else + { + if (_logger.trace()) + _logger.trace("Authentication failed"); + } + } + else + { + if (_logger.trace()) + _logger.trace("Key exchange failed: %s", std::string(ssh_get_error(_session))); + } + } + catch (Poco::Exception& exc) + { + _logger.error("SSH session error: %s", exc.displayText()); + } + catch (std::exception& exc) + { + _logger.error("SSH session error: %s", std::string(exc.what())); + } + catch (...) + { + _logger.error("SSH session: unknown exception"); + } + + if (_logger.trace()) + _logger.trace("Session cleanup"); + + _server.unregisterSession(_session); + ssh_disconnect(_session); + ssh_free(_session); + _session = nullptr; + delete this; +} + + +bool SSHSession::authenticate() +{ + int authAttempts = 0; + int authMethods = 0; + if (_config.enablePasswordAuth && _config.passwordAuthenticator) + authMethods |= SSH_AUTH_METHOD_PASSWORD; + if (_config.enablePubkeyAuth && !_config.authorizedKeysDir.empty()) + authMethods |= SSH_AUTH_METHOD_PUBLICKEY; + + // Cap total messages received to bound loop work even when a misbehaving + // client sends non-AUTH messages (which otherwise do not increment + // authAttempts). 10x maxAuthAttempts is a generous multiplier that still + // denies indefinite loops. + const int maxMessages = _config.maxAuthAttempts * 10; + int messagesSeen = 0; + + while (authAttempts < _config.maxAuthAttempts && messagesSeen < maxMessages) + { + ++messagesSeen; + ssh_message msg = ssh_message_get(_session); + if (!msg) + return false; + + int type = ssh_message_type(msg); + int subtype = ssh_message_subtype(msg); + + if (type == SSH_REQUEST_AUTH) + { + if (subtype == SSH_AUTH_METHOD_PASSWORD && (_config.enablePasswordAuth && _config.passwordAuthenticator)) + { + const char* user = ssh_message_auth_user(msg); + const char* pass = ssh_message_auth_password(msg); + if (user && pass && _config.passwordAuthenticator(user, pass)) + { + _authenticatedUser = user; + ssh_message_auth_reply_success(msg, 0); + ssh_message_free(msg); + return true; + } + authAttempts++; + if (_logger.trace()) + _logger.trace("Password auth failed for user: %s (attempt %d)", std::string(user ? user : ""), authAttempts); + ssh_message_reply_default(msg); + } + else if (subtype == SSH_AUTH_METHOD_PUBLICKEY && _config.enablePubkeyAuth) + { + const char* user = ssh_message_auth_user(msg); + ssh_key clientKey = ssh_message_auth_pubkey(msg); + + if (user && clientKey) + { + enum ssh_publickey_state_e keyState = ssh_message_auth_publickey_state(msg); + if (keyState == SSH_PUBLICKEY_STATE_NONE) + { + if (checkAuthorizedKey(user, clientKey)) + ssh_message_auth_reply_pk_ok_simple(msg); + else + { + authAttempts++; + ssh_message_reply_default(msg); + } + } + else if (keyState == SSH_PUBLICKEY_STATE_VALID) + { + if (checkAuthorizedKey(user, clientKey)) + { + _authenticatedUser = user; + ssh_message_auth_reply_success(msg, 0); + ssh_message_free(msg); + return true; + } + authAttempts++; + ssh_message_reply_default(msg); + } + else + { + ssh_message_reply_default(msg); + } + } + else + { + ssh_message_reply_default(msg); + } + } + else + { + ssh_message_auth_set_methods(msg, authMethods); + ssh_message_reply_default(msg); + } + } + else + { + ssh_message_reply_default(msg); + } + + ssh_message_free(msg); + } + + return false; +} + + +ssh_channel SSHSession::negotiateChannel() +{ + ssh_channel channel = nullptr; + + for (int i = 0; i < 10; ++i) + { + ssh_message msg = ssh_message_get(_session); + if (!msg) return nullptr; + + if (ssh_message_type(msg) == SSH_REQUEST_CHANNEL_OPEN && + ssh_message_subtype(msg) == SSH_CHANNEL_SESSION) + { + channel = ssh_message_channel_request_open_reply_accept(msg); + ssh_message_free(msg); + break; + } + + ssh_message_reply_default(msg); + ssh_message_free(msg); + } + + if (!channel) return nullptr; + + for (int i = 0; i < 10; ++i) + { + ssh_message msg = ssh_message_get(_session); + if (!msg) + { + ssh_channel_free(channel); + return nullptr; + } + + int type = ssh_message_type(msg); + int subtype = ssh_message_subtype(msg); + + if (type == SSH_REQUEST_CHANNEL) + { + if (subtype == SSH_CHANNEL_REQUEST_SHELL || + subtype == SSH_CHANNEL_REQUEST_EXEC) + { + ssh_message_channel_request_reply_success(msg); + ssh_message_free(msg); + return channel; + } + else if (subtype == SSH_CHANNEL_REQUEST_PTY) + { + ssh_message_channel_request_reply_success(msg); + ssh_message_free(msg); + continue; + } + } + + ssh_message_reply_default(msg); + ssh_message_free(msg); + } + + ssh_channel_free(channel); + return nullptr; +} + + +bool SSHSession::checkAuthorizedKey(const std::string& username, ssh_key clientKey) +{ + if (_config.authorizedKeysDir.empty()) + return false; + + Poco::Path keyFilePath(_config.authorizedKeysDir); + keyFilePath.append(username); + std::string keyFile = keyFilePath.toString(); + + Poco::File f(keyFile); + if (!f.exists()) return false; + + std::ifstream ifs(keyFile); + std::string line; + while (std::getline(ifs, line)) + { + if (line.empty() || line[0] == '#') continue; + + std::istringstream iss(line); + std::string keytype, b64key; + iss >> keytype >> b64key; + if (keytype.empty() || b64key.empty()) continue; + + ssh_key candidate = nullptr; + int rc = ssh_pki_import_pubkey_base64( + b64key.c_str(), + ssh_key_type_from_name(keytype.c_str()), + &candidate); + + if (rc == SSH_OK && candidate) + { + bool match = (ssh_key_cmp(clientKey, candidate, SSH_KEY_CMP_PUBLIC) == 0); + ssh_key_free(candidate); + if (match) return true; + } + else if (candidate) + { + ssh_key_free(candidate); + } + } + + return false; +} + + +} } // namespace Poco::SSH + + +#if defined(_MSC_VER) +#pragma warning(pop) +#elif defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif diff --git a/SSH/testsuite/CMakeLists.txt b/SSH/testsuite/CMakeLists.txt new file mode 100644 index 0000000000..d64ef85b0f --- /dev/null +++ b/SSH/testsuite/CMakeLists.txt @@ -0,0 +1,18 @@ +file(GLOB SRCS_G "src/*.cpp") +POCO_SOURCES_AUTO(TEST_SRCS ${SRCS_G}) + +file(GLOB_RECURSE HDRS_G "src/*.h") +POCO_HEADERS_AUTO(TEST_SRCS ${HDRS_G}) + +add_executable(SSH-testrunner ${TEST_SRCS}) +set_target_properties(SSH-testrunner PROPERTIES DEBUG_POSTFIX "d") + +target_link_libraries(SSH-testrunner PUBLIC Poco::SSH Poco::Foundation CppUnit) +target_link_libraries(SSH-testrunner PRIVATE ssh) + +add_test( + NAME SSH + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMAND SSH-testrunner -all +) +set_tests_properties(SSH PROPERTIES ENVIRONMENT POCO_BASE=${PROJECT_SOURCE_DIR}) diff --git a/SSH/testsuite/Makefile b/SSH/testsuite/Makefile new file mode 100644 index 0000000000..4a88df5c47 --- /dev/null +++ b/SSH/testsuite/Makefile @@ -0,0 +1,22 @@ +# +# Makefile +# +# Makefile for Poco SSH testsuite +# + +include $(POCO_BASE)/build/rules/global + +objects = SSHTest SSHTestSuite Driver + +target = testrunner +target_version = 1 +target_libs = PocoSSH PocoFoundation CppUnit + +ifeq ($(OSNAME),Darwin) +SYSFLAGS += $(shell pkg-config --cflags libssh 2>/dev/null) +SYSLIBS += $(shell pkg-config --libs libssh 2>/dev/null || echo -lssh) +else +SYSLIBS += -lssh +endif + +include $(POCO_BASE)/build/rules/exec diff --git a/SSH/testsuite/src/Driver.cpp b/SSH/testsuite/src/Driver.cpp new file mode 100644 index 0000000000..de1fde4f9d --- /dev/null +++ b/SSH/testsuite/src/Driver.cpp @@ -0,0 +1,29 @@ +// +// Driver.cpp +// +// Console-based test driver for Poco SSH. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "CppUnit/TestRunner.h" +#include "SSHTestSuite.h" +#include +#include + + +int main(int ac, char **av) +{ + std::vector args; + args.reserve(ac); + for (int i = 0; i < ac; ++i) + args.emplace_back(av[i]); + CppUnit::TestRunner runner; + runner.addTest("SSHTestSuite", SSHTestSuite::suite()); + CppUnitPocoExceptionText (exc); + return runner.run(args, exc) ? 0 : 1; +} diff --git a/SSH/testsuite/src/SSHTest.cpp b/SSH/testsuite/src/SSHTest.cpp new file mode 100644 index 0000000000..ba26876628 --- /dev/null +++ b/SSH/testsuite/src/SSHTest.cpp @@ -0,0 +1,443 @@ +// +// SSHTest.cpp +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "SSHTest.h" +#include "CppUnit/TestCaller.h" +#include "CppUnit/TestSuite.h" +#include "Poco/SSH/SSHHostKeyManager.h" +#include "Poco/SSH/SSHServer.h" +#include "Poco/SSH/SSHSession.h" +#include "Poco/SSH/SSHClient.h" +#include "Poco/SSH/SSHChannelStream.h" +#include "Poco/SSH/SSHException.h" +#include "Poco/Path.h" +#include "Poco/File.h" +#include "Poco/TemporaryFile.h" +#include "Poco/Thread.h" +#include + + +using namespace Poco::SSH; + + +namespace +{ + /// Simple echo session for testing: reads lines and echoes them back. + class EchoSession: public SSHSession + { + public: + EchoSession(ssh_session s, const SSHServerConfig& cfg, SSHServer& srv): + SSHSession(s, cfg, srv) + { + } + + void handleSession(ssh_channel channel, const std::string& /*username*/) override + { + SSHChannelStream out(channel); + out << "HELLO\r\n" << std::flush; + + std::string line; + while (sshReadLine(channel, line)) + { + if (ssh_channel_is_eof(channel) || ssh_channel_is_closed(channel)) + break; + if (line == "quit") + { + out << "BYE\r\n" << std::flush; + break; + } + out << "ECHO:" << line << "\r\n" << std::flush; + } + } + }; + + // Restrict the client to classical key exchange algorithms. libssh 0.12+ + // advertises ML-KEM hybrid KEX (mlkem768x25519-sha256 etc.) by default, + // and the in-process client+server pair picks it. ML-KEM through OpenSSL + // 3.6.x has been observed to fail ("Failed to construct client init + // buffer") on Windows vcpkg builds. Pinning classical KEX keeps the test + // deterministic across libssh upgrades until upstream stabilizes. + void setClassicalKex(ssh_session session) + { + const char* kex = + "curve25519-sha256,curve25519-sha256@libssh.org," + "ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521," + "diffie-hellman-group16-sha512,diffie-hellman-group14-sha256"; + ssh_options_set(session, SSH_OPTIONS_KEY_EXCHANGE, kex); + } + + SSHServerConfig makeTestConfig(const std::string& keyDir, int port) + { + SSHServerConfig cfg; + cfg.bindAddress = "127.0.0.1"; + cfg.port = port; + cfg.hostKeyPath = SSHHostKeyManager::ensureHostKey(keyDir); + cfg.maxConnections = 2; + cfg.maxAuthAttempts = 3; + cfg.enablePasswordAuth = true; + cfg.enablePubkeyAuth = false; + cfg.passwordAuthenticator = [](const std::string& user, const std::string& pass) -> bool + { + return user == "testuser" && pass == "testpass"; + }; + return cfg; + } + + // Find a free port by binding to port 0 + int findFreePort() + { + ssh_bind bind = ssh_bind_new(); + int port = 0; + ssh_bind_options_set(bind, SSH_BIND_OPTIONS_BINDADDR, "127.0.0.1"); + ssh_bind_options_set(bind, SSH_BIND_OPTIONS_BINDPORT, &port); + // Can't easily get the port from ssh_bind, use a simpler approach + ssh_bind_free(bind); + // Use a high port that's unlikely to conflict + static int nextPort = 24000; + return nextPort++; + } +} + + +SSHTest::SSHTest(const std::string& name): + CppUnit::TestCase(name) +{ +} + + +SSHTest::~SSHTest() = default; + + +void SSHTest::setUp() +{ + _testDir = Poco::TemporaryFile::tempName(); + Poco::File(_testDir).createDirectories(); +} + + +void SSHTest::tearDown() +{ + try + { + Poco::File(_testDir).remove(true); + } + catch (...) + { + // Best-effort cleanup; log nothing in tests to avoid spurious output. + } +} + + +void SSHTest::testHostKeyGeneration() +{ + std::string keyPath = SSHHostKeyManager::ensureHostKey(_testDir); + Poco::File keyFile(keyPath); + assertTrue(keyFile.exists()); + assertTrue(keyFile.getSize() > 0); + + // Verify the key loads + ssh_key key = nullptr; + int rc = ssh_pki_import_privkey_file(keyPath.c_str(), nullptr, nullptr, nullptr, &key); + assertEqual(SSH_OK, rc); + assertTrue(key != nullptr); + ssh_key_free(key); +} + + +void SSHTest::testHostKeyReuse() +{ + std::string keyPath1 = SSHHostKeyManager::ensureHostKey(_testDir); + Poco::File keyFile(keyPath1); + auto modTime1 = keyFile.getLastModified(); + + Poco::Thread::sleep(100); + + std::string keyPath2 = SSHHostKeyManager::ensureHostKey(_testDir); + Poco::File keyFile2(keyPath2); + auto modTime2 = keyFile2.getLastModified(); + + assertEqual(keyPath1, keyPath2); + assertEqual(modTime1.epochMicroseconds(), modTime2.epochMicroseconds()); +} + + +void SSHTest::testServerStartStop() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + + server.start(); + Poco::Thread::sleep(100); + server.stop(); + // No crash = pass +} + + +void SSHTest::testClientConnect() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + { + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + assertTrue(client.isConnected()); + client.disconnect(); + } + + server.stop(); +} + + +void SSHTest::testPasswordAuth() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + try + { + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + bool ok = client.authenticatePassword("testuser", "testpass"); + assertTrue(ok); + client.disconnect(); + } + catch (...) + { + server.stop(); + throw; + } + + server.stop(); +} + + +void SSHTest::testPasswordAuthFail() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + try + { + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + bool ok = client.authenticatePassword("testuser", "wrongpass"); + assertTrue(!ok); + client.disconnect(); + } + catch (...) + { + server.stop(); + throw; + } + + server.stop(); +} + + +void SSHTest::testSessionHandling() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + try + { + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + assertTrue(client.authenticatePassword("testuser", "testpass")); + + ssh_channel channel = client.openShellChannel(); + assertTrue(channel != nullptr); + + // Read greeting + char buf[256]; + int n = ssh_channel_read(channel, buf, sizeof(buf), 0); + assertTrue(n > 0); + std::string greeting(buf, n); + assertTrue(greeting.find("HELLO") != std::string::npos); + + // Send quit + ssh_channel_write(channel, "quit\r\n", 6); + Poco::Thread::sleep(200); + + ssh_channel_send_eof(channel); + ssh_channel_close(channel); + ssh_channel_free(channel); + client.disconnect(); + } + catch (...) + { + server.stop(); + throw; + } + + server.stop(); +} + + +void SSHTest::testServerShutdownWithActiveSession() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + assertTrue(client.authenticatePassword("testuser", "testpass")); + + ssh_channel channel = client.openShellChannel(); + assertTrue(channel != nullptr); + + // Read greeting + char buf[256]; + ssh_channel_read(channel, buf, sizeof(buf), 0); + + // Stop server while client is connected — should not hang or crash + server.stop(); + + ssh_channel_free(channel); + client.disconnect(); +} + + +void SSHTest::testClientDisconnect() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + { + SSHClient client; + setClassicalKex(client.session()); + client.connect("127.0.0.1", port); + assertTrue(client.authenticatePassword("testuser", "testpass")); + // Client goes out of scope -- destructor disconnects + } + + Poco::Thread::sleep(200); + server.stop(); +} + + +void SSHTest::testMaxConnections() +{ + int port = findFreePort(); + SSHServerConfig cfg = makeTestConfig(_testDir, port); + cfg.maxConnections = 1; + + SSHServer server(cfg, [](ssh_session s, const SSHServerConfig& c, SSHServer& srv) -> SSHSession* + { + return new EchoSession(s, c, srv); + }); + server.start(); + + SSHClient client1; + setClassicalKex(client1.session()); + client1.connect("127.0.0.1", port); + assertTrue(client1.authenticatePassword("testuser", "testpass")); + + ssh_channel ch1 = client1.openShellChannel(); + assertTrue(ch1 != nullptr); + + // Read greeting to fully establish session + char buf[256]; + ssh_channel_read(ch1, buf, sizeof(buf), 0); + + // Second connection: server has maxConnections=1, thread pool is full. + // The TCP connection succeeds but no thread is available to handle + // the SSH session, so key exchange will not complete. + // Set a timeout so the client doesn't block forever. + SSHClient client2; + setClassicalKex(client2.session()); + long timeout2 = 3; + ssh_options_set(client2.session(), SSH_OPTIONS_TIMEOUT, &timeout2); + bool connected2 = false; + try + { + client2.connect("127.0.0.1", port); + connected2 = true; + // Key exchange should timeout since server can't handle the session + bool auth2 = client2.authenticatePassword("testuser", "testpass"); + assertTrue(!auth2); + } + catch (Poco::Exception&) + { + // Connection or auth timeout/failure is expected + } + if (connected2) client2.disconnect(); + + ssh_channel_free(ch1); + client1.disconnect(); + + server.stop(); +} + + +CppUnit::Test* SSHTest::suite() +{ + CppUnit::TestSuite* pSuite = new CppUnit::TestSuite("SSHTest"); + + CppUnit_addTest(pSuite, SSHTest, testHostKeyGeneration); + CppUnit_addTest(pSuite, SSHTest, testHostKeyReuse); + CppUnit_addTest(pSuite, SSHTest, testServerStartStop); + CppUnit_addTest(pSuite, SSHTest, testClientConnect); + CppUnit_addTest(pSuite, SSHTest, testPasswordAuth); + CppUnit_addTest(pSuite, SSHTest, testPasswordAuthFail); + CppUnit_addTest(pSuite, SSHTest, testSessionHandling); + CppUnit_addTest(pSuite, SSHTest, testServerShutdownWithActiveSession); + CppUnit_addTest(pSuite, SSHTest, testClientDisconnect); + CppUnit_addTest(pSuite, SSHTest, testMaxConnections); + + return pSuite; +} diff --git a/SSH/testsuite/src/SSHTest.h b/SSH/testsuite/src/SSHTest.h new file mode 100644 index 0000000000..d849abf2d0 --- /dev/null +++ b/SSH/testsuite/src/SSHTest.h @@ -0,0 +1,48 @@ +// +// SSHTest.h +// +// Definition of the SSHTest class. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSHTest_INCLUDED +#define SSHTest_INCLUDED + + +#include "Poco/SSH/SSH.h" +#include "CppUnit/TestCase.h" + + +class SSHTest: public CppUnit::TestCase +{ +public: + SSHTest(const std::string& name); + ~SSHTest(); + + void testHostKeyGeneration(); + void testHostKeyReuse(); + void testServerStartStop(); + void testClientConnect(); + void testPasswordAuth(); + void testPasswordAuthFail(); + void testSessionHandling(); + void testServerShutdownWithActiveSession(); + void testClientDisconnect(); + void testMaxConnections(); + + void setUp(); + void tearDown(); + + static CppUnit::Test* suite(); + +private: + std::string _testDir; +}; + + +#endif // SSHTest_INCLUDED diff --git a/SSH/testsuite/src/SSHTestSuite.cpp b/SSH/testsuite/src/SSHTestSuite.cpp new file mode 100644 index 0000000000..c0f43ba7df --- /dev/null +++ b/SSH/testsuite/src/SSHTestSuite.cpp @@ -0,0 +1,22 @@ +// +// SSHTestSuite.cpp +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#include "SSHTestSuite.h" +#include "SSHTest.h" + + +CppUnit::Test* SSHTestSuite::suite() +{ + CppUnit::TestSuite* pSuite = new CppUnit::TestSuite("SSHTestSuite"); + + pSuite->addTest(SSHTest::suite()); + + return pSuite; +} diff --git a/SSH/testsuite/src/SSHTestSuite.h b/SSH/testsuite/src/SSHTestSuite.h new file mode 100644 index 0000000000..507647259c --- /dev/null +++ b/SSH/testsuite/src/SSHTestSuite.h @@ -0,0 +1,27 @@ +// +// SSHTestSuite.h +// +// Definition of the SSHTestSuite class. +// +// Copyright (c) 2026, Aleph ONE Software Engineering LLC +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + + +#ifndef SSHTestSuite_INCLUDED +#define SSHTestSuite_INCLUDED + + +#include "CppUnit/TestSuite.h" + + +class SSHTestSuite +{ +public: + static CppUnit::Test* suite(); +}; + + +#endif // SSHTestSuite_INCLUDED diff --git a/components b/components index df4ba9bcd3..eb1203f599 100644 --- a/components +++ b/components @@ -6,6 +6,7 @@ JSON Util Net Crypto +SSH NetSSL_OpenSSL NetSSL_Win Data