From 67429eacd13be9b9d1c8cf99e317c6cb06ce1c3f Mon Sep 17 00:00:00 2001 From: khodowany Date: Fri, 4 Sep 2026 19:30:11 -0500 Subject: [PATCH] Initial public khodo snapshot --- .gitea/workflows/ci.yml | 105 ++ .gitignore | 20 + CMakeLists.txt | 73 + LICENSE | 21 + README.md | 43 + cmake/ligo_config.cmake.in | 6 + conanfile.py | 107 ++ include/ligo.hpp | 10 + include/ligo/config.hpp | 54 + include/ligo/detail/errors.hpp | 53 + include/ligo/detail/lifecycle.hpp | 242 ++++ include/ligo/detail/state.hpp | 70 + include/ligo/detail/transport.hpp | 117 ++ include/ligo/http.hpp | 86 ++ include/ligo/net.hpp | 71 + include/ligo/security.hpp | 46 + include/ligo/service.hpp | 49 + include/ligo/tcp.hpp | 128 ++ include/ligo/udp.hpp | 92 ++ modulus.json | 11 + modulus.py | 367 +++++ src/config.cpp | 414 ++++++ src/detail/errors.cpp | 14 + src/detail/lifecycle.cpp | 10 + src/detail/transport.cpp | 403 ++++++ src/http.cpp | 632 ++++++++ src/net.cpp | 510 +++++++ src/security.cpp | 238 +++ src/service.cpp | 131 ++ src/tcp.cpp | 791 ++++++++++ src/udp.cpp | 1079 ++++++++++++++ tests/CMakeLists.txt | 26 + tests/test.cpp | 2256 +++++++++++++++++++++++++++++ tests/test.hpp | 4 + 34 files changed, 8279 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100755 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/ligo_config.cmake.in create mode 100644 conanfile.py create mode 100755 include/ligo.hpp create mode 100755 include/ligo/config.hpp create mode 100644 include/ligo/detail/errors.hpp create mode 100644 include/ligo/detail/lifecycle.hpp create mode 100644 include/ligo/detail/state.hpp create mode 100644 include/ligo/detail/transport.hpp create mode 100755 include/ligo/http.hpp create mode 100644 include/ligo/net.hpp create mode 100644 include/ligo/security.hpp create mode 100755 include/ligo/service.hpp create mode 100755 include/ligo/tcp.hpp create mode 100755 include/ligo/udp.hpp create mode 100644 modulus.json create mode 100755 modulus.py create mode 100755 src/config.cpp create mode 100644 src/detail/errors.cpp create mode 100644 src/detail/lifecycle.cpp create mode 100644 src/detail/transport.cpp create mode 100755 src/http.cpp create mode 100644 src/net.cpp create mode 100644 src/security.cpp create mode 100755 src/service.cpp create mode 100755 src/tcp.cpp create mode 100755 src/udp.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test.cpp create mode 100644 tests/test.hpp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d99f356 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,105 @@ +name: publish + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + actions: write + +env: + CONAN_HOME: build/build_common/conan_cache + MODULUS_DIRECT: "1" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: | + if [ -r /etc/os-release ]; then + . /etc/os-release + fi + if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME}-security" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/ubuntu-arm64.sources + elif [ -f /etc/apt/sources.list ]; then + sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list + sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list + printf '%s\n' \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \ + > /etc/apt/sources.list.d/ubuntu-arm64.list + fi + fi + dpkg --add-architecture arm64 + apt-get update + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + g++-12 \ + g++-aarch64-linux-gnu \ + gcc-12 \ + gcc-aarch64-linux-gnu \ + git \ + libdrm-dev \ + libdrm-dev:arm64 \ + libegl-dev \ + libegl-dev:arm64 \ + libgbm-dev \ + libgbm-dev:arm64 \ + libgles-dev \ + libgles-dev:arm64 \ + libglvnd-dev \ + libglvnd-dev:arm64 \ + ninja-build \ + pkg-config \ + python3 \ + python3-venv + python3 -m venv build/build_common/ci_python + build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 + echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH" + - name: Restore Conan cache + uses: actions/cache@v4 + with: + path: build/build_common/conan_cache + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }} + restore-keys: conan-${{ runner.os }}- + - name: Configure native release + run: python3 modulus.py configure --target x86_64_gcc12_release + - name: Build native release + run: python3 modulus.py build --target x86_64_gcc12_release + - name: Test native release + run: python3 modulus.py test --target x86_64_gcc12_release + - name: Create and upload packages + if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} + env: + CONAN_USER: ${{ secrets.CONAN_USER }} + CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} + LIGO_CONAN_UPLOAD: "1" + MODULUS_ALLOW_PACKAGE_REPLACE: "1" + run: python3 modulus.py package --all --replace diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..f782367 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +build/ +.clangd +.clang-format +pyrightconfig.json + +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +package/CMakeCache.txt +package/CMakeFiles/ +package/metadata/ +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2d05bf7 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,73 @@ +cmake_minimum_required(VERSION 3.25) + +project(ligo VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +include(CTest) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(modulus CONFIG REQUIRED) + +add_library(ligo + src/config.cpp + src/detail/errors.cpp + src/detail/lifecycle.cpp + src/detail/transport.cpp + src/http.cpp + src/net.cpp + src/security.cpp + src/service.cpp + src/tcp.cpp + src/udp.cpp +) +target_include_directories(ligo PUBLIC + $ + $ +) +target_compile_features(ligo PUBLIC cxx_std_23) +target_link_libraries(ligo PUBLIC + modulus::modulus + modulus::boost_json + modulus::openssl +) +add_library(ligo::ligo ALIAS ligo) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +install(TARGETS ligo + EXPORT ligo-targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT ligo-targets + NAMESPACE ligo:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ligo +) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/ligo_config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/ligo-config.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ligo +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/ligo-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/ligo-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/ligo-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ligo +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f93603 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khodo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..739bfd2 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Ligo + +Ligo is a C++23 networking and transport library. It provides HTTP JSON calls, framed TCP JSON calls, reliable UDP datagrams, TCP/TLS handoff helpers, timeout handling, and `std::expected`-based error reporting. + +Ligo uses Modulus for shared build tooling, Boost.JSON, and OpenSSL integration. Product-specific routes and workflows belong in consuming applications. + +## Build + +Configure, build, and test the default target: + +```sh +python3 modulus.py configure +python3 modulus.py build +python3 modulus.py test +``` + +Release verification: + +```sh +python3 modulus.py configure --target x86_64_gcc12_release +python3 modulus.py build --target x86_64_gcc12_release +python3 modulus.py test --target x86_64_gcc12_release +``` + +Create packages: + +```sh +python3 modulus.py package --all +``` + +Configure/build/test can read public packages anonymously from the khodo Conan registry. Publishing requires credentials only on the upload step: + +```sh +CONAN_USER= \ +CONAN_TOKEN= \ +LIGO_CONAN_UPLOAD=1 \ +MODULUS_ALLOW_PACKAGE_REPLACE=1 \ +python3 modulus.py package --all --replace +``` + +## License + +Ligo is released under the MIT License. diff --git a/cmake/ligo_config.cmake.in b/cmake/ligo_config.cmake.in new file mode 100644 index 0000000..5a2f431 --- /dev/null +++ b/cmake/ligo_config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(modulus CONFIG REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/ligo-targets.cmake") diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..d7369ee --- /dev/null +++ b/conanfile.py @@ -0,0 +1,107 @@ +import conan +import conan.tools.build +import conan.tools.cmake + +class ligo_conan(conan.ConanFile): + name = "ligo" + version = "latest" + description = "C++23 networking and transport library" + license = "MIT" + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [ + True, + False, + ], + "fPIC": [ + True, + False, + ], + "with_tests": [ + True, + False, + ], + } + default_options = { + "shared": False, + "fPIC": True, + "with_tests": False, + } + exports = "modulus.json" + exports_sources = ( + "CMakeLists.txt", + "cmake/*.in", + "include/*.hpp", + "include/ligo/*.hpp", + "include/ligo/detail/*.hpp", + "src/*.cpp", + "src/detail/*.cpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", + ) + + def config_options(self): + if self.settings.os == "Windows": + del self.options.fPIC + + def configure(self): + if self.options.shared: + del self.options.fPIC + + def requirements(self): + self.requires( + "modulus/latest", + transitive_headers=True, + transitive_libs=True, + ) + + def generate(self): + deps = conan.tools.cmake.CMakeDeps(self) + deps.generate() + toolchain = conan.tools.cmake.CMakeToolchain(self) + if hasattr(toolchain, "user_presets_path"): + toolchain.user_presets_path = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) + toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) + toolchain.generate() + + def build(self): + cmake = conan.tools.cmake.CMake(self) + cmake.configure() + cmake.build() + if self.options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") + + def package_id(self): + self.info.options.rm_safe("with_tests") + for requirement in ( + "sqlite3", + "raylib", + "glfw", + "opengl", + "miniaudio", + ): + if requirement in self.info.requires.pkg_names: + self.info.requires.remove(requirement) + + def package(self): + cmake = conan.tools.cmake.CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.set_property( + "cmake_file_name", + "ligo", + ) + self.cpp_info.set_property( + "cmake_target_name", + "ligo::ligo", + ) + self.cpp_info.libs = ["ligo"] + self.cpp_info.requires = [ + "modulus::modulus", + "modulus::boost_json", + "modulus::openssl", + ] diff --git a/include/ligo.hpp b/include/ligo.hpp new file mode 100755 index 0000000..eaeeacc --- /dev/null +++ b/include/ligo.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/include/ligo/config.hpp b/include/ligo/config.hpp new file mode 100755 index 0000000..2efebfb --- /dev/null +++ b/include/ligo/config.hpp @@ -0,0 +1,54 @@ +#pragma once +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +namespace ligo { + +enum class protocol { + HTTP, + TCP, + UDP, +}; + +struct udp_reliability_config { + std::chrono::milliseconds resend_interval{50}; + std::uint32_t max_in_flight{64}; + std::chrono::milliseconds session_timeout{5000}; +}; + +struct http_config { + std::uint64_t max_body_size{16U * 1024U * 1024U}; +}; + +struct endpoint_config { + std::string name{}; + ligo::protocol protocol{ligo::protocol::TCP}; + std::string bind{}; + std::string connect{}; + std::chrono::milliseconds timeout{5000}; + ligo::security_config security{}; + ligo::http_config http{}; + ligo::udp_reliability_config udp{}; +}; + +auto validate(ligo::endpoint_config const& config) -> std::expected; +auto parse_endpoint_config(boost::json::value const& value) -> std::expected; +auto parse_endpoint_configs(boost::json::value const& value) -> std::expected, modulus::error>; +auto load_endpoint_configs(std::string const& path) -> std::expected, modulus::error>; +auto to_json(ligo::udp_reliability_config const& config) -> boost::json::value; +auto to_json(ligo::http_config const& config) -> boost::json::value; +auto to_json(ligo::security_config const& config) -> boost::json::value; +auto to_json(ligo::endpoint_config const& config) -> boost::json::value; +auto to_json(std::vector const& configs) -> boost::json::value; + +} diff --git a/include/ligo/detail/errors.hpp b/include/ligo/detail/errors.hpp new file mode 100644 index 0000000..caf17b7 --- /dev/null +++ b/include/ligo/detail/errors.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace ligo::detail { + + constexpr std::int32_t error_ok{0}; + constexpr std::int32_t error_invalid_argument{1}; + constexpr std::int32_t error_not_found{2}; + constexpr std::int32_t error_unavailable{3}; + constexpr std::int32_t error_timeout{4}; + constexpr std::int32_t error_internal{5}; + + auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; + auto fail_void(modulus::error error) -> std::expected; + + template + auto fail(modulus::error error) -> std::expected; + template + auto fail(std::expected const& result) -> std::expected; + template + auto fail(std::int32_t code, MESSAGE&& message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; + template + auto fail_void(std::int32_t code, MESSAGE&& message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; + +} + +template +auto ligo::detail::fail(modulus::error error) -> std::expected { + return modulus::fail(std::move(error)); +} + +template +auto ligo::detail::fail(std::expected const& result) -> std::expected { + assert(!result); + return ligo::detail::fail(result.error()); +} + +template +auto ligo::detail::fail(std::int32_t code, MESSAGE&& message, modulus::source_location location) -> std::expected { + return modulus::fail(ligo::detail::make_error(code, std::string{std::forward(message)}, location)); +} + +template +auto ligo::detail::fail_void(std::int32_t code, MESSAGE&& message, modulus::source_location location) -> std::expected { + return modulus::fail(ligo::detail::make_error(code, std::string{std::forward(message)}, location)); +} diff --git a/include/ligo/detail/lifecycle.hpp b/include/ligo/detail/lifecycle.hpp new file mode 100644 index 0000000..852a049 --- /dev/null +++ b/include/ligo/detail/lifecycle.hpp @@ -0,0 +1,242 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace ligo::detail { + +auto cancel_session_stream(boost::beast::tcp_stream& stream) -> void; +template +auto cancel_session_stream(boost::asio::ssl::stream& stream) -> void; + +template +struct session_registry { + static auto cancel_waiters(std::vector> const& live) -> void; + auto add(std::shared_ptr const& stream) -> void; + auto remove(std::shared_ptr const& stream) -> void; + auto cancel_all() -> void; + auto empty() const -> std::bool_t; + auto wait_empty(std::chrono::milliseconds timeout) -> boost::asio::awaitable>; + auto notify_waiters() -> void; + auto live_waiters_locked() -> std::vector>; + mutable std::mutex mutex{}; + std::set> sessions{}; + std::vector> waiters{}; +}; + +template +struct session_guard { + ~session_guard(); + auto release() -> void; + std::shared_ptr> registry{}; + std::shared_ptr stream{}; +}; + +template +auto stop_accepting_server(std::shared_ptr state) -> void; +template +auto stop_accepting_server_now(std::shared_ptr const& state) -> void; +template +auto async_stop_accepting_server(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable>; + +} + +template +auto ligo::detail::cancel_session_stream(boost::asio::ssl::stream& stream) -> void { + auto ignored = boost::system::error_code{}; + stream.lowest_layer().cancel(ignored); + stream.lowest_layer().close(ignored); +} + +template +auto ligo::detail::session_registry::cancel_waiters(std::vector> const& live) -> void { + for (auto& waiter : live) { + waiter->cancel(); + } +} + +template +auto ligo::detail::session_registry::add(std::shared_ptr const& stream) -> void { + auto lock = std::scoped_lock{mutex}; + sessions.insert(stream); +} + +template +auto ligo::detail::session_registry::remove(std::shared_ptr const& stream) -> void { + auto live = std::vector>{}; + { + auto lock = std::scoped_lock{mutex}; + sessions.erase(stream); + live = live_waiters_locked(); + } + cancel_waiters(live); +} + +template +auto ligo::detail::session_registry::cancel_all() -> void { + auto copy = std::vector>{}; + { + auto lock = std::scoped_lock{mutex}; + copy.assign(sessions.begin(), sessions.end()); + } + for (auto& stream : copy) { + boost::asio::post(stream->get_executor(), [stream] { + ligo::detail::cancel_session_stream(*stream); + }); + } +} + +template +auto ligo::detail::session_registry::empty() const -> std::bool_t { + auto lock = std::scoped_lock{mutex}; + return sessions.empty(); +} + +template +auto ligo::detail::session_registry::wait_empty(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto valid_timeout = ligo::detail::validate_positive_timeout(timeout, "shutdown timeout"); + if (!valid_timeout) { + co_return valid_timeout; + } + auto executor = co_await boost::asio::this_coro::executor; + if (empty()) { + co_return std::expected{}; + } + auto notify_timer = std::make_shared(executor); + auto deadline_timer = std::make_shared(executor); + auto timed_out = std::make_shared>(false); + { + auto lock = std::scoped_lock{mutex}; + waiters.push_back(notify_timer); + } + deadline_timer->expires_after(timeout); + deadline_timer->async_wait([notify_timer, timed_out](boost::system::error_code const& error) { + if (!error) { + timed_out->store(true, std::memory_order_relaxed); + notify_timer->cancel(); + } + }); + for (;;) { + if (empty()) { + deadline_timer->cancel(); + co_return std::expected{}; + } + if (timed_out->load(std::memory_order_relaxed)) { + co_return ligo::detail::fail_void(ligo::detail::error_timeout, "timed out waiting for sessions to drain"); + } + notify_timer->expires_at(std::chrono::steady_clock::time_point::max()); + auto [error] = co_await notify_timer->async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + if (error && error != boost::asio::error::operation_aborted) { + co_return ligo::detail::fail_void(ligo::detail::network_failure(error)); + } + } +} + +template +auto ligo::detail::session_registry::notify_waiters() -> void { + auto live = std::vector>{}; + { + auto lock = std::scoped_lock{mutex}; + live = live_waiters_locked(); + } + cancel_waiters(live); +} + +template +auto ligo::detail::session_registry::live_waiters_locked() -> std::vector> { + auto live = std::vector>{}; + auto retained = std::vector>{}; + for (auto& waiter : waiters) { + if (auto locked = waiter.lock()) { + live.push_back(locked); + retained.push_back(locked); + } + } + waiters = std::move(retained); + return live; +} + +template +ligo::detail::session_guard::~session_guard() { + if (registry && stream) { + registry->remove(stream); + } +} + +template +auto ligo::detail::session_guard::release() -> void { + if (registry && stream) { + registry->remove(stream); + stream.reset(); + registry.reset(); + } +} + +template +auto ligo::detail::stop_accepting_server(std::shared_ptr state) -> void { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + state->sessions->cancel_all(); + auto server_executor = state->strand; + boost::asio::post(server_executor, [state = std::move(state)] { + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + }); +} + +template +auto ligo::detail::stop_accepting_server_now(std::shared_ptr const& state) -> void { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + state->sessions->cancel_all(); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); +} + +template +auto ligo::detail::async_stop_accepting_server(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto valid_timeout = ligo::detail::validate_positive_timeout(timeout, "shutdown timeout"); + if (!valid_timeout) { + co_return valid_timeout; + } + auto should_stop = false; + { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + should_stop = !state->stopped.exchange(true); + if (should_stop) { + state->sessions->cancel_all(); + } + } + if (should_stop) { + co_await boost::asio::post(state->strand, boost::asio::use_awaitable); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + } + co_return co_await state->sessions->wait_empty(timeout); +} diff --git a/include/ligo/detail/state.hpp b/include/ligo/detail/state.hpp new file mode 100644 index 0000000..9d2acbc --- /dev/null +++ b/include/ligo/detail/state.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace ligo::detail { + +class state_handle { +public: + state_handle() = default; + state_handle(ligo::detail::state_handle const&) = delete; + state_handle(ligo::detail::state_handle&& other) noexcept; + ~state_handle() = default; + auto operator=(ligo::detail::state_handle const&) -> ligo::detail::state_handle& = delete; + auto operator=(ligo::detail::state_handle&& other) noexcept -> ligo::detail::state_handle&; + explicit operator std::bool_t() const noexcept; + template + explicit state_handle(std::shared_ptr state); + template + auto as() const -> std::shared_ptr; +private: + std::atomic> state_{}; +}; + +class unique_state { +public: + unique_state() noexcept = default; + unique_state(ligo::detail::unique_state const&) = delete; + unique_state(ligo::detail::unique_state&&) noexcept = default; + ~unique_state() = default; + auto operator=(ligo::detail::unique_state const&) -> ligo::detail::unique_state& = delete; + auto operator=(ligo::detail::unique_state&&) noexcept -> ligo::detail::unique_state& = default; + explicit operator std::bool_t() const noexcept; + template + explicit unique_state(std::unique_ptr state); + template + auto as() const noexcept -> STATE*; +private: + static auto ignore_state(void* state) -> void; + template + static auto delete_state(void* state) -> void; + std::unique_ptr> state_{nullptr, ignore_state}; +}; + +} + +template +ligo::detail::state_handle::state_handle(std::shared_ptr state) : state_{std::shared_ptr{std::move(state)}} {} + +template +auto ligo::detail::state_handle::as() const -> std::shared_ptr { + return std::static_pointer_cast(state_.load()); +} + +template +ligo::detail::unique_state::unique_state(std::unique_ptr state) : state_{state.release(), delete_state} {} + +template +auto ligo::detail::unique_state::as() const noexcept -> STATE* { + return static_cast(state_.get()); +} + +template +auto ligo::detail::unique_state::delete_state(void* state) -> void { + delete static_cast(state); +} diff --git a/include/ligo/detail/transport.hpp b/include/ligo/detail/transport.hpp new file mode 100644 index 0000000..2524cfd --- /dev/null +++ b/include/ligo/detail/transport.hpp @@ -0,0 +1,117 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace ligo::detail { + +struct endpoint_parts { + std::string host{"127.0.0.1"}; + std::uint16_t port{}; +}; + +auto status_name(std::int32_t code) -> std::string; +auto parse_status_code(std::string_view value) -> std::int32_t; +auto network_failure(boost::system::error_code const& error) -> modulus::error; +auto operation_failure(boost::system::error_code const& error, std::string timeout_message) -> modulus::error; +auto invalid_argument(std::string message) -> modulus::error; +auto unavailable(std::string message) -> modulus::error; +auto validate_positive_timeout(std::chrono::milliseconds timeout, std::string_view name) -> std::expected; +auto report_error(std::function const& on_error, modulus::error error) -> void; +auto report_error(std::function const& on_error, std::expected status) -> void; +auto response_envelope(std::expected result) -> boost::json::value; +auto serialize_response_envelope(std::expected result) -> std::string; +auto unwrap_response_envelope(boost::json::value value, std::string_view protocol_name) -> std::expected; +auto parse_port(std::string_view text) -> std::expected; +auto parse_endpoint_text(std::string_view text, std::uint16_t fallback_port) -> std::expected; +auto parse_client_endpoint_text(std::string_view text) -> std::expected; +auto parse_server_endpoint_text(std::string_view text) -> std::expected; +auto start_tcp_acceptor(boost::asio::ip::tcp::acceptor& acceptor, std::string const& host, std::uint16_t port) -> std::expected; +auto resolve_tcp_endpoints(boost::asio::any_io_executor executor, std::string const& host, std::uint16_t port, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable>; +auto call_handler(ligo::service_router const& router, ligo::request_context context, boost::json::value payload) -> boost::asio::awaitable>; +auto call_response_handler(ligo::service_router const& router, ligo::request_context context, boost::json::value payload) -> boost::asio::awaitable>; + +struct stream_deadline { + std::shared_ptr timer{}; + std::shared_ptr> timed_out{}; + std::shared_ptr> active{}; + std::shared_ptr cancellation{}; +}; + +auto start_stream_deadline(boost::beast::tcp_stream& stream, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline; +auto start_stream_deadline(boost::asio::ip::tcp::socket& socket, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline; + +template +auto start_stream_deadline(boost::asio::ssl::stream& stream, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline; + +auto cancel_stream_deadline(ligo::detail::stream_deadline& deadline) -> void; +auto deadline_token(ligo::detail::stream_deadline& deadline) -> decltype(boost::asio::bind_cancellation_slot(std::declval(), boost::asio::as_tuple(boost::asio::use_awaitable))); +auto operation_failure(boost::system::error_code const& error, ligo::detail::stream_deadline const& deadline, std::string timeout_message) -> modulus::error; + +template +auto async_tls_handshake(boost::asio::ssl::stream& stream, boost::asio::ssl::stream_base::handshake_type type, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable>; + +template +auto async_tls_shutdown(boost::asio::ssl::stream& stream, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable>; + +} + +template +auto ligo::detail::start_stream_deadline(boost::asio::ssl::stream& stream, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline { + auto deadline = ligo::detail::stream_deadline{.timer = std::make_shared(stream.get_executor()), .timed_out = std::make_shared>(false), .active = std::make_shared>(true), .cancellation = std::make_shared()}; + deadline.timer->expires_after(timeout); + deadline.timer->async_wait([timer = deadline.timer, timed_out = deadline.timed_out, active = deadline.active, cancellation = deadline.cancellation](boost::system::error_code const& error) { + if (!error && active->exchange(false, std::memory_order_acq_rel)) { + timed_out->store(true, std::memory_order_release); + cancellation->emit(boost::asio::cancellation_type::terminal); + } + }); + return deadline; +} + +template +auto ligo::detail::async_tls_handshake(boost::asio::ssl::stream& stream, boost::asio::ssl::stream_base::handshake_type type, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error] = co_await stream.async_handshake(type, ligo::detail::deadline_token(deadline)); + ligo::detail::cancel_stream_deadline(deadline); + if (error) { + co_return ligo::detail::fail_void(ligo::detail::operation_failure(error, deadline, std::move(timeout_message))); + } + co_return std::expected{}; +} + +template +auto ligo::detail::async_tls_shutdown(boost::asio::ssl::stream& stream, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error] = co_await stream.async_shutdown(ligo::detail::deadline_token(deadline)); + ligo::detail::cancel_stream_deadline(deadline); + if (error) { + co_return ligo::detail::fail_void(ligo::detail::operation_failure(error, deadline, std::move(timeout_message))); + } + co_return std::expected{}; +} diff --git a/include/ligo/http.hpp b/include/ligo/http.hpp new file mode 100755 index 0000000..78d1b27 --- /dev/null +++ b/include/ligo/http.hpp @@ -0,0 +1,86 @@ +#pragma once +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace ligo::http { + +enum class method { + GET, + POST, + PUT, + PATCH, + DELETE, +}; + +struct endpoint { + std::string host{"127.0.0.1"}; + std::uint16_t port{0}; +}; + +struct request { + ligo::http::method method{ligo::http::method::POST}; + std::string target{"/"}; + boost::json::value payload{}; +}; + +struct client_options { + std::chrono::milliseconds timeout{5000}; + std::uint64_t max_body_size{16U * 1024U * 1024U}; + ligo::security_config security{}; +}; + +struct server_options { + std::chrono::milliseconds timeout{5000}; + std::uint64_t max_body_size{16U * 1024U * 1024U}; + std::function on_error{}; + ligo::security_config security{}; +}; + +class client { +public: + explicit client(ligo::http::endpoint endpoint, ligo::http::client_options options = {}); + auto call(ligo::http::request const& request) const -> boost::asio::awaitable>; +private: + ligo::http::endpoint endpoint_{}; + ligo::http::client_options options_{}; +}; + +class server { +public: + server(boost::asio::io_context& io, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options = {}); + server(boost::asio::any_io_executor executor, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options = {}); + server(ligo::http::server const&) = delete; + server(ligo::http::server&&) noexcept = default; + ~server(); + auto operator=(ligo::http::server const&) -> ligo::http::server& = delete; + auto operator=(ligo::http::server&&) noexcept -> ligo::http::server&; + auto start() -> std::expected; + auto stop() -> void; + auto async_stop(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto selected_port() const -> std::uint16_t; +private: + ligo::detail::state_handle state_{}; +}; + +auto client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto client_options_from_config(ligo::endpoint_config const& config) -> ligo::http::client_options; +auto server_options_from_config(ligo::endpoint_config const& config) -> ligo::http::server_options; + +} diff --git a/include/ligo/net.hpp b/include/ligo/net.hpp new file mode 100644 index 0000000..0076569 --- /dev/null +++ b/include/ligo/net.hpp @@ -0,0 +1,71 @@ +#pragma once +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace ligo::net { + +class byte_stream_state; + +class byte_stream { +public: + byte_stream() = default; + byte_stream(ligo::net::byte_stream const&) = delete; + byte_stream(ligo::net::byte_stream&&) noexcept = default; + ~byte_stream(); + auto operator=(ligo::net::byte_stream const&) -> ligo::net::byte_stream& = delete; + auto operator=(ligo::net::byte_stream&&) noexcept -> ligo::net::byte_stream&; + static auto from_socket(ligo::tcp::socket_handle socket) -> std::expected; + static auto from_tls_socket(ligo::tcp::tls_socket socket) -> std::expected; + auto async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto async_shutdown(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto close(boost::system::error_code& error) -> void; + auto is_open() const -> std::bool_t; +private: + explicit byte_stream(std::shared_ptr state); + std::shared_ptr state_{}; +}; + +class listener { +public: + listener() = default; + listener(ligo::net::listener const&) = delete; + listener(ligo::net::listener&&) noexcept = default; + ~listener(); + auto operator=(ligo::net::listener const&) -> ligo::net::listener& = delete; + auto operator=(ligo::net::listener&&) noexcept -> ligo::net::listener&; + static auto bind(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint) -> std::expected; + auto selected_port() const -> std::uint16_t; + auto async_accept() -> boost::asio::awaitable>; + auto async_accept_tls_configured(ligo::security_config security, std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto async_accept_tls_handshaken(ligo::security_config security, std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto close() -> void; +private: + explicit listener(ligo::detail::state_handle state); + ligo::detail::state_handle state_{}; +}; + +auto connect_tcp(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}) -> boost::asio::awaitable>; +auto connect(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}) -> boost::asio::awaitable>; +auto connect(boost::asio::any_io_executor executor, ligo::endpoint_config const& config) -> boost::asio::awaitable>; +auto connect_tls_configured(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}, ligo::security_config security = {.mode = ligo::security_mode::TLS}) -> boost::asio::awaitable>; +auto connect_tls_handshaken(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}, ligo::security_config security = {.mode = ligo::security_mode::TLS}) -> boost::asio::awaitable>; + +} diff --git a/include/ligo/security.hpp b/include/ligo/security.hpp new file mode 100644 index 0000000..9c1ceb2 --- /dev/null +++ b/include/ligo/security.hpp @@ -0,0 +1,46 @@ +#pragma once +#include + +#include +#include +#include + +#include +#include +#include + +#include + +namespace ligo { + +enum class security_mode { + NONE, + TLS, + DTLS, +}; + +enum class verify_mode { + DEFAULTED, + NONE, + PEER, +}; + +struct security_config { + ligo::security_mode mode{ligo::security_mode::NONE}; + ligo::verify_mode verify{ligo::verify_mode::DEFAULTED}; + std::string peer_name{}; + std::string ca_file{}; + std::string certificate_chain_file{}; + std::string private_key_file{}; + std::shared_ptr tls_context{}; +}; + +auto make_tls_client_context(ligo::security_config const& config) -> std::expected, modulus::error>; +auto make_tls_server_context(ligo::security_config const& config) -> std::expected, modulus::error>; +auto apply_tls_client_verification(boost::asio::ssl::stream& stream, std::string const& host, ligo::security_config const& config) -> std::expected; +auto configure_tls_client_stream(boost::asio::ssl::stream& stream, std::string const& host, ligo::security_config const& config) -> std::expected; +auto validate_tls_server_config(std::string_view protocol, ligo::security_config const& config) -> std::expected; +auto validate_tls_server_context(std::shared_ptr const& context) -> std::expected; +auto unsupported_security(std::string_view protocol, ligo::security_mode mode) -> modulus::error; + +} diff --git a/include/ligo/service.hpp b/include/ligo/service.hpp new file mode 100755 index 0000000..c894e7d --- /dev/null +++ b/include/ligo/service.hpp @@ -0,0 +1,49 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace ligo { + +struct request_context { + std::string peer{}; + std::string method{}; + std::string route{}; +}; + +struct service_response { + std::string content_type{"application/json"}; + std::string body{}; +}; + +class service_router { +public: + service_router() = default; + service_router(ligo::service_router const& other); + service_router(ligo::service_router&& other); + ~service_router() = default; + auto operator=(ligo::service_router const& other) -> ligo::service_router&; + auto operator=(ligo::service_router&& other) -> ligo::service_router&; + auto add_route(std::string method, std::string route, std::function>(ligo::request_context, boost::json::value)> handler) -> void; + auto add_response_route(std::string method, std::string route, std::function>(ligo::request_context, boost::json::value)> handler) -> void; + auto call(ligo::request_context context, boost::json::value payload) const -> boost::asio::awaitable>; + auto call_response(ligo::request_context context, boost::json::value payload) const -> boost::asio::awaitable>; +private: + mutable std::mutex mutex_{}; + std::map, std::shared_ptr>(ligo::request_context, boost::json::value)> const>> handlers_{}; +}; + +auto json_response(boost::json::value payload) -> ligo::service_response; +auto text_response(std::string content_type, std::string body) -> ligo::service_response; + +} diff --git a/include/ligo/tcp.hpp b/include/ligo/tcp.hpp new file mode 100755 index 0000000..2b93b2f --- /dev/null +++ b/include/ligo/tcp.hpp @@ -0,0 +1,128 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace ligo::tcp { + +struct endpoint { + std::string host{"127.0.0.1"}; + std::uint16_t port{0}; +}; + +struct socket_handle { + socket_handle() = default; + socket_handle(ligo::tcp::socket_handle const&) = delete; + socket_handle(ligo::tcp::socket_handle&&) noexcept = default; + explicit socket_handle(boost::asio::ip::tcp::socket socket); + explicit socket_handle(std::shared_ptr socket); + auto operator=(ligo::tcp::socket_handle const&) -> ligo::tcp::socket_handle& = delete; + auto operator=(ligo::tcp::socket_handle&&) noexcept -> ligo::tcp::socket_handle& = default; + auto socket() -> std::expected, modulus::error>; + auto socket() const -> std::expected, modulus::error>; + auto is_open() const -> std::bool_t; + auto close(boost::system::error_code& error) -> void; + auto release_socket() -> std::expected; +private: + std::shared_ptr socket_{}; +}; + +struct tls_socket { + struct released_stream { + released_stream(std::shared_ptr context, boost::asio::ssl::stream stream); + std::shared_ptr context{}; + boost::asio::ssl::stream stream; + }; + tls_socket() = default; + tls_socket(ligo::tcp::tls_socket const&) = delete; + tls_socket(ligo::tcp::tls_socket&&) noexcept = default; + tls_socket(std::shared_ptr context, boost::asio::ip::tcp::socket socket); + auto operator=(ligo::tcp::tls_socket const&) -> ligo::tcp::tls_socket& = delete; + auto operator=(ligo::tcp::tls_socket&&) noexcept -> ligo::tcp::tls_socket& = default; + auto stream() -> std::expected>, modulus::error>; + auto stream() const -> std::expected const>, modulus::error>; + auto context() -> std::expected, modulus::error>; + auto context() const -> std::expected, modulus::error>; + auto is_open() const -> std::bool_t; + auto close(boost::system::error_code& error) -> void; + auto release_stream() -> std::expected; +private: + std::shared_ptr context_{}; + std::unique_ptr> stream_{}; +}; + +struct request { + std::string route{}; + boost::json::value payload{}; +}; + +struct client_options { + std::chrono::milliseconds timeout{5000}; + ligo::security_config security{}; +}; + +struct server_options { + std::chrono::milliseconds timeout{5000}; + std::function on_error{}; + ligo::security_config security{}; +}; + +class client { +public: + explicit client(ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}); + auto call(ligo::tcp::request const& request) const -> boost::asio::awaitable>; +private: + ligo::tcp::endpoint endpoint_{}; + ligo::tcp::client_options options_{}; +}; + +class server { +public: + server(boost::asio::io_context& io, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options = {}); + server(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options = {}); + server(ligo::tcp::server const&) = delete; + server(ligo::tcp::server&&) noexcept = default; + ~server(); + auto operator=(ligo::tcp::server const&) -> ligo::tcp::server& = delete; + auto operator=(ligo::tcp::server&&) noexcept -> ligo::tcp::server&; + auto start() -> std::expected; + auto stop() -> void; + auto async_stop(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; + auto selected_port() const -> std::uint16_t; +private: + ligo::detail::state_handle state_{}; +}; + +auto client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto client_options_from_config(ligo::endpoint_config const& config) -> ligo::tcp::client_options; +auto server_options_from_config(ligo::endpoint_config const& config) -> ligo::tcp::server_options; +auto connect(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}) -> boost::asio::awaitable>; +auto connect(boost::asio::any_io_executor executor, ligo::endpoint_config const& config) -> boost::asio::awaitable>; +auto connect_tls_configured(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}, ligo::security_config security = {.mode = ligo::security_mode::TLS}) -> boost::asio::awaitable>; +auto connect_tls_handshaken(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options = {}, ligo::security_config security = {.mode = ligo::security_mode::TLS}) -> boost::asio::awaitable>; + +inline tls_socket::released_stream::released_stream(std::shared_ptr context, boost::asio::ssl::stream stream) : context{std::move(context)}, stream{std::move(stream)} {} + +} diff --git a/include/ligo/udp.hpp b/include/ligo/udp.hpp new file mode 100755 index 0000000..053741e --- /dev/null +++ b/include/ligo/udp.hpp @@ -0,0 +1,92 @@ +#pragma once +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace ligo::udp { + +struct endpoint { + boost::asio::ip::udp::endpoint local{boost::asio::ip::udp::v4(), 0}; + boost::asio::ip::udp::endpoint peer{boost::asio::ip::address_v4::loopback(), 0}; +}; + +struct message { + boost::json::value payload{}; +}; + +struct client_options { + ligo::udp_reliability_config reliability{}; + ligo::security_config security{}; + std::uint64_t session_id{1}; +}; + +struct server_options { + ligo::udp_reliability_config reliability{}; + ligo::security_config security{}; + std::uint64_t session_id{1}; +}; + +class client { +public: + client(ligo::udp::client const&) = delete; + client(ligo::udp::client&&) noexcept = default; + ~client(); + auto operator=(ligo::udp::client const&) -> ligo::udp::client& = delete; + auto operator=(ligo::udp::client&&) noexcept -> ligo::udp::client&; + static auto open(boost::asio::any_io_executor executor, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options = {}) -> std::expected; + static auto open(boost::asio::io_context& io, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options = {}) -> std::expected; + static auto bind(boost::asio::any_io_executor executor, ligo::udp::endpoint endpoint, ligo::udp::client_options options = {}) -> std::expected; + static auto bind(boost::asio::io_context& io, ligo::udp::endpoint endpoint, ligo::udp::client_options options = {}) -> std::expected; + static auto adopt(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options = {}) -> std::expected; + auto send(boost::json::value payload) -> boost::asio::awaitable>; + auto send(ligo::udp::message message) -> boost::asio::awaitable>; + auto receive() -> boost::asio::awaitable>; + auto local_endpoint() const -> std::expected; + auto close() -> void; + auto async_close(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; +private: + explicit client(ligo::detail::state_handle state); + ligo::detail::state_handle state_{}; +}; + +class server { +public: + server(ligo::udp::server const&) = delete; + server(ligo::udp::server&&) noexcept = default; + ~server(); + auto operator=(ligo::udp::server const&) -> ligo::udp::server& = delete; + auto operator=(ligo::udp::server&&) noexcept -> ligo::udp::server&; + static auto bind(boost::asio::any_io_executor executor, ligo::udp::endpoint endpoint, ligo::udp::server_options options = {}) -> std::expected; + static auto bind(boost::asio::io_context& io, ligo::udp::endpoint endpoint, ligo::udp::server_options options = {}) -> std::expected; + static auto adopt(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp::server_options options = {}) -> std::expected; + auto send(boost::json::value payload) -> boost::asio::awaitable>; + auto send(ligo::udp::message message) -> boost::asio::awaitable>; + auto receive() -> boost::asio::awaitable>; + auto local_endpoint() const -> std::expected; + auto close() -> void; + auto async_close(std::chrono::milliseconds timeout = std::chrono::milliseconds{5000}) -> boost::asio::awaitable>; +private: + explicit server(ligo::detail::state_handle state); + ligo::detail::state_handle state_{}; +}; + +auto client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected; +auto client_options_from_config(ligo::endpoint_config const& config) -> ligo::udp::client_options; +auto server_options_from_config(ligo::endpoint_config const& config) -> ligo::udp::server_options; + +} diff --git a/modulus.json b/modulus.json new file mode 100644 index 0000000..6ffb359 --- /dev/null +++ b/modulus.json @@ -0,0 +1,11 @@ +{ + "package": { + "name": "ligo" + }, + "tooling": { + "remote": { + "name": "khodo", + "url": "https://git.thyme-labs.com/api/packages/khodo/conan" + } + } +} diff --git a/modulus.py b/modulus.py new file mode 100755 index 0000000..836b640 --- /dev/null +++ b/modulus.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import shlex +import subprocess +import sys + +PACKAGE_REF = "modulus/latest" +CONAN_VERSION = "2.26.2" +FETCH_IMAGE = "modulus-fetch:dev" +COMMANDS = ( + "configure", + "build", + "test", + "package", + "lsp", +) +COMMON_DIR = pathlib.Path("build") / "build_common" +TOOLS_DIR = pathlib.Path("build") / "build_tools" +RUNTIME_DIR = COMMON_DIR / "modulus" +CONAN_HOME_DIR = COMMON_DIR / "conan_cache" +DEFAULT_REMOTE_NAME = "khodo" +DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan" +DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image" + +def repo_root(): + root = pathlib.Path(__file__).resolve().parent + if not (root / "modulus.json").is_file(): + raise SystemExit("modulus.py must be run from a Modulus-managed repository root") + return root + +def read_json(path): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"failed to read {path}: {error}") from error + if not isinstance(data, dict): + raise SystemExit(f"{path} must contain a JSON object") + return data + +def remote_config(root): + config = read_json(root / "modulus.json") + remote = config.get( + "tooling", + {}, + ).get("remote", {}) + if not isinstance(remote, dict): + remote = {} + return ( + str(remote.get("name") or DEFAULT_REMOTE_NAME), + str(remote.get("url") or DEFAULT_REMOTE_URL), + ) + +def runtime_ready(root): + return ( + (root / TOOLS_DIR / "configure.py").is_file() + and (root / TOOLS_DIR / "lsp.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "export.py").is_file() + ) + +def optional_credentials(): + user = os.environ.get("CONAN_USER") + token = os.environ.get("CONAN_TOKEN") + if bool(user) != bool(token): + raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset") + return user, token + +def env_flag(name): + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + +def docker_marker(root): + config = read_json(root / "modulus.json") + marker = config.get( + "tooling", + {}, + ).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER) + return pathlib.Path(str(marker)) + +def direct_mode(root): + return env_flag("MODULUS_DIRECT") or docker_marker(root).exists() + +def run(command, input_text=None, capture=False, display_command=None, env=None, check=True): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + completed = subprocess.run( + [str(part) for part in command], + input=input_text, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + text=True, + check=False, + env=env, + ) + if check and completed.returncode != 0: + if capture and completed.stdout: + print(completed.stdout, end="") + if capture and completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + raise SystemExit(completed.returncode) + return completed + +def fetch_dockerfile(): + return f"""FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ca-certificates \\ + python3 \\ + python3-venv \\ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/modulus-python + +ENV PATH=/opt/modulus-python/bin:$PATH + +RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION} + +WORKDIR /workspace +""" + +def ensure_fetch_image(): + inspected = subprocess.run( + [ + "docker", + "image", + "inspect", + FETCH_IMAGE, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspected.returncode == 0: + return + run( + [ + "docker", + "build", + "--tag", + FETCH_IMAGE, + "-", + ], + input_text=fetch_dockerfile(), + ) + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def docker_workspace_args(root): + if in_container(): + container = os.environ.get("HOSTNAME", "").strip() + if not container: + raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container") + return [ + "--volumes-from", + container, + ] + return [ + "--volume", + f"{root}:{root}", + ] + +def fetch_env(root): + env = os.environ.copy() + env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) + return env + +def run_fetch(root, command, capture=False, display_command=None, check=True): + if direct_mode(root): + return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + ensure_fetch_image() + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + "--env", + "CONAN_HOME", + "--env", + "CONAN_USER", + "--env", + "CONAN_TOKEN", + *docker_workspace_args(root), + "--workdir", + str(root), + FETCH_IMAGE, + *command, + ] + return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + +def configure_remote(root, remote_name, remote_url): + user, token = optional_credentials() + run_fetch( + root, + [ + "conan", + "remote", + "add", + "-f", + remote_name, + remote_url, + ], + ) + if not user and not token: + return + run_fetch( + root, + [ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + token, + ], + display_command=[ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + "", + ], + ) + +def package_refs(data): + refs = [] + for location_data in data.values(): + if not isinstance(location_data, dict): + continue + for ref, ref_data in location_data.items(): + if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict): + continue + revisions = ref_data.get( + "revisions", + {}, + ) + if not isinstance(revisions, dict): + continue + for recipe_revision, revision_data in revisions.items(): + if not isinstance(revision_data, dict): + continue + packages = revision_data.get( + "packages", + {}, + ) + if not isinstance(packages, dict): + continue + for package_id, package_data in packages.items(): + package_revisions = package_data.get( + "revisions", + {}, + )if isinstance(package_data, dict) else {} + if isinstance(package_revisions, dict) and package_revisions: + for package_revision in package_revisions: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}") + else: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") + return sorted(refs) + +def remove_cached_modulus(root): + run_fetch( + root, + [ + "conan", + "remove", + PACKAGE_REF + ":*", + "-c", + ], + check=False, + ) + +def download_modulus(root, remote_name): + remove_cached_modulus(root) + run_fetch( + root, + [ + "conan", + "download", + PACKAGE_REF + ":*", + "-r", + remote_name, + ], + ) + completed = run_fetch( + root, + [ + "conan", + "list", + PACKAGE_REF + ":*", + "-c", + "-f", + "json", + ], + capture=True, + ) + refs = package_refs(json.loads(completed.stdout)) + if not refs: + raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") + package_ref = refs[-1] + completed = run_fetch( + root, + [ + "conan", + "cache", + "path", + package_ref, + ], + capture=True, + ) + package_root = pathlib.Path(completed.stdout.strip()) + if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): + raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}") + return package_root + +def materialize_runtime(root): + remote_name, remote_url = remote_config(root) + configure_remote(root, remote_name, remote_url) + package_root = download_modulus(root, remote_name) + sys.path.insert(0, str(package_root)) + import modulus.export + modulus.export.sync_project(root, package_root) + +def ensure_runtime(root): + if runtime_ready(root): + return + materialize_runtime(root) + +def delegate(command, argv): + root = repo_root() + ensure_runtime(root) + sys.path.insert(0, str(root / RUNTIME_DIR)) + import modulus.cli + previous_argv = sys.argv + previous_cwd = pathlib.Path.cwd() + sys.argv = [ + str(root / "modulus.py"), + *argv, + ] + os.chdir(root) + try: + return modulus.cli.main(command) + finally: + os.chdir(previous_cwd) + sys.argv = previous_argv + +def main(argv): + if not argv or argv[0] in ("-h", "--help"): + print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...") + return 0 if argv else 2 + command = argv[0] + if command not in COMMANDS: + raise SystemExit(f"unknown Modulus command: {command}") + return delegate(command, argv[1:]) + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/config.cpp b/src/config.cpp new file mode 100755 index 0000000..98c92cc --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,414 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +namespace { + +constexpr auto json_options = modulus::json_config::options{ + .category = "ligo", + .invalid_argument_code = ligo::detail::error_invalid_argument, + .not_found_code = ligo::detail::error_not_found +}; + +auto protocol_name(ligo::protocol value) -> std::string_view { + switch (value) { + case ligo::protocol::HTTP: + return "http"; + case ligo::protocol::TCP: + return "tcp"; + case ligo::protocol::UDP: + return "udp"; + } + return "tcp"; +} + +auto security_mode_name(ligo::security_mode value) -> std::string_view { + switch (value) { + case ligo::security_mode::NONE: + return "none"; + case ligo::security_mode::TLS: + return "tls"; + case ligo::security_mode::DTLS: + return "dtls"; + } + return "none"; +} + +auto verify_mode_name(ligo::verify_mode value) -> std::string_view { + switch (value) { + case ligo::verify_mode::DEFAULTED: + return "defaulted"; + case ligo::verify_mode::NONE: + return "none"; + case ligo::verify_mode::PEER: + return "peer"; + } + return "defaulted"; +} + +auto parse_security_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "endpoint security", json_options); + if (!object) { + return ligo::detail::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "mode", + "verify", + "peer_name", + "ca_file", + "certificate_chain_file", + "private_key_file", + }, "endpoint security", json_options); + if (!unknown) { + return ligo::detail::fail(unknown.error()); + } + auto config = ligo::security_config{}; + auto mode = modulus::json_config::optional_enum_field(**object, "mode", config.mode, { + { + .name = "none", + .value = ligo::security_mode::NONE, + }, + { + .name = "tls", + .value = ligo::security_mode::TLS, + }, + { + .name = "dtls", + .value = ligo::security_mode::DTLS, + }, + }, "endpoint security mode must be none, tls, or dtls", json_options); + if (!mode) { + return ligo::detail::fail(mode.error()); + } + auto verify = modulus::json_config::optional_enum_field(**object, "verify", config.verify, { + { + .name = "defaulted", + .value = ligo::verify_mode::DEFAULTED, + }, + { + .name = "none", + .value = ligo::verify_mode::NONE, + }, + { + .name = "peer", + .value = ligo::verify_mode::PEER, + }, + }, "endpoint security verify must be defaulted, none, or peer", json_options); + if (!verify) { + return ligo::detail::fail(verify.error()); + } + auto peer_name = modulus::json_config::optional_string_field(**object, "peer_name", config.peer_name, json_options); + auto ca_file = modulus::json_config::optional_string_field(**object, "ca_file", config.ca_file, json_options); + auto certificate_chain_file = modulus::json_config::optional_string_field(**object, "certificate_chain_file", config.certificate_chain_file, json_options); + auto private_key_file = modulus::json_config::optional_string_field(**object, "private_key_file", config.private_key_file, json_options); + if (!peer_name) { + return ligo::detail::fail(peer_name.error()); + } + if (!ca_file) { + return ligo::detail::fail(ca_file.error()); + } + if (!certificate_chain_file) { + return ligo::detail::fail(certificate_chain_file.error()); + } + if (!private_key_file) { + return ligo::detail::fail(private_key_file.error()); + } + config.mode = *mode; + config.verify = *verify; + config.peer_name = std::move(*peer_name); + config.ca_file = std::move(*ca_file); + config.certificate_chain_file = std::move(*certificate_chain_file); + config.private_key_file = std::move(*private_key_file); + return modulus::success(std::move(config)); +} + +} + +auto ligo::validate(ligo::endpoint_config const& config) -> std::expected { + if (config.name.empty()) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "endpoint name is required"); + } + if (config.timeout.count() <= 0) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "timeout_ms must be positive"); + } + if (config.http.max_body_size == 0) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "max_body_size must be positive"); + } + if (config.udp.resend_interval.count() <= 0) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "resend_interval_ms must be positive"); + } + if (config.udp.max_in_flight == 0) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "max_in_flight must be positive"); + } + if (config.udp.session_timeout.count() <= 0) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "session_timeout_ms must be positive"); + } + return {}; +} + +auto ligo::parse_endpoint_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "endpoint", json_options); + if (!object) { + return ligo::detail::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "name", + "protocol", + "bind", + "connect", + "timeout_ms", + "security", + "http", + "udp", + }, "endpoint", json_options); + if (!unknown) { + return ligo::detail::fail(unknown.error()); + } + auto config = ligo::endpoint_config{}; + auto name = modulus::json_config::required_string_field(**object, "name", "endpoint", json_options); + if (!name) { + return ligo::detail::fail(name.error()); + } + auto protocol = modulus::json_config::optional_enum_field(**object, "protocol", config.protocol, { + { + .name = "http", + .value = ligo::protocol::HTTP, + }, + { + .name = "tcp", + .value = ligo::protocol::TCP, + }, + { + .name = "udp", + .value = ligo::protocol::UDP, + }, + }, "endpoint protocol must be http, tcp, or udp", json_options); + if (!protocol) { + return ligo::detail::fail(protocol.error()); + } + auto bind = modulus::json_config::optional_string_field(**object, "bind", config.bind, json_options); + auto connect = modulus::json_config::optional_string_field(**object, "connect", config.connect, json_options); + auto timeout = modulus::json_config::positive_milliseconds_field(**object, "timeout_ms", config.timeout, static_cast(std::numeric_limits::max()), json_options); + if (!bind) { + return ligo::detail::fail(bind.error()); + } + if (!connect) { + return ligo::detail::fail(connect.error()); + } + if (!timeout) { + return ligo::detail::fail(timeout.error()); + } + config.name = std::move(*name); + config.protocol = *protocol; + config.bind = std::move(*bind); + config.connect = std::move(*connect); + config.timeout = *timeout; + if (auto found = (*object)->find("security"); found != (*object)->end()) { + auto parsed_security = parse_security_config(found->value()); + if (!parsed_security) { + return ligo::detail::fail(parsed_security.error()); + } + config.security = std::move(*parsed_security); + } + if (auto found = (*object)->find("http"); found != (*object)->end()) { + auto http = modulus::json_config::require_object(found->value(), "endpoint http", json_options); + if (!http) { + return ligo::detail::fail(http.error()); + } + auto http_unknown = modulus::json_config::reject_unknown_fields(**http, {"max_body_size"}, "endpoint http", json_options); + if (!http_unknown) { + return ligo::detail::fail(http_unknown.error()); + } + auto max_body_size = modulus::json_config::positive_uint64_field(**http, "max_body_size", config.http.max_body_size, std::numeric_limits::max(), json_options); + if (!max_body_size) { + return ligo::detail::fail(max_body_size.error()); + } + config.http.max_body_size = *max_body_size; + } + if (auto found = (*object)->find("udp"); found != (*object)->end()) { + auto udp = modulus::json_config::require_object(found->value(), "endpoint udp", json_options); + if (!udp) { + return ligo::detail::fail(udp.error()); + } + auto udp_unknown = modulus::json_config::reject_unknown_fields(**udp, { + "resend_interval_ms", + "max_in_flight", + "session_timeout_ms", + }, "endpoint udp", json_options); + if (!udp_unknown) { + return ligo::detail::fail(udp_unknown.error()); + } + auto resend_interval = modulus::json_config::positive_milliseconds_field(**udp, "resend_interval_ms", config.udp.resend_interval, static_cast(std::numeric_limits::max()), json_options); + if (!resend_interval) { + return ligo::detail::fail(resend_interval.error()); + } + auto max_in_flight = modulus::json_config::positive_uint64_field(**udp, "max_in_flight", config.udp.max_in_flight, std::numeric_limits::max(), json_options); + if (!max_in_flight) { + return ligo::detail::fail(max_in_flight.error()); + } + auto session_timeout = modulus::json_config::positive_milliseconds_field(**udp, "session_timeout_ms", config.udp.session_timeout, static_cast(std::numeric_limits::max()), json_options); + if (!session_timeout) { + return ligo::detail::fail(session_timeout.error()); + } + config.udp.resend_interval = *resend_interval; + config.udp.max_in_flight = static_cast(*max_in_flight); + config.udp.session_timeout = *session_timeout; + } + auto valid = ligo::validate(config); + if (!valid) { + return ligo::detail::fail(valid.error()); + } + return modulus::success(std::move(config)); +} + +auto ligo::parse_endpoint_configs(boost::json::value const& value) -> std::expected, modulus::error> { + auto object = modulus::json_config::require_object(value, "config root", json_options); + if (!object) { + return ligo::detail::fail>(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, {"endpoints"}, "config root", json_options); + if (!unknown) { + return ligo::detail::fail>(unknown.error()); + } + auto found = (*object)->find("endpoints"); + if (found == (*object)->end() || !found->value().is_array()) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "config endpoints array is required"); + } + auto configs = std::vector{}; + for (auto const& endpoint_value : found->value().as_array()) { + auto parsed = ligo::parse_endpoint_config(endpoint_value); + if (!parsed) { + return ligo::detail::fail>(parsed.error()); + } + configs.push_back(std::move(*parsed)); + } + return modulus::success(std::move(configs)); +} + +auto ligo::load_endpoint_configs(std::string const& path) -> std::expected, modulus::error> { + auto value = modulus::json_config::load_value(path, json_options); + if (!value) { + return ligo::detail::fail>(value.error()); + } + return ligo::parse_endpoint_configs(*value); +} + +auto ligo::to_json(ligo::udp_reliability_config const& config) -> boost::json::value { + return boost::json::object{ + { + "resend_interval_ms", + config.resend_interval.count(), + }, + { + "max_in_flight", + config.max_in_flight, + }, + { + "session_timeout_ms", + config.session_timeout.count(), + }, + }; +} + +auto ligo::to_json(ligo::http_config const& config) -> boost::json::value { + return boost::json::object{ + { + "max_body_size", + config.max_body_size, + }, + }; +} + +auto ligo::to_json(ligo::security_config const& config) -> boost::json::value { + return boost::json::object{ + { + "mode", + std::string{security_mode_name(config.mode)}, + }, + { + "verify", + std::string{verify_mode_name(config.verify)}, + }, + { + "peer_name", + config.peer_name, + }, + { + "ca_file", + config.ca_file, + }, + { + "certificate_chain_file", + config.certificate_chain_file, + }, + { + "private_key_file", + config.private_key_file, + }, + }; +} + +auto ligo::to_json(ligo::endpoint_config const& config) -> boost::json::value { + return boost::json::object{ + { + "name", + config.name, + }, + { + "protocol", + std::string{protocol_name(config.protocol)}, + }, + { + "bind", + config.bind, + }, + { + "connect", + config.connect, + }, + { + "timeout_ms", + config.timeout.count(), + }, + { + "security", + ligo::to_json(config.security), + }, + { + "http", + ligo::to_json(config.http), + }, + { + "udp", + ligo::to_json(config.udp), + }, + }; +} + +auto ligo::to_json(std::vector const& configs) -> boost::json::value { + auto endpoints = boost::json::array{}; + for (auto const& config : configs) { + endpoints.push_back(ligo::to_json(config)); + } + return boost::json::object{ + { + "endpoints", + std::move(endpoints), + }, + }; +} diff --git a/src/detail/errors.cpp b/src/detail/errors.cpp new file mode 100644 index 0000000..65c4ea7 --- /dev/null +++ b/src/detail/errors.cpp @@ -0,0 +1,14 @@ +#include + +#include + +#include +#include + +auto ligo::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { + return modulus::make_error("ligo", code, std::move(message), location); +} + +auto ligo::detail::fail_void(modulus::error error) -> std::expected { + return modulus::fail(std::move(error)); +} diff --git a/src/detail/lifecycle.cpp b/src/detail/lifecycle.cpp new file mode 100644 index 0000000..7a5a897 --- /dev/null +++ b/src/detail/lifecycle.cpp @@ -0,0 +1,10 @@ +#include + +#include +#include + +auto ligo::detail::cancel_session_stream(boost::beast::tcp_stream& stream) -> void { + auto ignored = boost::system::error_code{}; + stream.socket().cancel(ignored); + stream.socket().close(ignored); +} diff --git a/src/detail/transport.cpp b/src/detail/transport.cpp new file mode 100644 index 0000000..7fa92b6 --- /dev/null +++ b/src/detail/transport.cpp @@ -0,0 +1,403 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace { + +auto error_status_code(boost::system::error_code const& error) -> std::int32_t { + if (!error) { + return ligo::detail::error_ok; + } + if (error == boost::beast::error::timeout || error == boost::asio::error::timed_out) { + return ligo::detail::error_timeout; + } + if (error == boost::asio::error::invalid_argument || error == boost::asio::error::address_family_not_supported || error == boost::asio::error::no_protocol_option || error == boost::asio::error::not_socket) { + return ligo::detail::error_invalid_argument; + } + return ligo::detail::error_unavailable; +} + +} + +ligo::detail::state_handle::state_handle(ligo::detail::state_handle&& other) noexcept : state_{other.state_.exchange(nullptr)} {} + +auto ligo::detail::state_handle::operator=(ligo::detail::state_handle&& other) noexcept -> ligo::detail::state_handle& { + if (this != &other) { + state_.store(other.state_.exchange(nullptr)); + } + return *this; +} + +ligo::detail::state_handle::operator std::bool_t() const noexcept { + return static_cast(state_.load()); +} + +ligo::detail::unique_state::operator std::bool_t() const noexcept { + return static_cast(state_); +} + +auto ligo::detail::unique_state::ignore_state(void* state) -> void { + static_cast(state); +} + +auto ligo::detail::status_name(std::int32_t code) -> std::string { + switch (code) { + case ligo::detail::error_ok: + return "ok"; + case ligo::detail::error_invalid_argument: + return "invalid_argument"; + case ligo::detail::error_not_found: + return "not_found"; + case ligo::detail::error_unavailable: + return "unavailable"; + case ligo::detail::error_timeout: + return "timeout"; + case ligo::detail::error_internal: + return "internal"; + } + return "internal"; +} + +auto ligo::detail::parse_status_code(std::string_view value) -> std::int32_t { + if (value == "invalid_argument") { + return ligo::detail::error_invalid_argument; + } + if (value == "not_found") { + return ligo::detail::error_not_found; + } + if (value == "unavailable") { + return ligo::detail::error_unavailable; + } + if (value == "timeout") { + return ligo::detail::error_timeout; + } + if (value == "ok") { + return ligo::detail::error_ok; + } + return ligo::detail::error_internal; +} + +auto ligo::detail::network_failure(boost::system::error_code const& error) -> modulus::error { + return ligo::detail::make_error(error_status_code(error), error.message()); +} + +auto ligo::detail::operation_failure(boost::system::error_code const& error, std::string timeout_message) -> modulus::error { + if (error_status_code(error) == ligo::detail::error_timeout) { + return ligo::detail::make_error(ligo::detail::error_timeout, std::move(timeout_message)); + } + return ligo::detail::network_failure(error); +} + +auto ligo::detail::start_stream_deadline(boost::beast::tcp_stream& stream, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline { + stream.expires_after(timeout); + return { + .cancellation = std::make_shared(), + }; +} + +auto ligo::detail::start_stream_deadline(boost::asio::ip::tcp::socket& socket, std::chrono::milliseconds timeout) -> ligo::detail::stream_deadline { + auto deadline = ligo::detail::stream_deadline{ + .timer = std::make_shared(socket.get_executor()), + .timed_out = std::make_shared>(false), + .active = std::make_shared>(true), + .cancellation = std::make_shared(), + }; + deadline.timer->expires_after(timeout); + deadline.timer->async_wait([timer = deadline.timer, timed_out = deadline.timed_out, active = deadline.active, cancellation = deadline.cancellation](boost::system::error_code const& error) { + if (!error && active->exchange(false, std::memory_order_acq_rel)) { + timed_out->store(true, std::memory_order_release); + cancellation->emit(boost::asio::cancellation_type::terminal); + } + }); + return deadline; +} + +auto ligo::detail::cancel_stream_deadline(ligo::detail::stream_deadline& deadline) -> void { + if (deadline.active) { + deadline.active->store(false, std::memory_order_release); + } + if (deadline.timer) { + deadline.timer->cancel(); + } +} + +auto ligo::detail::deadline_token(ligo::detail::stream_deadline& deadline) -> decltype(boost::asio::bind_cancellation_slot(std::declval(), boost::asio::as_tuple(boost::asio::use_awaitable))) { + return boost::asio::bind_cancellation_slot(deadline.cancellation->slot(), boost::asio::as_tuple(boost::asio::use_awaitable)); +} + +auto ligo::detail::operation_failure(boost::system::error_code const& error, ligo::detail::stream_deadline const& deadline, std::string timeout_message) -> modulus::error { + if (deadline.timed_out && deadline.timed_out->load(std::memory_order_acquire)) { + return ligo::detail::make_error(ligo::detail::error_timeout, std::move(timeout_message)); + } + return ligo::detail::operation_failure(error, std::move(timeout_message)); +} + +auto ligo::detail::invalid_argument(std::string message) -> modulus::error { + return ligo::detail::make_error(ligo::detail::error_invalid_argument, std::move(message)); +} + +auto ligo::detail::unavailable(std::string message) -> modulus::error { + return ligo::detail::make_error(ligo::detail::error_unavailable, std::move(message)); +} + +auto ligo::detail::validate_positive_timeout(std::chrono::milliseconds timeout, std::string_view name) -> std::expected { + if (timeout <= std::chrono::milliseconds{0}) { + return ligo::detail::fail_void(ligo::detail::invalid_argument(std::string{name} + " must be positive")); + } + return {}; +} + +auto ligo::detail::report_error(std::function const& on_error, modulus::error error) -> void { + if (!on_error) { + return; + } + try { + on_error(std::move(error)); + } catch (...) { + } +} + +auto ligo::detail::report_error(std::function const& on_error, std::expected status) -> void { + if (!status) { + ligo::detail::report_error(on_error, status.error()); + } +} + +auto ligo::detail::response_envelope(std::expected result) -> boost::json::value { + auto envelope = boost::json::object{}; + envelope["ok"] = result.has_value(); + if (result) { + envelope["payload"] = std::move(*result); + } else { + envelope["code"] = ligo::detail::status_name(result.error().code()); + envelope["message"] = result.error().message(); + } + return envelope; +} + +auto ligo::detail::serialize_response_envelope(std::expected result) -> std::string { + return boost::json::serialize(ligo::detail::response_envelope(std::move(result))); +} + +auto ligo::detail::unwrap_response_envelope(boost::json::value value, std::string_view protocol_name) -> std::expected { + auto const prefix = std::string{protocol_name}; + if (!value.is_object()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, prefix + " response must be an object"); + } + auto const& object = value.as_object(); + auto ok = object.find("ok"); + if (ok == object.end() || !ok->value().is_bool()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, prefix + " response missing ok flag"); + } + if (ok->value().as_bool()) { + auto payload = object.find("payload"); + if (payload == object.end()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, prefix + " response missing payload"); + } + return modulus::success(boost::json::value{payload->value()}); + } + auto code = object.find("code"); + auto message_member = object.find("message"); + return ligo::detail::fail(code != object.end() && code->value().is_string() ? ligo::detail::parse_status_code(std::string_view{code->value().as_string()}) : ligo::detail::error_internal, message_member != object.end() && message_member->value().is_string() ? std::string{message_member->value().as_string()} : prefix + " request failed"); +} + +auto ligo::detail::parse_port(std::string_view text) -> std::expected { + if (text.empty()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint port is required"); + } + auto parsed = 0U; + auto const* begin = text.data(); + auto const* end = text.data() + text.size(); + auto [ptr, error] = std::from_chars(begin, end, parsed); + if (error != std::errc{} || ptr != end || parsed > std::numeric_limits::max()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint port is invalid"); + } + return modulus::success(static_cast(parsed)); +} + +auto ligo::detail::parse_endpoint_text(std::string_view text, std::uint16_t fallback_port) -> std::expected { + if (text.empty()) { + return modulus::success(ligo::detail::endpoint_parts{ + .host = "127.0.0.1", + .port = fallback_port, + }); + } + auto host = std::string{}; + auto port = fallback_port; + if (text.front() == '[') { + auto const close = text.find(']'); + if (close == std::string_view::npos || close == 1) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint IPv6 host is invalid"); + } + host = std::string{text.substr(1, close - 1)}; + auto const rest = text.substr(close + 1); + if (!rest.empty()) { + if (rest.front() != ':') { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint has trailing characters"); + } + auto parsed_port = ligo::detail::parse_port(rest.substr(1)); + if (!parsed_port) { + return ligo::detail::fail(parsed_port.error()); + } + port = *parsed_port; + } + } else { + auto colon_count = std::size_t{}; + for (auto character : text) { + colon_count += character == ':' ? 1U : 0U; + } + if (colon_count == 1U) { + auto const separator = text.find(':'); + host = std::string{text.substr(0, separator)}; + auto parsed_port = ligo::detail::parse_port(text.substr(separator + 1)); + if (!parsed_port) { + return ligo::detail::fail(parsed_port.error()); + } + port = *parsed_port; + } else { + host = std::string{text}; + } + } + if (host.empty()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint host is required"); + } + return modulus::success(ligo::detail::endpoint_parts{ + .host = std::move(host), + .port = port, + }); +} + +auto ligo::detail::parse_client_endpoint_text(std::string_view text) -> std::expected { + if (text.empty()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint connect is required"); + } + auto parsed = ligo::detail::parse_endpoint_text(text, 0); + if (!parsed) { + return parsed; + } + if (parsed->port == 0) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "endpoint connect port is required"); + } + return parsed; +} + +auto ligo::detail::parse_server_endpoint_text(std::string_view text) -> std::expected { + return ligo::detail::parse_endpoint_text(text, 0); +} + +auto ligo::detail::start_tcp_acceptor(boost::asio::ip::tcp::acceptor& acceptor, std::string const& host, std::uint16_t port) -> std::expected { + auto close_on_failure = [&acceptor] { + auto ignored = boost::system::error_code{}; + acceptor.close(ignored); + }; + auto bind_endpoint = [&](boost::asio::ip::tcp::endpoint endpoint) -> std::expected { + auto error = boost::system::error_code{}; + close_on_failure(); + acceptor.open(endpoint.protocol(), error); + if (error) { + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + acceptor.set_option(boost::asio::socket_base::reuse_address(true), error); + if (error) { + close_on_failure(); + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + acceptor.bind(endpoint, error); + if (error) { + close_on_failure(); + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + acceptor.listen(boost::asio::socket_base::max_listen_connections, error); + if (error) { + close_on_failure(); + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + auto bound_endpoint = acceptor.local_endpoint(error); + if (error) { + close_on_failure(); + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + return modulus::success(bound_endpoint.port()); + }; + auto error = boost::system::error_code{}; + auto address = boost::asio::ip::make_address(host, error); + if (!error) { + return bind_endpoint({ + address, + port, + }); + } + auto resolver = boost::asio::ip::tcp::resolver{acceptor.get_executor()}; + auto endpoints = resolver.resolve(host, std::to_string(port), boost::asio::ip::tcp::resolver::passive, error); + if (error) { + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + auto last_error = ligo::detail::unavailable("no TCP bind endpoint resolved"); + for (auto const& endpoint : endpoints) { + auto selected = bind_endpoint(endpoint.endpoint()); + if (selected) { + return selected; + } + last_error = selected.error(); + } + return ligo::detail::fail(last_error); +} + +auto ligo::detail::resolve_tcp_endpoints(boost::asio::any_io_executor executor, std::string const& host, std::uint16_t port, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto resolver = std::make_shared(executor); + auto timer = std::make_shared(executor); + auto timed_out = std::make_shared>(false); + timer->expires_after(timeout); + timer->async_wait([resolver, timer, timed_out](boost::system::error_code const& error) { + if (!error) { + timed_out->store(true, std::memory_order_release); + resolver->cancel(); + } + }); + auto [resolve_error, endpoints] = co_await resolver->async_resolve(host, std::to_string(port), boost::asio::as_tuple(boost::asio::use_awaitable)); + timer->cancel(); + if (resolve_error) { + if (resolve_error == boost::asio::error::operation_aborted && timed_out->load(std::memory_order_acquire)) { + co_return ligo::detail::fail(ligo::detail::error_timeout, std::move(timeout_message)); + } + auto failure_status = ligo::detail::network_failure(resolve_error); + co_return ligo::detail::fail(failure_status); + } + co_return modulus::success(std::move(endpoints)); +} + +auto ligo::detail::call_handler(ligo::service_router const& router, ligo::request_context context, boost::json::value payload) -> boost::asio::awaitable> { + co_return co_await router.call(std::move(context), std::move(payload)); +} + +auto ligo::detail::call_response_handler(ligo::service_router const& router, ligo::request_context context, boost::json::value payload) -> boost::asio::awaitable> { + co_return co_await router.call_response(std::move(context), std::move(payload)); +} diff --git a/src/http.cpp b/src/http.cpp new file mode 100755 index 0000000..479fabf --- /dev/null +++ b/src/http.cpp @@ -0,0 +1,632 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct http_server_state { + http_server_state(boost::asio::any_io_executor executor, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options); + boost::asio::any_io_executor executor; + boost::asio::strand strand; + std::shared_ptr acceptor{}; + ligo::http::endpoint endpoint{}; + ligo::http::server_options options{}; + std::shared_ptr router{}; + std::shared_ptr> sessions{std::make_shared>()}; + std::shared_ptr>> tls_sessions{std::make_shared>>()}; + std::shared_ptr tls_context{}; + std::atomic_uint16_t selected_port{}; + std::atomic start_called{false}; + std::atomic stopped{false}; + mutable std::mutex lifecycle_mutex{}; +}; + +auto http_status(std::int32_t code) -> boost::beast::http::status { + switch (code) { + case ligo::detail::error_ok: + return boost::beast::http::status::ok; + case ligo::detail::error_invalid_argument: + return boost::beast::http::status::bad_request; + case ligo::detail::error_not_found: + return boost::beast::http::status::not_found; + case ligo::detail::error_unavailable: + return boost::beast::http::status::service_unavailable; + case ligo::detail::error_timeout: + return boost::beast::http::status::gateway_timeout; + case ligo::detail::error_internal: + return boost::beast::http::status::internal_server_error; + } + return boost::beast::http::status::internal_server_error; +} + +auto http_body_limit_exceeded(boost::system::error_code const& error) -> std::bool_t { + return error == boost::beast::http::error::body_limit; +} + +auto http_body_limit_status() -> modulus::error { + return ligo::detail::make_error(ligo::detail::error_invalid_argument, "HTTP body limit exceeded"); +} + +auto http_verb(ligo::http::method method) -> std::expected { + switch (method) { + case ligo::http::method::GET: + return modulus::success(boost::beast::http::verb::get); + case ligo::http::method::POST: + return modulus::success(boost::beast::http::verb::post); + case ligo::http::method::PUT: + return modulus::success(boost::beast::http::verb::put); + case ligo::http::method::PATCH: + return modulus::success(boost::beast::http::verb::patch); + case ligo::http::method::DELETE: + return modulus::success(boost::beast::http::verb::delete_); + } + return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP method is invalid"); +} + +auto should_send_json_body(ligo::http::request const& request) -> std::bool_t { + if (request.method != ligo::http::method::GET) { + return true; + } + if (request.payload.is_null()) { + return false; + } + return !request.payload.is_object() || !request.payload.as_object().empty(); +} + +auto host_header(std::string const& host, std::uint16_t port) -> std::string { + auto value = std::string{}; + if (!host.empty() && host.front() == '[' && host.back() == ']') { + value = host; + } else if (host.find(':') != std::string::npos) { + value = "[" + host + "]"; + } else { + value = host; + } + value += ":"; + value += std::to_string(port); + return value; +} + +auto remote_peer(boost::beast::tcp_stream& stream) -> std::string { + auto endpoint_error = boost::system::error_code{}; + auto remote_endpoint = stream.socket().remote_endpoint(endpoint_error); + return endpoint_error ? std::string{} : remote_endpoint.address().to_string(); +} + +auto remote_peer(boost::asio::ssl::stream& stream) -> std::string { + auto endpoint_error = boost::system::error_code{}; + auto remote_endpoint = stream.next_layer().remote_endpoint(endpoint_error); + return endpoint_error ? std::string{} : remote_endpoint.address().to_string(); +} + +auto close_http_session(boost::beast::tcp_stream& stream, std::chrono::milliseconds) -> boost::asio::awaitable { + auto ignored = boost::beast::error_code{}; + stream.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_send, ignored); + co_return; +} + +auto close_http_session(boost::asio::ssl::stream& stream, std::chrono::milliseconds timeout) -> boost::asio::awaitable { + static_cast(co_await ligo::detail::async_tls_shutdown(stream, timeout, "HTTP TLS shutdown timed out")); +} + +template +auto write_http_failure_response(STREAM& stream, modulus::error status, std::chrono::milliseconds timeout, std::function const& on_error) -> boost::asio::awaitable { + auto response = boost::beast::http::response{}; + response.version(11); + response.set(boost::beast::http::field::content_type, "application/json"); + response.keep_alive(false); + response.result(http_status(status.code())); + response.body() = ligo::detail::serialize_response_envelope(ligo::detail::fail(status)); + response.prepare_payload(); + auto write_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [write_error, written] = co_await boost::beast::http::async_write(stream, response, ligo::detail::deadline_token(write_deadline)); + ligo::detail::cancel_stream_deadline(write_deadline); + static_cast(written); + if (write_error) { + ligo::detail::report_error(on_error, ligo::detail::operation_failure(write_error, write_deadline, "HTTP response timed out")); + } + co_await close_http_session(stream, timeout); +} + +template +auto handle_session(STREAM& stream, std::shared_ptr router, ligo::http::server_options options) -> boost::asio::awaitable { + assert(router != nullptr); + auto buffer = boost::beast::flat_buffer{}; + auto parser = boost::beast::http::request_parser{}; + parser.body_limit(options.max_body_size); + auto read_deadline = ligo::detail::start_stream_deadline(stream, options.timeout); + auto [read_error, _] = co_await boost::beast::http::async_read(stream, buffer, parser, ligo::detail::deadline_token(read_deadline)); + ligo::detail::cancel_stream_deadline(read_deadline); + if (read_error) { + if (http_body_limit_exceeded(read_error)) { + co_await write_http_failure_response(stream, http_body_limit_status(), options.timeout, options.on_error); + } + co_return; + } + auto http_request = parser.release(); + auto response = boost::beast::http::response{}; + response.version(http_request.version()); + response.set(boost::beast::http::field::content_type, "application/json"); + response.keep_alive(false); + auto payload = boost::json::value{ + boost::json::object{}, + }; + if (!http_request.body().empty()) { + auto parse_error = boost::system::error_code{}; + payload = boost::json::parse(http_request.body(), parse_error); + if (parse_error) { + auto parse_status = ligo::detail::make_error(ligo::detail::error_invalid_argument, parse_error.message()); + co_await write_http_failure_response(stream, parse_status, options.timeout, options.on_error); + co_return; + } + } + auto context = ligo::request_context{ + .peer = remote_peer(stream), + .method = std::string{http_request.method_string()}, + .route = std::string{http_request.target()}, + }; + auto result = co_await ligo::detail::call_response_handler(*router, std::move(context), std::move(payload)); + if (result) { + response.result(boost::beast::http::status::ok); + response.set(boost::beast::http::field::content_type, result->content_type); + response.body() = std::move(result->body); + } else { + response.result(http_status(result.error().code())); + response.body() = ligo::detail::serialize_response_envelope(ligo::detail::fail(result.error())); + } + response.prepare_payload(); + auto write_deadline = ligo::detail::start_stream_deadline(stream, options.timeout); + auto [write_error, written] = co_await boost::beast::http::async_write(stream, response, ligo::detail::deadline_token(write_deadline)); + ligo::detail::cancel_stream_deadline(write_deadline); + static_cast(written); + if (write_error) { + ligo::detail::report_error(options.on_error, ligo::detail::operation_failure(write_error, write_deadline, "HTTP response timed out")); + } + co_await close_http_session(stream, options.timeout); +} + +auto serve_session(std::shared_ptr stream, std::shared_ptr router, ligo::http::server_options options, std::shared_ptr> registry) -> boost::asio::awaitable { + auto guard = ligo::detail::session_guard{ + .registry = std::move(registry), + .stream = stream, + }; + co_await handle_session(*stream, std::move(router), options); + guard.release(); +} + +auto serve_tls_session(std::shared_ptr> stream, std::shared_ptr router, ligo::http::server_options options, std::shared_ptr>> registry) -> boost::asio::awaitable { + auto guard = ligo::detail::session_guard>{ + .registry = std::move(registry), + .stream = stream, + }; + auto handshake = co_await ligo::detail::async_tls_handshake(*stream, boost::asio::ssl::stream_base::server, options.timeout, "HTTP TLS handshake timed out"); + if (!handshake) { + ligo::detail::report_error(options.on_error, handshake); + guard.release(); + co_return; + } + co_await handle_session(*stream, std::move(router), options); + guard.release(); +} + +auto accept_loop(std::shared_ptr state) -> boost::asio::awaitable { + for (;;) { + auto session_strand = boost::asio::make_strand(state->executor); + auto stream = std::make_shared(session_strand); + auto [error] = co_await state->acceptor->async_accept(stream->socket(), boost::asio::as_tuple(boost::asio::use_awaitable)); + if (error == boost::asio::error::operation_aborted || error == boost::asio::error::bad_descriptor || (error && state->stopped.load())) { + co_return; + } + if (!error) { + state->sessions->add(stream); + auto session_executor = stream->get_executor(); + boost::asio::co_spawn(session_executor, serve_session(std::move(stream), state->router, state->options, state->sessions), boost::asio::detached); + } else { + ligo::detail::report_error(state->options.on_error, ligo::detail::network_failure(error)); + } + } +} + +auto accept_tls_loop(std::shared_ptr state) -> boost::asio::awaitable { + for (;;) { + auto session_strand = boost::asio::make_strand(state->executor); + auto socket = std::make_shared(session_strand); + auto [error] = co_await state->acceptor->async_accept(*socket, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (error == boost::asio::error::operation_aborted || error == boost::asio::error::bad_descriptor || (error && state->stopped.load())) { + co_return; + } + if (!error) { + auto stream = std::make_shared>(std::move(*socket), *state->tls_context); + state->tls_sessions->add(stream); + auto session_executor = stream->get_executor(); + boost::asio::co_spawn(session_executor, serve_tls_session(std::move(stream), state->router, state->options, state->tls_sessions), boost::asio::detached); + } else { + ligo::detail::report_error(state->options.on_error, ligo::detail::network_failure(error)); + } + } +} + +auto http_state(ligo::detail::state_handle const& state) -> std::shared_ptr { + return state.as(); +} + +auto uninitialized_http_server() -> std::expected { + return ligo::detail::fail_void(ligo::detail::unavailable("HTTP server is not initialized")); +} + +auto start_http_server(std::shared_ptr const& state) -> std::expected { + assert(!state || state->acceptor != nullptr); + assert(!state || state->router != nullptr); + if (!state) { + return uninitialized_http_server(); + } + auto lock = std::scoped_lock{state->lifecycle_mutex}; + auto valid_timeout = ligo::detail::validate_positive_timeout(state->options.timeout, "HTTP server timeout"); + if (!valid_timeout) { + return valid_timeout; + } + if (state->options.security.mode == ligo::security_mode::DTLS) { + return ligo::detail::fail_void(ligo::unsupported_security("HTTP server", state->options.security.mode)); + } + if (state->start_called.exchange(true)) { + return ligo::detail::fail_void(ligo::detail::unavailable("HTTP server has already been started")); + } + if (state->options.security.mode == ligo::security_mode::TLS) { + auto valid_config = ligo::validate_tls_server_config("HTTP", state->options.security); + if (!valid_config) { + state->start_called.store(false); + return valid_config; + } + auto context = ligo::make_tls_server_context(state->options.security); + if (!context) { + state->start_called.store(false); + return ligo::detail::fail_void(context.error()); + } + auto valid_context = ligo::validate_tls_server_context(*context); + if (!valid_context) { + state->start_called.store(false); + return valid_context; + } + state->tls_context = std::move(*context); + } + auto selected = ligo::detail::start_tcp_acceptor(*state->acceptor, state->endpoint.host, state->endpoint.port); + if (!selected) { + state->start_called.store(false); + return ligo::detail::fail_void(selected.error()); + } + state->selected_port.store(*selected); + assert(state->selected_port.load() == *selected); + state->stopped.store(false); + modulus::log_debug("ligo HTTP server started on port " + std::to_string(*selected)); + if (state->options.security.mode == ligo::security_mode::TLS) { + boost::asio::co_spawn(state->strand, accept_tls_loop(state), boost::asio::detached); + } else { + boost::asio::co_spawn(state->strand, accept_loop(state), boost::asio::detached); + } + return {}; +} + +auto stop_http_server(std::shared_ptr state) -> void { + assert(state != nullptr); + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + modulus::log_debug("ligo HTTP server stopping"); + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + auto server_executor = state->strand; + boost::asio::post(server_executor, [state = std::move(state)] { + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + }); +} + +auto stop_http_server_now(std::shared_ptr const& state) -> void { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); +} + +auto async_stop_http_server(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state) { + co_return uninitialized_http_server(); + } + auto valid_timeout = ligo::detail::validate_positive_timeout(timeout, "shutdown timeout"); + if (!valid_timeout) { + co_return valid_timeout; + } + auto should_stop = false; + { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + should_stop = !state->stopped.exchange(true); + if (should_stop) { + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + } + } + if (should_stop) { + co_await boost::asio::post(state->strand, boost::asio::use_awaitable); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + } + auto drained = co_await state->sessions->wait_empty(timeout); + if (!drained) { + co_return drained; + } + co_return co_await state->tls_sessions->wait_empty(timeout); +} + +auto selected_http_port(std::shared_ptr const& state) -> std::uint16_t { + if (!state) { + return 0; + } + return state->selected_port.load(); +} + +auto make_http_request_message(ligo::http::endpoint const& endpoint, ligo::http::request const& request, boost::beast::http::verb verb) -> boost::beast::http::request { + auto http_request = boost::beast::http::request{ + verb, + request.target, + 11, + }; + http_request.set(boost::beast::http::field::host, host_header(endpoint.host, endpoint.port)); + if (should_send_json_body(request)) { + http_request.set(boost::beast::http::field::content_type, "application/json"); + http_request.body() = boost::json::serialize(request.payload); + } + http_request.prepare_payload(); + return http_request; +} + +template +auto read_http_response(STREAM& stream, std::chrono::milliseconds timeout, std::uint64_t max_body_size) -> boost::asio::awaitable> { + auto buffer = boost::beast::flat_buffer{}; + auto parser = boost::beast::http::response_parser{}; + parser.body_limit(max_body_size); + auto read_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [read_error, read] = co_await boost::beast::http::async_read(stream, buffer, parser, ligo::detail::deadline_token(read_deadline)); + ligo::detail::cancel_stream_deadline(read_deadline); + static_cast(read); + if (read_error) { + if (http_body_limit_exceeded(read_error)) { + auto failure_status = http_body_limit_status(); + co_return ligo::detail::fail(failure_status); + } + auto failure_status = ligo::detail::operation_failure(read_error, read_deadline, "HTTP request timed out"); + co_return ligo::detail::fail(failure_status); + } + auto http_response = parser.release(); + auto parse_error = boost::system::error_code{}; + auto body = boost::json::parse(http_response.body(), parse_error); + if (parse_error) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, parse_error.message()); + } + co_return ligo::detail::unwrap_response_envelope(std::move(body), "HTTP"); +} + +auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::request http_request, ligo::http::client_options options) -> boost::asio::awaitable> { + auto context = ligo::make_tls_client_context(options.security); + if (!context) { + co_return ligo::detail::fail(context.error()); + } + auto executor = co_await boost::asio::this_coro::executor; + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "HTTP request timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + auto tcp_stream = boost::beast::tcp_stream{executor}; + tcp_stream.expires_after(options.timeout); + auto [connect_error, _] = co_await tcp_stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "HTTP request timed out"); + co_return ligo::detail::fail(failure_status); + } + auto stream = boost::asio::ssl::stream{ + tcp_stream.release_socket(), + **context, + }; + auto verification = ligo::configure_tls_client_stream(stream, endpoint.host, options.security); + if (!verification) { + co_return ligo::detail::fail(verification.error()); + } + auto handshake = co_await ligo::detail::async_tls_handshake(stream, boost::asio::ssl::stream_base::client, options.timeout, "HTTP TLS handshake timed out"); + if (!handshake) { + co_return ligo::detail::fail(handshake.error()); + } + auto write_deadline = ligo::detail::start_stream_deadline(stream, options.timeout); + auto [write_error, written] = co_await boost::beast::http::async_write(stream, http_request, ligo::detail::deadline_token(write_deadline)); + ligo::detail::cancel_stream_deadline(write_deadline); + static_cast(written); + if (write_error) { + auto failure_status = ligo::detail::operation_failure(write_error, write_deadline, "HTTP request timed out"); + co_return ligo::detail::fail(failure_status); + } + auto response = co_await read_http_response(stream, options.timeout, options.max_body_size); + static_cast(co_await ligo::detail::async_tls_shutdown(stream, options.timeout, "HTTP TLS shutdown timed out")); + co_return response; +} + +auto call_http_endpoint(ligo::http::endpoint endpoint, std::shared_ptr request, ligo::http::client_options options) -> boost::asio::awaitable> { + assert(request != nullptr); + modulus::log_debug("ligo HTTP client call to " + endpoint.host + ":" + std::to_string(endpoint.port)); + auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "HTTP timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + if (endpoint.port == 0) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP port must be non-zero"); + } + if (request->target.empty()) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must not be empty"); + } + if (request->target.front() != '/') { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must start with /"); + } + auto verb = http_verb(request->method); + if (!verb) { + co_return ligo::detail::fail(verb.error()); + } + if (options.security.mode == ligo::security_mode::DTLS) { + auto status = ligo::unsupported_security("HTTP client", options.security.mode); + co_return ligo::detail::fail(status); + } + auto http_request = make_http_request_message(endpoint, *request, *verb); + if (options.security.mode == ligo::security_mode::TLS) { + co_return co_await call_https_endpoint(std::move(endpoint), std::move(http_request), options); + } + auto executor = co_await boost::asio::this_coro::executor; + auto stream = boost::beast::tcp_stream{executor}; + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "HTTP request timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + stream.expires_after(options.timeout); + auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "HTTP request timed out"); + co_return ligo::detail::fail(failure_status); + } + stream.expires_after(options.timeout); + auto [write_error, written] = co_await boost::beast::http::async_write(stream, http_request, boost::asio::as_tuple(boost::asio::use_awaitable)); + static_cast(written); + if (write_error) { + auto failure_status = ligo::detail::operation_failure(write_error, "HTTP request timed out"); + co_return ligo::detail::fail(failure_status); + } + stream.expires_after(options.timeout); + co_return co_await read_http_response(stream, options.timeout, options.max_body_size); +} + +http_server_state::http_server_state(boost::asio::any_io_executor executor, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options) : executor{std::move(executor)}, strand{boost::asio::make_strand(this->executor)}, acceptor{std::make_shared(strand)}, endpoint{std::move(endpoint)}, options{options}, router{std::make_shared(std::move(router))} {} + +} + +ligo::http::client::client(ligo::http::endpoint endpoint, ligo::http::client_options options) : endpoint_{std::move(endpoint)}, options_{options} {} + +auto ligo::http::client::call(ligo::http::request const& request) const -> boost::asio::awaitable> { + return call_http_endpoint(endpoint_, std::make_shared(request), options_); +} + +ligo::http::server::server(boost::asio::io_context& io, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options) : ligo::http::server{io.get_executor(), std::move(endpoint), std::move(router), options} {} + +ligo::http::server::server(boost::asio::any_io_executor executor, ligo::http::endpoint endpoint, ligo::service_router router, ligo::http::server_options options) : state_{ligo::detail::state_handle{std::make_shared(std::move(executor), std::move(endpoint), std::move(router), options)}} {} + +ligo::http::server::~server() { + if (auto state = http_state(state_)) { + stop_http_server_now(state); + } +} + +auto ligo::http::server::operator=(ligo::http::server&& other) noexcept -> ligo::http::server& { + if (this != &other) { + if (auto state = http_state(state_)) { + stop_http_server_now(state); + } + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::http::server::start() -> std::expected { + return start_http_server(http_state(state_)); +} + +auto ligo::http::server::stop() -> void { + if (auto state = http_state(state_)) { + stop_http_server(std::move(state)); + } +} + +auto ligo::http::server::async_stop(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + return async_stop_http_server(http_state(state_), timeout); +} + +auto ligo::http::server::selected_port() const -> std::uint16_t { + return selected_http_port(http_state(state_)); +} + +auto ligo::http::client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + auto parsed = ligo::detail::parse_client_endpoint_text(config.connect); + if (!parsed) { + return ligo::detail::fail(parsed.error()); + } + return modulus::success(ligo::http::endpoint{ + .host = std::move(parsed->host), + .port = parsed->port + }); +} + +auto ligo::http::server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + auto parsed = ligo::detail::parse_server_endpoint_text(config.bind); + if (!parsed) { + return ligo::detail::fail(parsed.error()); + } + return modulus::success(ligo::http::endpoint{ + .host = std::move(parsed->host), + .port = parsed->port, + }); +} + +auto ligo::http::client_options_from_config(ligo::endpoint_config const& config) -> ligo::http::client_options { + return { + .timeout = config.timeout, + .max_body_size = config.http.max_body_size, + .security = config.security, + }; +} + +auto ligo::http::server_options_from_config(ligo::endpoint_config const& config) -> ligo::http::server_options { + return { + .timeout = config.timeout, + .max_body_size = config.http.max_body_size, + .on_error = {}, + .security = config.security, + }; +} diff --git a/src/net.cpp b/src/net.cpp new file mode 100644 index 0000000..4a1f235 --- /dev/null +++ b/src/net.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +class ligo::net::byte_stream_state { +public: + byte_stream_state() = default; + byte_stream_state(ligo::net::byte_stream_state const&) = delete; + byte_stream_state(ligo::net::byte_stream_state&&) noexcept = delete; + virtual ~byte_stream_state() = default; + auto operator=(ligo::net::byte_stream_state const&) -> ligo::net::byte_stream_state& = delete; + auto operator=(ligo::net::byte_stream_state&&) noexcept -> ligo::net::byte_stream_state& = delete; + virtual auto async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> = 0; + virtual auto async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> = 0; + virtual auto async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> = 0; + virtual auto async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> = 0; + virtual auto close(boost::system::error_code& error) -> void = 0; + virtual auto is_open() const -> std::bool_t = 0; +}; + + +namespace { + +struct net_listener_state { + explicit net_listener_state(boost::asio::any_io_executor executor); + boost::asio::strand strand; + boost::asio::ip::tcp::acceptor acceptor; + std::atomic_uint16_t selected_port{}; + std::atomic closed{true}; +}; + +class tcp_byte_stream_state final : public ligo::net::byte_stream_state { +public: + explicit tcp_byte_stream_state(boost::asio::ip::tcp::socket socket); + auto async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto close(boost::system::error_code& error) -> void override; + auto is_open() const -> std::bool_t override; +private: + std::shared_ptr socket_{}; +}; + +class tls_byte_stream_state final : public ligo::net::byte_stream_state { +public: + explicit tls_byte_stream_state(ligo::tcp::tls_socket::released_stream released); + auto async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> override; + auto close(boost::system::error_code& error) -> void override; + auto is_open() const -> std::bool_t override; +private: + std::shared_ptr context_{}; + std::unique_ptr> stream_{}; +}; + +auto listener_state(ligo::detail::state_handle const& state) -> std::shared_ptr { + return state.as(); +} + +auto close_listener(std::shared_ptr const& state) -> void { + if (!state || state->closed.exchange(true)) { + return; + } + boost::asio::post(state->strand, [state] { + auto ignored = boost::system::error_code{}; + state->acceptor.cancel(ignored); + state->acceptor.close(ignored); + }); +} + +auto validate_tls_accept_security(ligo::security_config const& security) -> std::expected { + if (security.mode != ligo::security_mode::TLS) { + return ligo::detail::fail_void(ligo::unsupported_security("TCP TLS accept", security.mode)); + } + return {}; +} + +auto validate_stream_timeout(std::chrono::milliseconds timeout) -> std::expected { + return ligo::detail::validate_positive_timeout(timeout, "byte stream timeout"); +} + +template +auto read_some_from(STREAM& stream, boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto valid = validate_stream_timeout(timeout); + if (!valid) { + co_return ligo::detail::fail(valid.error()); + } + auto deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error, bytes] = co_await stream.async_read_some(buffer, ligo::detail::deadline_token(deadline)); + ligo::detail::cancel_stream_deadline(deadline); + if (error) { + auto failure_status = ligo::detail::operation_failure(error, deadline, std::move(timeout_message)); + co_return ligo::detail::fail(failure_status); + } + co_return modulus::success(bytes); +} + +template +auto write_some_to(STREAM& stream, boost::asio::const_buffer buffer, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto valid = validate_stream_timeout(timeout); + if (!valid) { + co_return ligo::detail::fail(valid.error()); + } + auto deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error, bytes] = co_await stream.async_write_some(buffer, ligo::detail::deadline_token(deadline)); + ligo::detail::cancel_stream_deadline(deadline); + if (error) { + auto failure_status = ligo::detail::operation_failure(error, deadline, std::move(timeout_message)); + co_return ligo::detail::fail(failure_status); + } + co_return modulus::success(bytes); +} + +template +auto write_all_to(STREAM& stream, boost::asio::const_buffer buffer, std::chrono::milliseconds timeout, std::string timeout_message) -> boost::asio::awaitable> { + auto valid = validate_stream_timeout(timeout); + if (!valid) { + co_return valid; + } + auto deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error, bytes] = co_await boost::asio::async_write(stream, buffer, ligo::detail::deadline_token(deadline)); + ligo::detail::cancel_stream_deadline(deadline); + static_cast(bytes); + if (error) { + co_return ligo::detail::fail_void(ligo::detail::operation_failure(error, deadline, std::move(timeout_message))); + } + co_return std::expected{}; +} + +auto accept_socket(std::shared_ptr state, std::optional timeout = std::nullopt) -> boost::asio::awaitable> { + if (!state) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "net listener is not initialized"); + } + if (timeout.has_value()) { + auto valid_timeout = ligo::detail::validate_positive_timeout(*timeout, "TCP accept timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + } + co_await boost::asio::post(state->strand, boost::asio::use_awaitable); + if (state->closed.load(std::memory_order_acquire) || !state->acceptor.is_open()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "net listener is closed"); + } + auto socket = boost::asio::ip::tcp::socket{state->strand}; + auto timed_out = std::make_shared>(false); + auto timer = std::shared_ptr{}; + if (timeout.has_value()) { + timer = std::make_shared(state->strand); + timer->expires_after(*timeout); + timer->async_wait([state, timer, timed_out](boost::system::error_code const& error) { + if (!error) { + timed_out->store(true, std::memory_order_release); + auto ignored = boost::system::error_code{}; + state->acceptor.cancel(ignored); + } + }); + } + auto [error] = co_await state->acceptor.async_accept(socket, boost::asio::bind_executor(state->strand, boost::asio::as_tuple(boost::asio::use_awaitable))); + if (timer) { + timer->cancel(); + } + if (error) { + if (error == boost::asio::error::operation_aborted && timed_out->load(std::memory_order_acquire)) { + co_return ligo::detail::fail(ligo::detail::error_timeout, "TCP accept timed out"); + } + auto failure_status = ligo::detail::network_failure(error); + co_return ligo::detail::fail(failure_status); + } + co_return modulus::success(ligo::tcp::socket_handle{std::move(socket)}); +} + +auto make_server_tls_context(ligo::security_config const& security) -> std::expected, modulus::error> { + auto valid_security = validate_tls_accept_security(security); + if (!valid_security) { + return ligo::detail::fail>(valid_security.error()); + } + auto valid_config = ligo::validate_tls_server_config("TCP", security); + if (!valid_config) { + return ligo::detail::fail>(valid_config.error()); + } + auto context = ligo::make_tls_server_context(security); + if (!context) { + return ligo::detail::fail>(context.error()); + } + auto valid_context = ligo::validate_tls_server_context(*context); + if (!valid_context) { + return ligo::detail::fail>(valid_context.error()); + } + return context; +} + +net_listener_state::net_listener_state(boost::asio::any_io_executor executor) : strand{boost::asio::make_strand(std::move(executor))}, acceptor{strand} {} + +tcp_byte_stream_state::tcp_byte_stream_state(boost::asio::ip::tcp::socket socket) : socket_{std::make_shared(std::move(socket))} {} + +auto tcp_byte_stream_state::async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is closed"); + } + co_return co_await read_some_from(*socket_, buffer, timeout, "byte stream read timed out"); +} + +auto tcp_byte_stream_state::async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is closed"); + } + co_return co_await write_some_to(*socket_, buffer, timeout, "byte stream write timed out"); +} + +auto tcp_byte_stream_state::async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is closed")); + } + co_return co_await write_all_to(*socket_, buffer, timeout, "byte stream write timed out"); +} + +auto tcp_byte_stream_state::async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto valid = validate_stream_timeout(timeout); + if (!valid) { + co_return valid; + } + if (!socket_) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is closed")); + } + auto error = boost::system::error_code{}; + socket_->shutdown(boost::asio::ip::tcp::socket::shutdown_both, error); + if (error && error != boost::asio::error::not_connected) { + co_return ligo::detail::fail_void(ligo::detail::network_failure(error)); + } + socket_->close(error); + if (error) { + co_return ligo::detail::fail_void(ligo::detail::network_failure(error)); + } + co_return std::expected{}; +} + +auto tcp_byte_stream_state::close(boost::system::error_code& error) -> void { + if (socket_) { + socket_->close(error); + } +} + +auto tcp_byte_stream_state::is_open() const -> std::bool_t { + return socket_ && socket_->is_open(); +} + +tls_byte_stream_state::tls_byte_stream_state(ligo::tcp::tls_socket::released_stream released) : context_{std::move(released.context)}, stream_{std::make_unique>(std::move(released.stream))} {} + +auto tls_byte_stream_state::async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is closed"); + } + co_return co_await read_some_from(*stream_, buffer, timeout, "TLS byte stream read timed out"); +} + +auto tls_byte_stream_state::async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is closed"); + } + co_return co_await write_some_to(*stream_, buffer, timeout, "TLS byte stream write timed out"); +} + +auto tls_byte_stream_state::async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!is_open()) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is closed")); + } + co_return co_await write_all_to(*stream_, buffer, timeout, "TLS byte stream write timed out"); +} + +auto tls_byte_stream_state::async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!stream_) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is closed")); + } + auto status = co_await ligo::detail::async_tls_shutdown(*stream_, timeout, "TLS byte stream shutdown timed out"); + auto ignored = boost::system::error_code{}; + stream_->lowest_layer().close(ignored); + co_return status; +} + +auto tls_byte_stream_state::close(boost::system::error_code& error) -> void { + if (stream_) { + stream_->lowest_layer().close(error); + } +} + +auto tls_byte_stream_state::is_open() const -> std::bool_t { + return stream_ && stream_->lowest_layer().is_open(); +} + +} + +ligo::net::byte_stream::~byte_stream() { + auto error = boost::system::error_code{}; + close(error); +} + +auto ligo::net::byte_stream::operator=(ligo::net::byte_stream&& other) noexcept -> ligo::net::byte_stream& { + if (this != &other) { + auto error = boost::system::error_code{}; + close(error); + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::net::byte_stream::from_socket(ligo::tcp::socket_handle socket) -> std::expected { + modulus::log_debug("ligo byte stream adopting TCP socket"); + auto released = socket.release_socket(); + if (!released) { + return ligo::detail::fail(released.error()); + } + auto state = std::make_shared(std::move(*released)); + return modulus::success(ligo::net::byte_stream{std::move(state)}); +} + +auto ligo::net::byte_stream::from_tls_socket(ligo::tcp::tls_socket socket) -> std::expected { + modulus::log_debug("ligo byte stream adopting TLS socket"); + auto released = socket.release_stream(); + if (!released) { + return ligo::detail::fail(released.error()); + } + auto state = std::make_shared(std::move(*released)); + return modulus::success(ligo::net::byte_stream{std::move(state)}); +} + +auto ligo::net::byte_stream::async_read_some(boost::asio::mutable_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state_) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is not initialized"); + } + co_return co_await state_->async_read_some(buffer, timeout); +} + +auto ligo::net::byte_stream::async_write_some(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state_) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "byte stream is not initialized"); + } + co_return co_await state_->async_write_some(buffer, timeout); +} + +auto ligo::net::byte_stream::async_write_all(boost::asio::const_buffer buffer, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state_) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is not initialized")); + } + co_return co_await state_->async_write_all(buffer, timeout); +} + +auto ligo::net::byte_stream::async_shutdown(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state_) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("byte stream is not initialized")); + } + co_return co_await state_->async_shutdown(timeout); +} + +auto ligo::net::byte_stream::close(boost::system::error_code& error) -> void { + if (state_) { + state_->close(error); + } +} + +auto ligo::net::byte_stream::is_open() const -> std::bool_t { + if (state_) { + return state_->is_open(); + } + return false; +} + +ligo::net::byte_stream::byte_stream(std::shared_ptr state) : state_{std::move(state)} {} + +ligo::net::listener::~listener() { + close(); +} + +auto ligo::net::listener::operator=(ligo::net::listener&& other) noexcept -> ligo::net::listener& { + if (this != &other) { + close(); + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::net::listener::bind(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint) -> std::expected { + auto state = std::make_shared(std::move(executor)); + auto selected = ligo::detail::start_tcp_acceptor(state->acceptor, endpoint.host, endpoint.port); + if (!selected) { + return ligo::detail::fail(selected.error()); + } + state->selected_port.store(*selected); + assert(state->selected_port.load() == *selected); + state->closed.store(false); + modulus::log_debug("ligo net listener bound on port " + std::to_string(*selected)); + return modulus::success(ligo::net::listener{ + ligo::detail::state_handle{std::move(state)}, + }); +} + +auto ligo::net::listener::selected_port() const -> std::uint16_t { + if (auto state = listener_state(state_)) { + return state->selected_port.load(); + } + return 0; +} + +auto ligo::net::listener::async_accept() -> boost::asio::awaitable> { + co_return co_await accept_socket(listener_state(state_)); +} + +auto ligo::net::listener::async_accept_tls_configured(ligo::security_config security, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto context = make_server_tls_context(security); + if (!context) { + co_return ligo::detail::fail(context.error()); + } + auto accepted = co_await accept_socket(listener_state(state_), timeout); + if (!accepted) { + co_return ligo::detail::fail(accepted.error()); + } + auto socket = accepted->release_socket(); + if (!socket) { + co_return ligo::detail::fail(socket.error()); + } + co_return std::expected{ + std::in_place, + ligo::tcp::tls_socket{ + std::move(*context), + std::move(*socket), + }, + }; +} + +auto ligo::net::listener::async_accept_tls_handshaken(ligo::security_config security, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto accepted = co_await async_accept_tls_configured(std::move(security), timeout); + if (!accepted) { + co_return ligo::detail::fail(accepted.error()); + } + auto stream = accepted->stream(); + if (!stream) { + co_return ligo::detail::fail(stream.error()); + } + auto handshake = co_await ligo::detail::async_tls_handshake(stream->get(), boost::asio::ssl::stream_base::server, timeout, "TCP TLS handshake timed out"); + if (!handshake) { + co_return ligo::detail::fail(handshake.error()); + } + co_return std::expected{ + std::in_place, + std::move(*accepted), + }; +} + +auto ligo::net::listener::close() -> void { + close_listener(listener_state(state_)); +} + +ligo::net::listener::listener(ligo::detail::state_handle state) : state_{std::move(state)} {} + +auto ligo::net::connect_tcp(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options) -> boost::asio::awaitable> { + co_return co_await ligo::tcp::connect(std::move(executor), std::move(endpoint), options); +} + +auto ligo::net::connect(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options) -> boost::asio::awaitable> { + co_return co_await ligo::net::connect_tcp(std::move(executor), std::move(endpoint), options); +} + +auto ligo::net::connect(boost::asio::any_io_executor executor, ligo::endpoint_config const& config) -> boost::asio::awaitable> { + co_return co_await ligo::tcp::connect(std::move(executor), config); +} + +auto ligo::net::connect_tls_configured(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options, ligo::security_config security) -> boost::asio::awaitable> { + co_return co_await ligo::tcp::connect_tls_configured(std::move(executor), std::move(endpoint), options, std::move(security)); +} + +auto ligo::net::connect_tls_handshaken(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options, ligo::security_config security) -> boost::asio::awaitable> { + co_return co_await ligo::tcp::connect_tls_handshaken(std::move(executor), std::move(endpoint), options, std::move(security)); +} diff --git a/src/security.cpp b/src/security.cpp new file mode 100644 index 0000000..e44e5c5 --- /dev/null +++ b/src/security.cpp @@ -0,0 +1,238 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +auto ssl_failure(std::int32_t code, std::string message) -> modulus::error { + auto const error = ERR_get_error(); + if (error != 0) { + message += ": "; + message += ERR_error_string(error, nullptr); + } + return ligo::detail::make_error(code, std::move(message)); +} + +auto ssl_failure(std::string message) -> modulus::error { + return ssl_failure(ligo::detail::error_unavailable, std::move(message)); +} + +auto apply_verify_mode(boost::asio::ssl::context& context, ligo::verify_mode verify) -> std::expected { + auto const mode = verify == ligo::verify_mode::PEER || verify == ligo::verify_mode::DEFAULTED ? boost::asio::ssl::verify_peer : boost::asio::ssl::verify_none; + auto error = boost::system::error_code{}; + context.set_verify_mode(mode, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + return {}; +} + +auto apply_server_verify_mode(boost::asio::ssl::context& context, ligo::verify_mode verify) -> std::expected { + auto const mode = verify == ligo::verify_mode::PEER ? boost::asio::ssl::verify_peer | boost::asio::ssl::verify_fail_if_no_peer_cert : boost::asio::ssl::verify_none; + auto error = boost::system::error_code{}; + context.set_verify_mode(mode, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + return {}; +} + +auto load_verify_paths(boost::asio::ssl::context& context, ligo::security_config const& config) -> std::expected { + if (config.verify == ligo::verify_mode::NONE) { + return {}; + } + auto error = boost::system::error_code{}; + if (!config.ca_file.empty()) { + context.load_verify_file(config.ca_file, error); + } else { + context.set_default_verify_paths(error); + } + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + return {}; +} + +auto load_server_identity(boost::asio::ssl::context& context, ligo::security_config const& config) -> std::expected { + if (config.certificate_chain_file.empty() && config.private_key_file.empty()) { + return {}; + } + if (config.certificate_chain_file.empty() || config.private_key_file.empty()) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "TLS server certificate chain and private key must both be configured"); + } + auto error = boost::system::error_code{}; + context.use_certificate_chain_file(config.certificate_chain_file, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + context.use_private_key_file(config.private_key_file, boost::asio::ssl::context::pem, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + if (SSL_CTX_check_private_key(context.native_handle()) != 1) { + return ligo::detail::fail_void(ssl_failure(ligo::detail::error_invalid_argument, "TLS server private key does not match certificate")); + } + return {}; +} + +auto make_context(boost::asio::ssl::context::method method, ligo::security_config const& config, std::bool_t server) -> std::expected, modulus::error> { + if (config.mode == ligo::security_mode::DTLS) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "DTLS config cannot create a TLS context"); + } + if (config.tls_context) { + if (server) { + if (config.verify != ligo::verify_mode::DEFAULTED) { + auto verify = apply_server_verify_mode(*config.tls_context, config.verify); + if (!verify) { + return ligo::detail::fail>(verify.error()); + } + } + auto identity = load_server_identity(*config.tls_context, config); + if (!identity) { + return ligo::detail::fail>(identity.error()); + } + } else { + if (config.verify != ligo::verify_mode::DEFAULTED) { + auto verify = apply_verify_mode(*config.tls_context, config.verify); + if (!verify) { + return ligo::detail::fail>(verify.error()); + } + } + if (!config.ca_file.empty()) { + auto verify_paths = load_verify_paths(*config.tls_context, config); + if (!verify_paths) { + return ligo::detail::fail>(verify_paths.error()); + } + } + } + return modulus::success(config.tls_context); + } + auto context = std::make_shared(method); + auto error = boost::system::error_code{}; + context->set_options(boost::asio::ssl::context::default_workarounds | boost::asio::ssl::context::no_compression | boost::asio::ssl::context::no_sslv2 | boost::asio::ssl::context::no_sslv3 | boost::asio::ssl::context::no_tlsv1 | boost::asio::ssl::context::no_tlsv1_1, error); + if (error) { + return ligo::detail::fail>(ligo::detail::error_unavailable, error.message()); + } + if (server) { + auto verify = apply_server_verify_mode(*context, config.verify); + if (!verify) { + return ligo::detail::fail>(verify.error()); + } + auto identity = load_server_identity(*context, config); + if (!identity) { + return ligo::detail::fail>(identity.error()); + } + } else { + auto verify = apply_verify_mode(*context, config.verify); + if (!verify) { + return ligo::detail::fail>(verify.error()); + } + auto verify_paths = load_verify_paths(*context, config); + if (!verify_paths) { + return ligo::detail::fail>(verify_paths.error()); + } + } + return modulus::success(std::move(context)); +} + +} + +auto ligo::make_tls_client_context(ligo::security_config const& config) -> std::expected, modulus::error> { + return make_context(boost::asio::ssl::context::tls_client, config, false); +} + +auto ligo::make_tls_server_context(ligo::security_config const& config) -> std::expected, modulus::error> { + return make_context(boost::asio::ssl::context::tls_server, config, true); +} + +auto ligo::apply_tls_client_verification(boost::asio::ssl::stream& stream, std::string const& host, ligo::security_config const& config) -> std::expected { + return ligo::configure_tls_client_stream(stream, host, config); +} + +auto ligo::configure_tls_client_stream(boost::asio::ssl::stream& stream, std::string const& host, ligo::security_config const& config) -> std::expected { + auto const identity = config.peer_name.empty() ? host : config.peer_name; + if (!identity.empty()) { + if (SSL_set_tlsext_host_name(stream.native_handle(), identity.c_str()) != 1) { + return ligo::detail::fail_void(ssl_failure("failed to configure TLS SNI")); + } + } + if (config.verify == ligo::verify_mode::NONE) { + auto error = boost::system::error_code{}; + stream.set_verify_mode(boost::asio::ssl::verify_none, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + return {}; + } + auto error = boost::system::error_code{}; + stream.set_verify_mode(boost::asio::ssl::verify_peer, error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + if (!identity.empty()) { + stream.set_verify_callback(boost::asio::ssl::host_name_verification(identity), error); + if (error) { + return ligo::detail::fail_void(ligo::detail::error_unavailable, error.message()); + } + } + return {}; +} + +auto ligo::validate_tls_server_config(std::string_view protocol, ligo::security_config const& config) -> std::expected { + if (config.mode != ligo::security_mode::TLS) { + return {}; + } + if (config.tls_context) { + return {}; + } + if (!config.certificate_chain_file.empty() && !config.private_key_file.empty()) { + return {}; + } + if (!config.certificate_chain_file.empty() || !config.private_key_file.empty()) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, std::string{protocol} + " TLS server certificate chain and private key must both be configured"); + } + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, std::string{protocol} + " TLS server requires certificate chain/private key files or an SSL context"); +} + +auto ligo::validate_tls_server_context(std::shared_ptr const& context) -> std::expected { + if (!context) { + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "TLS server requires an SSL context"); + } + if (SSL_CTX_check_private_key(context->native_handle()) != 1) { + return ligo::detail::fail_void(ssl_failure(ligo::detail::error_invalid_argument, "TLS server context requires matching certificate/private key material")); + } + return {}; +} + +auto ligo::unsupported_security(std::string_view protocol, ligo::security_mode mode) -> modulus::error { + auto suffix = std::string{}; + switch (mode) { + case ligo::security_mode::NONE: + suffix = "none"; + break; + case ligo::security_mode::TLS: + suffix = "TLS"; + break; + case ligo::security_mode::DTLS: + suffix = "DTLS"; + break; + } + return ligo::detail::make_error(ligo::detail::error_invalid_argument, std::string{suffix} + " is not supported by " + std::string{protocol}); +} diff --git a/src/service.cpp b/src/service.cpp new file mode 100755 index 0000000..a3db541 --- /dev/null +++ b/src/service.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +auto json_content_type(std::string const& value) -> std::bool_t { + return value == "application/json" || value.starts_with("application/json;"); +} + +} + +ligo::service_router::service_router(ligo::service_router const& other) { + auto lock = std::scoped_lock{other.mutex_}; + handlers_ = other.handlers_; +} + +ligo::service_router::service_router(ligo::service_router&& other) { + auto lock = std::scoped_lock{other.mutex_}; + handlers_ = std::move(other.handlers_); +} + +auto ligo::service_router::operator=(ligo::service_router const& other) -> ligo::service_router& { + if (this != &other) { + auto lock = std::scoped_lock{ + mutex_, + other.mutex_, + }; + handlers_ = other.handlers_; + } + return *this; +} + +auto ligo::service_router::operator=(ligo::service_router&& other) -> ligo::service_router& { + if (this != &other) { + auto lock = std::scoped_lock{ + mutex_, + other.mutex_, + }; + handlers_ = std::move(other.handlers_); + } + return *this; +} + +auto ligo::service_router::add_route(std::string method, std::string route, std::function>(ligo::request_context, boost::json::value)> handler) -> void { + auto wrapped = [handler = std::move(handler)](ligo::request_context context, boost::json::value payload) -> boost::asio::awaitable> { + auto result = co_await handler(std::move(context), std::move(payload)); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return ligo::json_response(std::move(*result)); + }; + add_response_route(std::move(method), std::move(route), std::move(wrapped)); +} + +auto ligo::service_router::add_response_route(std::string method, std::string route, std::function>(ligo::request_context, boost::json::value)> handler) -> void { + auto stored = std::make_shared>(ligo::request_context, boost::json::value)> const>(std::move(handler)); + auto lock = std::scoped_lock{mutex_}; + handlers_[{ + std::move(method), + std::move(route), + }] = std::move(stored); +} + +auto ligo::service_router::call(ligo::request_context context, boost::json::value payload) const -> boost::asio::awaitable> { + auto response = co_await call_response(std::move(context), std::move(payload)); + if (!response) { + co_return ligo::detail::fail(response.error()); + } + if (!json_content_type(response->content_type)) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "service response is not JSON"); + } + auto parse_error = boost::system::error_code{}; + auto value = boost::json::parse(response->body, parse_error); + if (parse_error) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, parse_error.message()); + } + co_return ligo::detail::unwrap_response_envelope(std::move(value), "service"); +} + +auto ligo::service_router::call_response(ligo::request_context context, boost::json::value payload) const -> boost::asio::awaitable> { + auto handler = std::shared_ptr>(ligo::request_context, boost::json::value)> const>{}; + { + auto lock = std::scoped_lock{mutex_}; + auto found = handlers_.find({ + context.method, + context.route, + }); + if (found == handlers_.end()) { + co_return ligo::detail::fail(ligo::detail::error_not_found, "route not found"); + } + handler = found->second; + } + try { + co_return co_await (*handler)(std::move(context), std::move(payload)); + } catch (std::exception const& error) { + co_return ligo::detail::fail(ligo::detail::error_internal, std::string{"handler failed: "} + error.what()); + } catch (...) { + co_return ligo::detail::fail(ligo::detail::error_internal, "handler failed"); + } +} + +auto ligo::json_response(boost::json::value payload) -> ligo::service_response { + return { + .content_type = "application/json", + .body = ligo::detail::serialize_response_envelope(modulus::success(std::move(payload))), + }; +} + +auto ligo::text_response(std::string content_type, std::string body) -> ligo::service_response { + return { + .content_type = std::move(content_type), + .body = std::move(body), + }; +} diff --git a/src/tcp.cpp b/src/tcp.cpp new file mode 100755 index 0000000..931a675 --- /dev/null +++ b/src/tcp.cpp @@ -0,0 +1,791 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct tcp_server_state { + tcp_server_state(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options); + boost::asio::any_io_executor executor; + boost::asio::strand strand; + std::shared_ptr acceptor{}; + ligo::tcp::endpoint endpoint{}; + ligo::tcp::server_options options{}; + std::shared_ptr router{}; + std::shared_ptr> sessions{std::make_shared>()}; + std::shared_ptr>> tls_sessions{std::make_shared>>()}; + std::shared_ptr tls_context{}; + std::atomic_uint16_t selected_port{}; + std::atomic start_called{false}; + std::atomic stopped{false}; + mutable std::mutex lifecycle_mutex{}; +}; + +constexpr auto max_frame_size = std::uint32_t{16U * 1024U * 1024U}; + +struct parsed_tcp_request { + std::string route{}; + boost::json::value payload{}; +}; + +auto encode_size(std::uint32_t size) -> std::array { + return { + static_cast((size >> 24U) & 0xffU), + static_cast((size >> 16U) & 0xffU), + static_cast((size >> 8U) & 0xffU), + static_cast(size & 0xffU), + }; +} + +auto decode_size(std::array const& bytes) -> std::uint32_t { + return (static_cast(bytes[0]) << 24U) | (static_cast(bytes[1]) << 16U) | (static_cast(bytes[2]) << 8U) | static_cast(bytes[3]); +} + +template +auto read_frame_from(S& stream, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto header = std::array{}; + auto header_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [header_error, header_size] = co_await boost::asio::async_read(stream, boost::asio::buffer(header), ligo::detail::deadline_token(header_deadline)); + ligo::detail::cancel_stream_deadline(header_deadline); + if (header_error) { + auto failure_status = ligo::detail::operation_failure(header_error, header_deadline, "TCP request timed out"); + co_return ligo::detail::fail(failure_status); + } + if (header_size != header.size()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "incomplete frame header"); + } + auto const size = decode_size(header); + if (size == 0 || size > max_frame_size) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "invalid frame size"); + } + auto body = std::string(size, static_cast('\0')); + auto body_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [body_error, body_size] = co_await boost::asio::async_read(stream, boost::asio::buffer(body), ligo::detail::deadline_token(body_deadline)); + ligo::detail::cancel_stream_deadline(body_deadline); + if (body_error) { + auto failure_status = ligo::detail::operation_failure(body_error, body_deadline, "TCP request timed out"); + co_return ligo::detail::fail(failure_status); + } + if (body_size != body.size()) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "incomplete frame body"); + } + auto parse_error = boost::system::error_code{}; + auto value = boost::json::parse(body, parse_error); + if (parse_error) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, parse_error.message()); + } + co_return modulus::success(std::move(value)); +} + +template +auto write_frame_to(S& stream, std::string body, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (body.size() > max_frame_size) { + co_return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "frame too large"); + } + auto header = encode_size(static_cast(body.size())); + auto header_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [header_error, header_size] = co_await boost::asio::async_write(stream, boost::asio::buffer(header), ligo::detail::deadline_token(header_deadline)); + ligo::detail::cancel_stream_deadline(header_deadline); + if (header_error) { + co_return ligo::detail::fail_void(ligo::detail::operation_failure(header_error, header_deadline, "TCP request timed out")); + } + if (header_size != header.size()) { + co_return ligo::detail::fail_void(ligo::detail::error_unavailable, "incomplete frame header write"); + } + auto body_deadline = ligo::detail::start_stream_deadline(stream, timeout); + auto [error, body_size] = co_await boost::asio::async_write(stream, boost::asio::buffer(body), ligo::detail::deadline_token(body_deadline)); + ligo::detail::cancel_stream_deadline(body_deadline); + if (error) { + co_return ligo::detail::fail_void(ligo::detail::operation_failure(error, body_deadline, "TCP request timed out")); + } + if (body_size != body.size()) { + co_return ligo::detail::fail_void(ligo::detail::error_unavailable, "incomplete frame body write"); + } + co_return std::expected{}; +} + +auto parse_tcp_request(boost::json::value const& value) -> std::expected { + if (!value.is_object()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP request must be an object"); + } + auto const& object = value.as_object(); + auto route = object.find("route"); + auto payload = object.find("payload"); + if (route == object.end() || !route->value().is_string() || payload == object.end()) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP request missing route or payload"); + } + return modulus::success(parsed_tcp_request{ + .route = std::string{route->value().as_string()}, + .payload = boost::json::value{payload->value()}, + }); +} + +auto serialize_tcp_request(ligo::tcp::request const& request) -> std::expected { + auto envelope = boost::json::object{}; + envelope["route"] = request.route; + envelope["payload"] = request.payload; + auto body = boost::json::serialize(envelope); + if (body.size() > max_frame_size) { + return ligo::detail::fail(ligo::detail::error_invalid_argument, "frame too large"); + } + return modulus::success(std::move(body)); +} + +auto remote_peer(boost::beast::tcp_stream& stream) -> std::string { + auto endpoint_error = boost::system::error_code{}; + auto remote_endpoint = stream.socket().remote_endpoint(endpoint_error); + return endpoint_error ? std::string{} : remote_endpoint.address().to_string(); +} + +auto remote_peer(boost::asio::ssl::stream& stream) -> std::string { + auto endpoint_error = boost::system::error_code{}; + auto remote_endpoint = stream.next_layer().remote_endpoint(endpoint_error); + return endpoint_error ? std::string{} : remote_endpoint.address().to_string(); +} + +template +auto handle_session(STREAM& stream, std::shared_ptr router, ligo::tcp::server_options options) -> boost::asio::awaitable { + assert(router != nullptr); + auto request_value = co_await read_frame_from(stream, options.timeout); + if (!request_value) { + auto written = co_await write_frame_to(stream, ligo::detail::serialize_response_envelope(ligo::detail::fail(request_value.error())), options.timeout); + ligo::detail::report_error(options.on_error, written); + co_return; + } + auto parsed_request = parse_tcp_request(*request_value); + if (!parsed_request) { + auto written = co_await write_frame_to(stream, ligo::detail::serialize_response_envelope(ligo::detail::fail(parsed_request.error())), options.timeout); + ligo::detail::report_error(options.on_error, written); + co_return; + } + auto context = ligo::request_context{ + .peer = remote_peer(stream), + .method = "TCP", + .route = std::move(parsed_request->route), + }; + auto result = co_await ligo::detail::call_handler(*router, std::move(context), std::move(parsed_request->payload)); + auto written = co_await write_frame_to(stream, ligo::detail::serialize_response_envelope(std::move(result)), options.timeout); + ligo::detail::report_error(options.on_error, written); +} + +auto serve_session(std::shared_ptr stream, std::shared_ptr router, ligo::tcp::server_options options, std::shared_ptr> registry) -> boost::asio::awaitable { + auto guard = ligo::detail::session_guard{ + .registry = std::move(registry), + .stream = stream, + }; + co_await handle_session(*stream, std::move(router), options); + guard.release(); +} + +auto serve_tls_session(std::shared_ptr> stream, std::shared_ptr router, ligo::tcp::server_options options, std::shared_ptr>> registry) -> boost::asio::awaitable { + auto guard = ligo::detail::session_guard>{ + .registry = std::move(registry), + .stream = stream, + }; + auto handshake = co_await ligo::detail::async_tls_handshake(*stream, boost::asio::ssl::stream_base::server, options.timeout, "TCP TLS handshake timed out"); + if (!handshake) { + ligo::detail::report_error(options.on_error, handshake); + guard.release(); + co_return; + } + co_await handle_session(*stream, std::move(router), options); + guard.release(); +} + +auto accept_loop(std::shared_ptr state) -> boost::asio::awaitable { + for (;;) { + auto session_strand = boost::asio::make_strand(state->executor); + auto stream = std::make_shared(session_strand); + auto [error] = co_await state->acceptor->async_accept(stream->socket(), boost::asio::as_tuple(boost::asio::use_awaitable)); + if (error == boost::asio::error::operation_aborted || error == boost::asio::error::bad_descriptor || (error && state->stopped.load())) { + co_return; + } + if (!error) { + state->sessions->add(stream); + auto session_executor = stream->get_executor(); + boost::asio::co_spawn(session_executor, serve_session(std::move(stream), state->router, state->options, state->sessions), boost::asio::detached); + } else { + ligo::detail::report_error(state->options.on_error, ligo::detail::network_failure(error)); + } + } +} + +auto accept_tls_loop(std::shared_ptr state) -> boost::asio::awaitable { + for (;;) { + auto session_strand = boost::asio::make_strand(state->executor); + auto socket = std::make_shared(session_strand); + auto [error] = co_await state->acceptor->async_accept(*socket, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (error == boost::asio::error::operation_aborted || error == boost::asio::error::bad_descriptor || (error && state->stopped.load())) { + co_return; + } + if (!error) { + auto stream = std::make_shared>(std::move(*socket), *state->tls_context); + state->tls_sessions->add(stream); + auto session_executor = stream->get_executor(); + boost::asio::co_spawn(session_executor, serve_tls_session(std::move(stream), state->router, state->options, state->tls_sessions), boost::asio::detached); + } else { + ligo::detail::report_error(state->options.on_error, ligo::detail::network_failure(error)); + } + } +} + +auto tcp_state(ligo::detail::state_handle const& state) -> std::shared_ptr { + return state.as(); +} + +auto uninitialized_tcp_server() -> std::expected { + return ligo::detail::fail_void(ligo::detail::unavailable("TCP server is not initialized")); +} + +auto start_tcp_server(std::shared_ptr const& state) -> std::expected { + assert(!state || state->acceptor != nullptr); + assert(!state || state->router != nullptr); + if (!state) { + return uninitialized_tcp_server(); + } + auto lock = std::scoped_lock{state->lifecycle_mutex}; + auto valid_timeout = ligo::detail::validate_positive_timeout(state->options.timeout, "TCP server timeout"); + if (!valid_timeout) { + return valid_timeout; + } + if (state->options.security.mode == ligo::security_mode::DTLS) { + return ligo::detail::fail_void(ligo::unsupported_security("TCP server", state->options.security.mode)); + } + if (state->start_called.exchange(true)) { + return ligo::detail::fail_void(ligo::detail::unavailable("TCP server has already been started")); + } + if (state->options.security.mode == ligo::security_mode::TLS) { + auto valid_config = ligo::validate_tls_server_config("TCP", state->options.security); + if (!valid_config) { + state->start_called.store(false); + return valid_config; + } + auto context = ligo::make_tls_server_context(state->options.security); + if (!context) { + state->start_called.store(false); + return ligo::detail::fail_void(context.error()); + } + auto valid_context = ligo::validate_tls_server_context(*context); + if (!valid_context) { + state->start_called.store(false); + return valid_context; + } + state->tls_context = std::move(*context); + } + auto selected = ligo::detail::start_tcp_acceptor(*state->acceptor, state->endpoint.host, state->endpoint.port); + if (!selected) { + state->start_called.store(false); + return ligo::detail::fail_void(selected.error()); + } + state->selected_port.store(*selected); + assert(state->selected_port.load() == *selected); + state->stopped.store(false); + modulus::log_debug("ligo TCP server started on port " + std::to_string(*selected)); + if (state->options.security.mode == ligo::security_mode::TLS) { + boost::asio::co_spawn(state->strand, accept_tls_loop(state), boost::asio::detached); + } else { + boost::asio::co_spawn(state->strand, accept_loop(state), boost::asio::detached); + } + return {}; +} + +auto stop_tcp_server(std::shared_ptr state) -> void { + assert(state != nullptr); + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + modulus::log_debug("ligo TCP server stopping"); + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + auto server_executor = state->strand; + boost::asio::post(server_executor, [state = std::move(state)] { + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + }); +} + +auto stop_tcp_server_now(std::shared_ptr const& state) -> void { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + if (state->stopped.exchange(true)) { + return; + } + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); +} + +auto async_stop_tcp_server(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state) { + co_return uninitialized_tcp_server(); + } + auto valid_timeout = ligo::detail::validate_positive_timeout(timeout, "shutdown timeout"); + if (!valid_timeout) { + co_return valid_timeout; + } + auto should_stop = false; + { + auto lock = std::scoped_lock{state->lifecycle_mutex}; + should_stop = !state->stopped.exchange(true); + if (should_stop) { + state->sessions->cancel_all(); + state->tls_sessions->cancel_all(); + } + } + if (should_stop) { + co_await boost::asio::post(state->strand, boost::asio::use_awaitable); + auto ignored = boost::system::error_code{}; + state->acceptor->close(ignored); + } + auto drained = co_await state->sessions->wait_empty(timeout); + if (!drained) { + co_return drained; + } + co_return co_await state->tls_sessions->wait_empty(timeout); +} + +auto selected_tcp_port(std::shared_ptr const& state) -> std::uint16_t { + if (!state) { + return 0; + } + return state->selected_port.load(); +} + +auto failed_tcp_call(modulus::error status) -> boost::asio::awaitable> { + co_return ligo::detail::fail(std::move(status)); +} + +auto call_tls_tcp_endpoint(ligo::tcp::endpoint endpoint, std::shared_ptr request_body, ligo::tcp::client_options options) -> boost::asio::awaitable> { + auto executor = co_await boost::asio::this_coro::executor; + auto socket = co_await ligo::tcp::connect_tls_handshaken(executor, std::move(endpoint), options, options.security); + if (!socket) { + co_return ligo::detail::fail(socket.error()); + } + auto stream = socket->stream(); + if (!stream) { + co_return ligo::detail::fail(stream.error()); + } + auto sent = co_await write_frame_to(stream->get(), *request_body, options.timeout); + if (!sent) { + co_return ligo::detail::fail(sent.error()); + } + auto response = co_await read_frame_from(stream->get(), options.timeout); + if (!response) { + co_return response; + } + co_return ligo::detail::unwrap_response_envelope(std::move(*response), "TCP"); +} + +auto call_tcp_endpoint(ligo::tcp::endpoint endpoint, std::shared_ptr request_body, ligo::tcp::client_options options) -> boost::asio::awaitable> { + assert(request_body != nullptr); + modulus::log_debug("ligo TCP client call to " + endpoint.host + ":" + std::to_string(endpoint.port)); + auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "TCP timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + if (endpoint.port == 0) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP port must be non-zero"); + } + if (options.security.mode == ligo::security_mode::DTLS) { + auto status = ligo::unsupported_security("TCP client", options.security.mode); + co_return ligo::detail::fail(status); + } + if (options.security.mode == ligo::security_mode::TLS) { + co_return co_await call_tls_tcp_endpoint(std::move(endpoint), std::move(request_body), options); + } + auto executor = co_await boost::asio::this_coro::executor; + auto stream = boost::beast::tcp_stream{executor}; + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "TCP request timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + stream.expires_after(options.timeout); + auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "TCP request timed out"); + co_return ligo::detail::fail(failure_status); + } + stream.expires_after(options.timeout); + auto sent = co_await write_frame_to(stream, *request_body, options.timeout); + if (!sent) { + co_return ligo::detail::fail(sent.error()); + } + stream.expires_after(options.timeout); + auto response = co_await read_frame_from(stream, options.timeout); + if (!response) { + co_return response; + } + co_return ligo::detail::unwrap_response_envelope(std::move(*response), "TCP"); +} + +auto connect_tcp_socket(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options) -> boost::asio::awaitable> { + auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "TCP timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + if (options.security.mode != ligo::security_mode::NONE) { + auto status = ligo::unsupported_security("raw TCP connect", options.security.mode); + co_return ligo::detail::fail(status); + } + if (endpoint.port == 0) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP port must be non-zero"); + } + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "TCP connect timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + auto stream = boost::beast::tcp_stream{executor}; + stream.expires_after(options.timeout); + auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "TCP connect timed out"); + co_return ligo::detail::fail(failure_status); + } + co_return modulus::success(ligo::tcp::socket_handle{stream.release_socket()}); +} + +auto validate_tls_connect_security(ligo::security_config const& security) -> std::expected { + if (security.mode == ligo::security_mode::TLS) { + return {}; + } + return ligo::detail::fail_void(ligo::detail::error_invalid_argument, "TLS connect requires TLS security mode"); +} + +tcp_server_state::tcp_server_state(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options) : executor{std::move(executor)}, strand{boost::asio::make_strand(this->executor)}, acceptor{std::make_shared(strand)}, endpoint{std::move(endpoint)}, options{options}, router{std::make_shared(std::move(router))} {} + +} + +ligo::tcp::socket_handle::socket_handle(boost::asio::ip::tcp::socket socket) : socket_{std::make_shared(std::move(socket))} {} + +ligo::tcp::socket_handle::socket_handle(std::shared_ptr socket) : socket_{std::move(socket)} {} + +auto ligo::tcp::socket_handle::socket() -> std::expected, modulus::error> { + if (!socket_) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "TCP socket has been released"); + } + return modulus::success(std::ref(*socket_)); +} + +auto ligo::tcp::socket_handle::socket() const -> std::expected, modulus::error> { + if (!socket_) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "TCP socket has been released"); + } + return modulus::success(std::cref(*socket_)); +} + +auto ligo::tcp::socket_handle::is_open() const -> std::bool_t { + return socket_ && socket_->is_open(); +} + +auto ligo::tcp::socket_handle::close(boost::system::error_code& error) -> void { + if (socket_) { + socket_->close(error); + } +} + +auto ligo::tcp::socket_handle::release_socket() -> std::expected { + if (!socket_) { + return ligo::detail::fail(ligo::detail::error_unavailable, "TCP socket has been released"); + } + auto released = std::move(*socket_); + socket_.reset(); + return std::expected{ + std::in_place, + std::move(released), + }; +} + +ligo::tcp::tls_socket::tls_socket(std::shared_ptr context, boost::asio::ip::tcp::socket socket) : context_{std::move(context)} { + if (context_) { + stream_ = std::make_unique>(std::move(socket), *context_); + } +} + +auto ligo::tcp::tls_socket::stream() -> std::expected>, modulus::error> { + if (!stream_) { + return ligo::detail::fail>>(ligo::detail::error_unavailable, "TCP TLS stream has been released"); + } + return modulus::success(std::ref(*stream_)); +} + +auto ligo::tcp::tls_socket::stream() const -> std::expected const>, modulus::error> { + if (!stream_) { + return ligo::detail::fail const>>(ligo::detail::error_unavailable, "TCP TLS stream has been released"); + } + return modulus::success(std::cref(*stream_)); +} + +auto ligo::tcp::tls_socket::context() -> std::expected, modulus::error> { + if (!context_) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "TCP TLS context is not initialized"); + } + return modulus::success(std::ref(*context_)); +} + +auto ligo::tcp::tls_socket::context() const -> std::expected, modulus::error> { + if (!context_) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "TCP TLS context is not initialized"); + } + return modulus::success(std::cref(*context_)); +} + +auto ligo::tcp::tls_socket::is_open() const -> std::bool_t { + return stream_ && stream_->lowest_layer().is_open(); +} + +auto ligo::tcp::tls_socket::close(boost::system::error_code& error) -> void { + if (stream_) { + stream_->lowest_layer().close(error); + } +} + +auto ligo::tcp::tls_socket::release_stream() -> std::expected { + if (!stream_) { + return ligo::detail::fail(ligo::detail::error_unavailable, "TCP TLS stream has been released"); + } + auto released = std::move(*stream_); + stream_.reset(); + return std::expected{ + std::in_place, + context_, + std::move(released), + }; +} + +ligo::tcp::client::client(ligo::tcp::endpoint endpoint, ligo::tcp::client_options options) : endpoint_{std::move(endpoint)}, options_{options} {} + +auto ligo::tcp::client::call(ligo::tcp::request const& request) const -> boost::asio::awaitable> { + auto body = serialize_tcp_request(request); + if (!body) { + return failed_tcp_call(body.error()); + } + return call_tcp_endpoint(endpoint_, std::make_shared(std::move(*body)), options_); +} + +ligo::tcp::server::server(boost::asio::io_context& io, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options) : ligo::tcp::server{io.get_executor(), std::move(endpoint), std::move(router), options} {} + +ligo::tcp::server::server(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::service_router router, ligo::tcp::server_options options) : state_{ligo::detail::state_handle{std::make_shared(std::move(executor), std::move(endpoint), std::move(router), options)}} {} + +ligo::tcp::server::~server() { + if (auto state = tcp_state(state_)) { + stop_tcp_server_now(state); + } +} + +auto ligo::tcp::server::operator=(ligo::tcp::server&& other) noexcept -> ligo::tcp::server& { + if (this != &other) { + if (auto state = tcp_state(state_)) { + stop_tcp_server_now(state); + } + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::tcp::server::start() -> std::expected { + return start_tcp_server(tcp_state(state_)); +} + +auto ligo::tcp::server::stop() -> void { + if (auto state = tcp_state(state_)) { + stop_tcp_server(std::move(state)); + } +} + +auto ligo::tcp::server::async_stop(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + return async_stop_tcp_server(tcp_state(state_), timeout); +} + +auto ligo::tcp::server::selected_port() const -> std::uint16_t { + return selected_tcp_port(tcp_state(state_)); +} + +auto ligo::tcp::client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + auto parsed = ligo::detail::parse_client_endpoint_text(config.connect); + if (!parsed) { + return ligo::detail::fail(parsed.error()); + } + return modulus::success(ligo::tcp::endpoint{ + .host = std::move(parsed->host), + .port = parsed->port, + }); +} + +auto ligo::tcp::server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + auto parsed = ligo::detail::parse_server_endpoint_text(config.bind); + if (!parsed) { + return ligo::detail::fail(parsed.error()); + } + return modulus::success(ligo::tcp::endpoint{ + .host = std::move(parsed->host), + .port = parsed->port, + }); +} + +auto ligo::tcp::client_options_from_config(ligo::endpoint_config const& config) -> ligo::tcp::client_options { + return { + .timeout = config.timeout, + .security = config.security, + }; +} + +auto ligo::tcp::server_options_from_config(ligo::endpoint_config const& config) -> ligo::tcp::server_options { + return { + .timeout = config.timeout, + .on_error = {}, + .security = config.security, + }; +} + +auto ligo::tcp::connect(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options) -> boost::asio::awaitable> { + co_return co_await connect_tcp_socket(std::move(executor), std::move(endpoint), options); +} + +auto ligo::tcp::connect(boost::asio::any_io_executor executor, ligo::endpoint_config const& config) -> boost::asio::awaitable> { + auto endpoint = ligo::tcp::client_endpoint_from_config(config); + if (!endpoint) { + co_return ligo::detail::fail(endpoint.error()); + } + co_return co_await ligo::tcp::connect(std::move(executor), std::move(*endpoint), ligo::tcp::client_options_from_config(config)); +} + +auto ligo::tcp::connect_tls_configured(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options, ligo::security_config security) -> boost::asio::awaitable> { + auto host = endpoint.host; + auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "TCP timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + auto valid_security = validate_tls_connect_security(security); + if (!valid_security) { + co_return ligo::detail::fail(valid_security.error()); + } + if (endpoint.port == 0) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP port must be non-zero"); + } + auto context = ligo::make_tls_client_context(security); + if (!context) { + co_return ligo::detail::fail(context.error()); + } + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "TCP connect timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + auto stream = boost::beast::tcp_stream{executor}; + stream.expires_after(options.timeout); + auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "TCP connect timed out"); + co_return ligo::detail::fail(failure_status); + } + auto socket = ligo::tcp::tls_socket{ + std::move(*context), + stream.release_socket(), + }; + auto socket_stream = socket.stream(); + if (!socket_stream) { + co_return ligo::detail::fail(socket_stream.error()); + } + auto verification = ligo::configure_tls_client_stream(socket_stream->get(), host, security); + if (!verification) { + co_return ligo::detail::fail(verification.error()); + } + co_return std::expected{ + std::in_place, + std::move(socket), + }; +} + +auto ligo::tcp::connect_tls_handshaken(boost::asio::any_io_executor executor, ligo::tcp::endpoint endpoint, ligo::tcp::client_options options, ligo::security_config security) -> boost::asio::awaitable> { + auto host = endpoint.host; + auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "TCP timeout"); + if (!valid_timeout) { + co_return ligo::detail::fail(valid_timeout.error()); + } + auto valid_security = validate_tls_connect_security(security); + if (!valid_security) { + co_return ligo::detail::fail(valid_security.error()); + } + if (endpoint.port == 0) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "TCP port must be non-zero"); + } + auto context = ligo::make_tls_client_context(security); + if (!context) { + co_return ligo::detail::fail(context.error()); + } + auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "TCP connect timed out"); + if (!endpoints) { + co_return ligo::detail::fail(endpoints.error()); + } + auto stream = boost::beast::tcp_stream{executor}; + stream.expires_after(options.timeout); + auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto failure_status = ligo::detail::operation_failure(connect_error, "TCP connect timed out"); + co_return ligo::detail::fail(failure_status); + } + auto socket = ligo::tcp::tls_socket{ + std::move(*context), + stream.release_socket(), + }; + auto socket_stream = socket.stream(); + if (!socket_stream) { + co_return ligo::detail::fail(socket_stream.error()); + } + auto verification = ligo::apply_tls_client_verification(socket_stream->get(), host, security); + if (!verification) { + co_return ligo::detail::fail(verification.error()); + } + auto handshake = co_await ligo::detail::async_tls_handshake(socket_stream->get(), boost::asio::ssl::stream_base::client, options.timeout, "TCP TLS handshake timed out"); + if (!handshake) { + co_return ligo::detail::fail(handshake.error()); + } + co_return std::expected{ + std::in_place, + std::move(socket), + }; +} diff --git a/src/udp.cpp b/src/udp.cpp new file mode 100755 index 0000000..bbc395d --- /dev/null +++ b/src/udp.cpp @@ -0,0 +1,1079 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +struct ssl_ctx_deleter { + auto operator()(SSL_CTX* context) const noexcept -> void; +}; + +struct ssl_deleter { + auto operator()(SSL* ssl) const noexcept -> void; +}; + +struct bio_deleter { + auto operator()(BIO* bio) const noexcept -> void; +}; + +struct dtls_state { + std::unique_ptr context{}; + std::unique_ptr ssl{}; + std::unique_ptr network_bio{}; + std::bool_t server{}; + std::bool_t handshake_started{}; + std::bool_t handshake_complete{}; +}; + +struct dtls_processed { + std::vector wire{}; + std::vector plaintext{}; +}; + +struct reliable_channel_state { + reliable_channel_state(std::unique_ptr socket, boost::asio::ip::udp::endpoint local_endpoint, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config, ligo::security_config security, std::uint64_t session_id, std::unique_ptr dtls); + std::unique_ptr socket{}; + boost::asio::strand strand{boost::asio::make_strand(socket->get_executor())}; + boost::asio::ip::udp::endpoint local_endpoint{}; + boost::asio::ip::udp::endpoint peer{}; + ligo::udp_reliability_config config{}; + ligo::security_config security{}; + std::unique_ptr dtls{}; + std::uint64_t session_id{1}; + std::uint64_t next_send_sequence{1}; + std::uint64_t next_receive_sequence{1}; + std::uint32_t in_flight{}; + std::uint32_t active_operations{}; + std::array receive_buffer{}; + boost::asio::ip::udp::endpoint receive_sender{}; + std::bool_t receive_loop_started{}; + std::atomic close_requested{}; + std::bool_t closed{}; + std::bool_t receive_waiting{}; + boost::asio::steady_timer notify_timer{strand}; + boost::asio::steady_timer close_timer{strand}; + std::optional receive_error{}; + std::set active_send_sequences{}; + std::set acked{}; + std::map> ack_waiters{}; + std::map pending{}; +}; + +constexpr auto max_datagram_size = std::size_t{65507}; +constexpr auto max_dtls_plaintext_size = std::size_t{12000}; + +auto same_peer(boost::asio::ip::udp::endpoint const& left, boost::asio::ip::udp::endpoint const& right) -> std::bool_t { + return left.address() == right.address() && left.port() == right.port(); +} + +auto uint_member(boost::json::object const& object, std::string_view name) -> std::optional { + auto found = object.find(name); + if (found == object.end() || !found->value().is_number()) { + return std::nullopt; + } + if (found->value().is_int64() && found->value().as_int64() < 0) { + return std::nullopt; + } + if (found->value().is_double()) { + return std::nullopt; + } + return found->value().is_uint64() ? found->value().as_uint64() : static_cast(found->value().as_int64()); +} + +auto string_member(boost::json::object const& object, std::string_view name) -> std::string { + auto found = object.find(name); + if (found == object.end() || !found->value().is_string()) { + return {}; + } + return std::string{found->value().as_string()}; +} + +auto make_data(std::uint64_t session_id, std::uint64_t sequence, boost::json::value payload) -> std::string { + auto envelope = boost::json::object{}; + envelope["kind"] = "data"; + envelope["session"] = session_id; + envelope["sequence"] = sequence; + envelope["payload"] = std::move(payload); + return boost::json::serialize(envelope); +} + +auto make_ack(std::uint64_t session_id, std::uint64_t sequence) -> std::string { + auto envelope = boost::json::object{}; + envelope["kind"] = "ack"; + envelope["session"] = session_id; + envelope["sequence"] = sequence; + return boost::json::serialize(envelope); +} + +auto validate_config(ligo::udp_reliability_config const& config) -> std::expected { + if (config.resend_interval <= std::chrono::milliseconds{0}) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP resend_interval must be positive")); + } + if (config.max_in_flight == 0) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP max_in_flight must be positive")); + } + if (config.session_timeout <= std::chrono::milliseconds{0}) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP session_timeout must be positive")); + } + return {}; +} + +auto dtls_failure(std::int32_t code, std::string message) -> modulus::error { + auto const error = ERR_get_error(); + if (error != 0) { + message += ": "; + message += ERR_error_string(error, nullptr); + } + return ligo::detail::make_error(code, std::move(message)); +} + +auto dtls_failure(std::string message) -> modulus::error { + return dtls_failure(ligo::detail::error_unavailable, std::move(message)); +} + +auto validate_security(ligo::security_config const& config, std::bool_t server) -> std::expected { + if (config.mode == ligo::security_mode::TLS) { + return ligo::detail::fail_void(ligo::unsupported_security("UDP", config.mode)); + } + if (config.mode != ligo::security_mode::DTLS) { + return {}; + } + if (config.tls_context) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP DTLS does not accept a TLS context")); + } + if (server && (config.certificate_chain_file.empty() || config.private_key_file.empty())) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP DTLS server requires certificate chain/private key files")); + } + if (!config.certificate_chain_file.empty() != !config.private_key_file.empty()) { + return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP DTLS certificate chain and private key must both be configured")); + } + return {}; +} + +auto load_dtls_verify_paths(SSL_CTX* context, ligo::security_config const& config) -> std::expected { + if (config.verify == ligo::verify_mode::NONE) { + return {}; + } + if (!config.ca_file.empty()) { + if (SSL_CTX_load_verify_locations(context, config.ca_file.c_str(), nullptr) != 1) { + return ligo::detail::fail_void(dtls_failure("failed to load UDP DTLS CA file")); + } + return {}; + } + if (SSL_CTX_set_default_verify_paths(context) != 1) { + return ligo::detail::fail_void(dtls_failure("failed to load UDP DTLS default verify paths")); + } + return {}; +} + +auto load_dtls_identity(SSL_CTX* context, ligo::security_config const& config) -> std::expected { + if (config.certificate_chain_file.empty() && config.private_key_file.empty()) { + return {}; + } + if (SSL_CTX_use_certificate_chain_file(context, config.certificate_chain_file.c_str()) != 1) { + return ligo::detail::fail_void(dtls_failure("failed to load UDP DTLS certificate chain")); + } + if (SSL_CTX_use_PrivateKey_file(context, config.private_key_file.c_str(), SSL_FILETYPE_PEM) != 1) { + return ligo::detail::fail_void(dtls_failure("failed to load UDP DTLS private key")); + } + if (SSL_CTX_check_private_key(context) != 1) { + return ligo::detail::fail_void(dtls_failure(ligo::detail::error_invalid_argument, "UDP DTLS private key does not match certificate")); + } + return {}; +} + +auto make_dtls_state(ligo::security_config const& config, std::bool_t server) -> std::expected, modulus::error> { + if (config.mode != ligo::security_mode::DTLS) { + return modulus::success(std::unique_ptr{}); + } + auto valid = validate_security(config, server); + if (!valid) { + return ligo::detail::fail>(valid.error()); + } + auto context = std::unique_ptr{SSL_CTX_new(server ? DTLS_server_method() : DTLS_client_method())}; + if (!context) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "failed to create UDP DTLS context"); + } + SSL_CTX_set_options(context.get(), SSL_OP_NO_COMPRESSION | SSL_OP_NO_QUERY_MTU); + if (SSL_CTX_set_min_proto_version(context.get(), DTLS1_2_VERSION) != 1) { + return ligo::detail::fail>(dtls_failure("failed to configure UDP DTLS minimum protocol")); + } + auto const verify_peer = config.verify == ligo::verify_mode::PEER || (!server && config.verify == ligo::verify_mode::DEFAULTED); + auto const verify = verify_peer ? SSL_VERIFY_PEER | (server ? SSL_VERIFY_FAIL_IF_NO_PEER_CERT : 0) : SSL_VERIFY_NONE; + SSL_CTX_set_verify(context.get(), verify, nullptr); + auto verify_paths = load_dtls_verify_paths(context.get(), config); + if (!verify_paths) { + return ligo::detail::fail>(verify_paths.error()); + } + auto identity = load_dtls_identity(context.get(), config); + if (!identity) { + return ligo::detail::fail>(identity.error()); + } + auto ssl = std::unique_ptr{SSL_new(context.get())}; + if (!ssl) { + return ligo::detail::fail>(ligo::detail::error_unavailable, "failed to create UDP DTLS session"); + } + if (!server && verify_peer && !config.peer_name.empty()) { + if (X509_VERIFY_PARAM_set1_host(SSL_get0_param(ssl.get()), config.peer_name.c_str(), 0) != 1) { + return ligo::detail::fail>(dtls_failure("failed to configure UDP DTLS peer identity")); + } + } + SSL_set_mtu(ssl.get(), 1200); + auto* ssl_bio = static_cast(nullptr); + auto* network_bio = static_cast(nullptr); + if (BIO_new_bio_dgram_pair(&ssl_bio, 0, &network_bio, 0) != 1 || ssl_bio == nullptr || network_bio == nullptr) { + BIO_free(ssl_bio); + BIO_free(network_bio); + return ligo::detail::fail>(dtls_failure("failed to create UDP DTLS BIO pair")); + } + BIO_up_ref(ssl_bio); + SSL_set_bio(ssl.get(), ssl_bio, ssl_bio); + if (server) { + SSL_set_accept_state(ssl.get()); + } else { + SSL_set_connect_state(ssl.get()); + } + return modulus::success(std::make_unique(dtls_state{ + .context = std::move(context), + .ssl = std::move(ssl), + .network_bio = std::unique_ptr{network_bio}, + .server = server, + })); +} + +auto drain_dtls_output(dtls_state& state) -> std::expected, modulus::error> { + auto output = std::vector{}; + auto* bio = state.network_bio.get(); + while (BIO_ctrl_pending(bio) > 0) { + auto const pending = static_cast(BIO_ctrl_pending(bio)); + auto datagram = std::string(std::min(pending, max_datagram_size), '\0'); + auto const read = BIO_read(bio, datagram.data(), static_cast(datagram.size())); + if (read <= 0) { + return ligo::detail::fail>(dtls_failure("failed to read UDP DTLS output")); + } + datagram.resize(static_cast(read)); + output.push_back(std::move(datagram)); + } + return modulus::success(std::move(output)); +} + +auto append_dtls_output(std::vector& target, dtls_state& state) -> std::expected { + auto drained = drain_dtls_output(state); + if (!drained) { + return ligo::detail::fail_void(drained.error()); + } + for (auto& datagram : *drained) { + target.push_back(std::move(datagram)); + } + return {}; +} + +auto run_dtls_handshake(dtls_state& state, std::vector& output) -> std::expected { + if (state.handshake_complete) { + return {}; + } + state.handshake_started = true; + auto const result = SSL_do_handshake(state.ssl.get()); + if (result == 1) { + state.handshake_complete = true; + return append_dtls_output(output, state); + } + auto const error = SSL_get_error(state.ssl.get(), result); + if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE) { + return append_dtls_output(output, state); + } + return ligo::detail::fail_void(dtls_failure("UDP DTLS handshake failed")); +} + +auto protect_dtls_payload(reliable_channel_state& state, std::string_view plaintext) -> std::expected, modulus::error> { + if (!state.dtls) { + return modulus::success(std::vector{ + std::string{plaintext}, + }); + } + if (plaintext.size() > max_dtls_plaintext_size) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "UDP DTLS payload is too large"); + } + auto output = std::vector{}; + auto handshake = run_dtls_handshake(*state.dtls, output); + if (!handshake) { + return ligo::detail::fail>(handshake.error()); + } + if (!state.dtls->handshake_complete) { + return modulus::success(std::move(output)); + } + auto const written = SSL_write(state.dtls->ssl.get(), plaintext.data(), static_cast(plaintext.size())); + if (written <= 0) { + auto const error = SSL_get_error(state.dtls->ssl.get(), written); + if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) { + return ligo::detail::fail>(dtls_failure("UDP DTLS write failed")); + } + } + auto drained = append_dtls_output(output, *state.dtls); + if (!drained) { + return ligo::detail::fail>(drained.error()); + } + return modulus::success(std::move(output)); +} + +auto process_dtls_input(reliable_channel_state& state, std::string_view datagram) -> std::expected { + auto processed = dtls_processed{}; + if (!state.dtls) { + processed.plaintext.push_back(std::string{datagram}); + return modulus::success(std::move(processed)); + } + auto const written = BIO_write(state.dtls->network_bio.get(), datagram.data(), static_cast(datagram.size())); + if (written <= 0) { + return ligo::detail::fail(dtls_failure("failed to write UDP DTLS input")); + } + auto handshake = run_dtls_handshake(*state.dtls, processed.wire); + if (!handshake) { + return ligo::detail::fail(handshake.error()); + } + if (!state.dtls->handshake_complete) { + return modulus::success(std::move(processed)); + } + for (;;) { + auto plaintext = std::string(max_dtls_plaintext_size, '\0'); + auto const read = SSL_read(state.dtls->ssl.get(), plaintext.data(), static_cast(plaintext.size())); + if (read > 0) { + plaintext.resize(static_cast(read)); + processed.plaintext.push_back(std::move(plaintext)); + continue; + } + auto const error = SSL_get_error(state.dtls->ssl.get(), read); + if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE || error == SSL_ERROR_ZERO_RETURN) { + break; + } + return ligo::detail::fail(dtls_failure("UDP DTLS read failed")); + } + auto drained = append_dtls_output(processed.wire, *state.dtls); + if (!drained) { + return ligo::detail::fail(drained.error()); + } + return modulus::success(std::move(processed)); +} + +auto channel_state(ligo::detail::state_handle const& handle) -> std::shared_ptr { + return handle.as(); +} + +auto notify_waiters(std::shared_ptr const& state) -> void { + state->notify_timer.cancel(); +} + +auto notify_close_waiters(std::shared_ptr const& state) -> void { + state->close_timer.cancel(); +} + +auto notify_send_waiters(std::shared_ptr const& state) -> void { + for (auto& [_, waiter] : state->ack_waiters) { + if (auto timer = waiter.lock()) { + timer->cancel(); + } + } +} + +auto send_wire_datagrams(std::shared_ptr const& state, std::vector const& datagrams) -> boost::asio::awaitable> { + for (auto const& datagram : datagrams) { + auto [send_error, _] = co_await state->socket->async_send_to(boost::asio::buffer(datagram), state->peer, boost::asio::bind_executor(state->strand, boost::asio::as_tuple(boost::asio::use_awaitable))); + if (send_error) { + co_return ligo::detail::fail_void(ligo::detail::network_failure(send_error)); + } + } + co_return std::expected{}; +} + +auto send_ack(std::shared_ptr const& state, std::uint64_t sequence) -> boost::asio::awaitable> { + auto ack_body = make_ack(state->session_id, sequence); + auto datagrams = protect_dtls_payload(*state, ack_body); + if (!datagrams) { + co_return ligo::detail::fail_void(datagrams.error()); + } + co_return co_await send_wire_datagrams(state, *datagrams); +} + +auto close_state(std::shared_ptr const& state) -> void { + if (!state || state->closed) { + return; + } + state->close_requested.store(true, std::memory_order_release); + state->closed = true; + state->receive_error = ligo::detail::unavailable("UDP channel closed"); + auto ignored = boost::system::error_code{}; + if (state->socket && state->socket->is_open()) { + state->socket->cancel(ignored); + state->socket->close(ignored); + } + notify_send_waiters(state); + notify_waiters(state); + notify_close_waiters(state); +} + +auto receive_loop(std::shared_ptr state) -> boost::asio::awaitable { + for (;;) { + if (state->active_operations == 0) { + state->receive_loop_started = false; + notify_close_waiters(state); + co_return; + } + auto [receive_error, size] = co_await state->socket->async_receive_from(boost::asio::buffer(state->receive_buffer), state->receive_sender, boost::asio::bind_executor(state->strand, boost::asio::as_tuple(boost::asio::use_awaitable))); + if (receive_error) { + if (state->closed) { + state->receive_loop_started = false; + notify_close_waiters(state); + co_return; + } + if (receive_error == boost::asio::error::operation_aborted) { + if (state->active_operations == 0) { + state->receive_loop_started = false; + notify_close_waiters(state); + co_return; + } + continue; + } + state->receive_error = ligo::detail::network_failure(receive_error); + notify_send_waiters(state); + notify_waiters(state); + continue; + } + if (size > max_datagram_size || !same_peer(state->receive_sender, state->peer)) { + continue; + } + auto processed = process_dtls_input(*state, std::string_view{ + state->receive_buffer.data(), + size, + }); + if (!processed) { + state->receive_error = processed.error(); + notify_send_waiters(state); + notify_waiters(state); + continue; + } + auto sent_dtls = co_await send_wire_datagrams(state, processed->wire); + if (!sent_dtls) { + state->receive_error = sent_dtls.error(); + notify_send_waiters(state); + notify_waiters(state); + continue; + } + for (auto& plaintext : processed->plaintext) { + auto parse_error = boost::system::error_code{}; + auto envelope = boost::json::parse(plaintext, parse_error); + if (parse_error || !envelope.is_object()) { + continue; + } + auto const& object = envelope.as_object(); + auto session = uint_member(object, "session"); + auto sequence = uint_member(object, "sequence"); + if (!session.has_value() || *session != state->session_id || !sequence.has_value()) { + continue; + } + auto const kind = string_member(object, "kind"); + if (kind == "ack") { + if (state->active_send_sequences.contains(*sequence)) { + state->acked.insert(*sequence); + if (auto found = state->ack_waiters.find(*sequence); found != state->ack_waiters.end()) { + if (auto timer = found->second.lock()) { + timer->cancel(); + } + } + notify_waiters(state); + } + continue; + } + if (kind != "data") { + continue; + } + auto payload = object.find("payload"); + if (payload == object.end()) { + continue; + } + if (*sequence < state->next_receive_sequence) { + auto ack = co_await send_ack(state, *sequence); + if (!ack) { + state->receive_error = ack.error(); + notify_send_waiters(state); + notify_waiters(state); + } + continue; + } + if (*sequence - state->next_receive_sequence >= state->config.max_in_flight) { + continue; + } + auto ack = co_await send_ack(state, *sequence); + if (!ack) { + state->receive_error = ack.error(); + notify_send_waiters(state); + notify_waiters(state); + continue; + } + if (state->pending.contains(*sequence)) { + continue; + } + state->pending.emplace(*sequence, std::move(payload->value())); + notify_waiters(state); + } + } +} + +auto ensure_receive_loop(std::shared_ptr const& state) -> void { + if (state->receive_loop_started || state->closed) { + return; + } + state->receive_loop_started = true; + boost::asio::co_spawn(state->strand, receive_loop(state), boost::asio::detached); +} + +auto begin_operation(std::shared_ptr const& state) -> std::expected { + if (state->closed || state->close_requested.load(std::memory_order_acquire)) { + return ligo::detail::fail_void(ligo::detail::unavailable("UDP channel closed")); + } + ++state->active_operations; + ensure_receive_loop(state); + return {}; +} + +auto finish_operation(std::shared_ptr const& state) -> void { + if (state->active_operations > 0) { + --state->active_operations; + } + if (state->active_operations == 0) { + auto ignored = boost::system::error_code{}; + if (state->socket && state->socket->is_open()) { + state->socket->cancel(ignored); + } + notify_close_waiters(state); + } +} + +auto finish_send(std::shared_ptr const& state, std::uint64_t sequence) -> void { + if (state->in_flight > 0) { + --state->in_flight; + } + state->active_send_sequences.erase(sequence); + state->acked.erase(sequence); + state->ack_waiters.erase(sequence); + finish_operation(state); +} + +auto udp_endpoint_from_parts(ligo::detail::endpoint_parts const& parts) -> std::expected { + auto error = boost::system::error_code{}; + auto address = boost::asio::ip::make_address(parts.host, error); + if (error) { + return ligo::detail::fail(ligo::detail::error_unavailable, error.message()); + } + return modulus::success(boost::asio::ip::udp::endpoint{ + address, + parts.port, + }); +} + +auto open_state(boost::asio::any_io_executor executor, boost::asio::ip::udp::endpoint local, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config, ligo::security_config security, std::uint64_t session_id, std::bool_t server) -> std::expected, modulus::error> { + auto valid = validate_config(config); + if (!valid) { + return ligo::detail::fail>(valid.error()); + } + auto dtls = make_dtls_state(security, server); + if (!dtls) { + return ligo::detail::fail>(dtls.error()); + } + auto socket = std::make_unique(std::move(executor)); + auto error = boost::system::error_code{}; + socket->open(local.protocol(), error); + if (error) { + auto failure_status = ligo::detail::network_failure(error); + return ligo::detail::fail>(failure_status); + } + socket->bind(local, error); + if (error) { + auto failure_status = ligo::detail::network_failure(error); + return ligo::detail::fail>(failure_status); + } + auto local_endpoint = socket->local_endpoint(error); + if (error) { + auto failure_status = ligo::detail::network_failure(error); + return ligo::detail::fail>(failure_status); + } + return modulus::success(std::make_shared(std::move(socket), local_endpoint, std::move(peer), config, std::move(security), session_id, std::move(*dtls))); +} + +auto adopt_state(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config, ligo::security_config security, std::uint64_t session_id, std::bool_t server) -> std::expected, modulus::error> { + auto valid = validate_config(config); + if (!valid) { + return ligo::detail::fail>(valid.error()); + } + auto dtls = make_dtls_state(security, server); + if (!dtls) { + return ligo::detail::fail>(dtls.error()); + } + if (!socket) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "UDP socket is null"); + } + if (!socket->is_open()) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "UDP socket is not open"); + } + auto error = boost::system::error_code{}; + auto local_endpoint = socket->local_endpoint(error); + if (error || local_endpoint.port() == 0) { + return ligo::detail::fail>(ligo::detail::error_invalid_argument, "UDP socket is not bound"); + } + return modulus::success(std::make_shared(std::move(socket), local_endpoint, std::move(peer), config, std::move(security), session_id, std::move(*dtls))); +} + +auto send_payload_on_strand(std::shared_ptr state, boost::json::value payload) -> boost::asio::awaitable> { + if (state->in_flight >= state->config.max_in_flight) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("too many UDP messages in flight")); + } + auto const sequence = state->next_send_sequence; + auto body = make_data(state->session_id, sequence, std::move(payload)); + if (body.size() > max_datagram_size) { + co_return ligo::detail::fail_void(ligo::detail::invalid_argument("UDP payload is too large")); + } + auto begin = begin_operation(state); + if (!begin) { + co_return begin; + } + ++state->next_send_sequence; + ++state->in_flight; + state->active_send_sequences.insert(sequence); + auto timer = std::make_shared(state->strand); + auto deadline = std::chrono::steady_clock::now() + state->config.session_timeout; + for (;;) { + if (state->closed || state->close_requested.load(std::memory_order_acquire)) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(ligo::detail::unavailable("UDP channel closed")); + } + auto datagrams = protect_dtls_payload(*state, body); + if (!datagrams) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(datagrams.error()); + } + auto sent = co_await send_wire_datagrams(state, *datagrams); + if (state->closed || state->close_requested.load(std::memory_order_acquire)) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(ligo::detail::unavailable("UDP channel closed")); + } + if (!sent) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(sent.error()); + } + + if (state->acked.erase(sequence) > 0) { + finish_send(state, sequence); + co_return std::expected{}; + } + if (state->receive_error.has_value()) { + auto receive_status = *state->receive_error; + state->receive_error.reset(); + finish_send(state, sequence); + co_return ligo::detail::fail_void(receive_status); + } + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(ligo::detail::error_timeout, "timed out waiting for UDP ACK"); + } + auto wake_at = now + state->config.resend_interval; + if (deadline < wake_at) { + wake_at = deadline; + } + timer->expires_at(wake_at); + state->ack_waiters[sequence] = timer; + auto [timer_error] = co_await timer->async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + state->ack_waiters.erase(sequence); + if (state->closed || state->close_requested.load(std::memory_order_acquire)) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(ligo::detail::unavailable("UDP channel closed")); + } + if (timer_error && timer_error != boost::asio::error::operation_aborted) { + finish_send(state, sequence); + co_return ligo::detail::fail_void(ligo::detail::network_failure(timer_error)); + } + if (state->acked.erase(sequence) > 0) { + finish_send(state, sequence); + co_return std::expected{}; + } + if (state->receive_error.has_value()) { + auto receive_status = *state->receive_error; + state->receive_error.reset(); + finish_send(state, sequence); + co_return ligo::detail::fail_void(receive_status); + } + } +} + +auto send_payload(std::shared_ptr state, boost::json::value payload) -> boost::asio::awaitable> { + if (!state) { + co_return ligo::detail::fail_void(ligo::detail::unavailable("UDP channel is not initialized")); + } + co_return co_await boost::asio::co_spawn(state->strand, send_payload_on_strand(state, std::move(payload)), boost::asio::use_awaitable); +} + +auto receive_payload_on_strand(std::shared_ptr state) -> boost::asio::awaitable> { + if (state->receive_waiting) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "UDP receive is already pending"); + } + auto begin = begin_operation(state); + if (!begin) { + co_return ligo::detail::fail(begin.error()); + } + state->receive_waiting = true; + for (;;) { + if (state->closed || state->close_requested.load(std::memory_order_acquire)) { + state->receive_waiting = false; + finish_operation(state); + co_return ligo::detail::fail(ligo::detail::error_unavailable, "UDP channel closed"); + } + if (auto found = state->pending.find(state->next_receive_sequence); found != state->pending.end()) { + auto value = std::move(found->second); + state->pending.erase(found); + ++state->next_receive_sequence; + state->receive_waiting = false; + finish_operation(state); + co_return modulus::success(std::move(value)); + } + if (state->receive_error.has_value()) { + auto receive_status = *state->receive_error; + state->receive_error.reset(); + state->receive_waiting = false; + finish_operation(state); + co_return ligo::detail::fail(receive_status); + } + auto [timer_error] = co_await state->notify_timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + if (timer_error && timer_error != boost::asio::error::operation_aborted) { + state->receive_waiting = false; + finish_operation(state); + auto failure_status = ligo::detail::network_failure(timer_error); + co_return ligo::detail::fail(failure_status); + } + } +} + +auto receive_payload(std::shared_ptr state) -> boost::asio::awaitable> { + if (!state) { + co_return ligo::detail::fail(ligo::detail::error_unavailable, "UDP channel is not initialized"); + } + co_return co_await boost::asio::co_spawn(state->strand, receive_payload_on_strand(state), boost::asio::use_awaitable); +} + +auto receive_message(std::shared_ptr state) -> boost::asio::awaitable> { + auto payload = co_await receive_payload(std::move(state)); + if (!payload) { + co_return ligo::detail::fail(payload.error()); + } + co_return modulus::success(ligo::udp::message{ + .payload = std::move(*payload), + }); +} + +auto local_endpoint_for(std::shared_ptr const& state) -> std::expected { + if (!state || !state->socket) { + return ligo::detail::fail(ligo::detail::error_unavailable, "UDP channel is not initialized"); + } + return modulus::success(state->local_endpoint); +} + +auto close_channel(std::shared_ptr state) -> void { + if (!state) { + return; + } + state->close_requested.store(true, std::memory_order_release); + boost::asio::post(state->strand, [state] { + close_state(state); + }); +} + +auto channel_drained(std::shared_ptr const& state) -> std::bool_t { + return !state || (state->active_operations == 0 && !state->receive_loop_started); +} + +auto async_close_channel_on_strand(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + close_state(state); + if (channel_drained(state)) { + co_return std::expected{}; + } + auto deadline_timer = std::make_shared(state->strand); + auto timed_out = std::make_shared>(false); + deadline_timer->expires_after(timeout); + deadline_timer->async_wait([state, deadline_timer, timed_out](boost::system::error_code const& error) { + if (!error) { + timed_out->store(true, std::memory_order_release); + notify_close_waiters(state); + } + }); + for (;;) { + if (channel_drained(state)) { + deadline_timer->cancel(); + co_return std::expected{}; + } + if (timed_out->load(std::memory_order_acquire)) { + co_return ligo::detail::fail_void(ligo::detail::error_timeout, "timed out waiting for UDP channel to drain"); + } + state->close_timer.expires_at(std::chrono::steady_clock::time_point::max()); + auto [timer_error] = co_await state->close_timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + if (timer_error && timer_error != boost::asio::error::operation_aborted) { + deadline_timer->cancel(); + co_return ligo::detail::fail_void(ligo::detail::network_failure(timer_error)); + } + } +} + +auto async_close_channel(std::shared_ptr state, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + if (!state) { + co_return std::expected{}; + } + auto valid_timeout = ligo::detail::validate_positive_timeout(timeout, "UDP shutdown timeout"); + if (!valid_timeout) { + co_return valid_timeout; + } + co_return co_await boost::asio::co_spawn(state->strand, async_close_channel_on_strand(state, timeout), boost::asio::use_awaitable); +} + +auto endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + auto local_parts = ligo::detail::parse_server_endpoint_text(config.bind); + if (!local_parts) { + return ligo::detail::fail(local_parts.error()); + } + auto peer_parts = ligo::detail::parse_client_endpoint_text(config.connect); + if (!peer_parts) { + return ligo::detail::fail(peer_parts.error()); + } + auto local = udp_endpoint_from_parts(*local_parts); + if (!local) { + return ligo::detail::fail(local.error()); + } + auto peer = udp_endpoint_from_parts(*peer_parts); + if (!peer) { + return ligo::detail::fail(peer.error()); + } + return modulus::success(ligo::udp::endpoint{ + .local = *local, + .peer = *peer, + }); +} + +auto ssl_ctx_deleter::operator()(SSL_CTX* context) const noexcept -> void { + SSL_CTX_free(context); +} + +auto ssl_deleter::operator()(SSL* ssl) const noexcept -> void { + SSL_free(ssl); +} + +auto bio_deleter::operator()(BIO* bio) const noexcept -> void { + BIO_free(bio); +} + +reliable_channel_state::reliable_channel_state(std::unique_ptr socket, boost::asio::ip::udp::endpoint local_endpoint, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config, ligo::security_config security, std::uint64_t session_id, std::unique_ptr dtls) : socket{std::move(socket)}, local_endpoint{std::move(local_endpoint)}, peer{std::move(peer)}, config{config}, security{std::move(security)}, dtls{std::move(dtls)}, session_id{session_id} { + notify_timer.expires_at(std::chrono::steady_clock::time_point::max()); + close_timer.expires_at(std::chrono::steady_clock::time_point::max()); +} + +} + +ligo::udp::client::~client() { + close(); +} + +auto ligo::udp::client::operator=(ligo::udp::client&& other) noexcept -> ligo::udp::client& { + if (this != &other) { + close(); + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::udp::client::open(boost::asio::any_io_executor executor, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options) -> std::expected { + return ligo::udp::client::bind(std::move(executor), { + .local = { + peer.protocol(), + 0, + }, + .peer = std::move(peer), + }, options); +} + +auto ligo::udp::client::open(boost::asio::io_context& io, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options) -> std::expected { + return ligo::udp::client::open(io.get_executor(), std::move(peer), options); +} + +auto ligo::udp::client::bind(boost::asio::any_io_executor executor, ligo::udp::endpoint endpoint, ligo::udp::client_options options) -> std::expected { + auto state = open_state(std::move(executor), std::move(endpoint.local), std::move(endpoint.peer), options.reliability, options.security, options.session_id, false); + if (!state) { + return ligo::detail::fail(state.error()); + } + return modulus::success(ligo::udp::client{ + ligo::detail::state_handle{std::move(*state)}, + }); +} + +auto ligo::udp::client::bind(boost::asio::io_context& io, ligo::udp::endpoint endpoint, ligo::udp::client_options options) -> std::expected { + return ligo::udp::client::bind(io.get_executor(), std::move(endpoint), options); +} + +auto ligo::udp::client::adopt(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp::client_options options) -> std::expected { + auto state = adopt_state(std::move(socket), std::move(peer), options.reliability, options.security, options.session_id, false); + if (!state) { + return ligo::detail::fail(state.error()); + } + return modulus::success(ligo::udp::client{ + ligo::detail::state_handle{std::move(*state)}, + }); +} + +auto ligo::udp::client::send(boost::json::value payload) -> boost::asio::awaitable> { + return send_payload(channel_state(state_), std::move(payload)); +} + +auto ligo::udp::client::send(ligo::udp::message message) -> boost::asio::awaitable> { + return send_payload(channel_state(state_), std::move(message.payload)); +} + +auto ligo::udp::client::receive() -> boost::asio::awaitable> { + return receive_message(channel_state(state_)); +} + +auto ligo::udp::client::local_endpoint() const -> std::expected { + return local_endpoint_for(channel_state(state_)); +} + +auto ligo::udp::client::close() -> void { + close_channel(channel_state(state_)); +} + +auto ligo::udp::client::async_close(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + co_return co_await async_close_channel(channel_state(state_), timeout); +} + +ligo::udp::client::client(ligo::detail::state_handle state) : state_{std::move(state)} {} + +ligo::udp::server::~server() { + close(); +} + +auto ligo::udp::server::operator=(ligo::udp::server&& other) noexcept -> ligo::udp::server& { + if (this != &other) { + close(); + state_ = std::move(other.state_); + } + return *this; +} + +auto ligo::udp::server::bind(boost::asio::any_io_executor executor, ligo::udp::endpoint endpoint, ligo::udp::server_options options) -> std::expected { + auto state = open_state(std::move(executor), std::move(endpoint.local), std::move(endpoint.peer), options.reliability, options.security, options.session_id, true); + if (!state) { + return ligo::detail::fail(state.error()); + } + return modulus::success(ligo::udp::server{ + ligo::detail::state_handle{std::move(*state)}, + }); +} + +auto ligo::udp::server::bind(boost::asio::io_context& io, ligo::udp::endpoint endpoint, ligo::udp::server_options options) -> std::expected { + return ligo::udp::server::bind(io.get_executor(), std::move(endpoint), options); +} + +auto ligo::udp::server::adopt(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp::server_options options) -> std::expected { + auto state = adopt_state(std::move(socket), std::move(peer), options.reliability, options.security, options.session_id, true); + if (!state) { + return ligo::detail::fail(state.error()); + } + return modulus::success(ligo::udp::server{ + ligo::detail::state_handle{std::move(*state)}, + }); +} + +auto ligo::udp::server::send(boost::json::value payload) -> boost::asio::awaitable> { + return send_payload(channel_state(state_), std::move(payload)); +} + +auto ligo::udp::server::send(ligo::udp::message message) -> boost::asio::awaitable> { + return send_payload(channel_state(state_), std::move(message.payload)); +} + +auto ligo::udp::server::receive() -> boost::asio::awaitable> { + return receive_message(channel_state(state_)); +} + +auto ligo::udp::server::local_endpoint() const -> std::expected { + return local_endpoint_for(channel_state(state_)); +} + +auto ligo::udp::server::close() -> void { + close_channel(channel_state(state_)); +} + +auto ligo::udp::server::async_close(std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + co_return co_await async_close_channel(channel_state(state_), timeout); +} + +ligo::udp::server::server(ligo::detail::state_handle state) : state_{std::move(state)} {} + +auto ligo::udp::client_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + return endpoint_from_config(config); +} + +auto ligo::udp::server_endpoint_from_config(ligo::endpoint_config const& config) -> std::expected { + return endpoint_from_config(config); +} + +auto ligo::udp::client_options_from_config(ligo::endpoint_config const& config) -> ligo::udp::client_options { + return { + .reliability = config.udp, + .security = config.security, + }; +} + +auto ligo::udp::server_options_from_config(ligo::endpoint_config const& config) -> ligo::udp::server_options { + return { + .reliability = config.udp, + .security = config.security, + }; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..f4fef33 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,26 @@ +add_executable(ligo-test + test.cpp +) +find_package(Threads REQUIRED) +target_include_directories(ligo-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(ligo-test PRIVATE + ligo::ligo + Threads::Threads +) +target_compile_features(ligo-test PRIVATE cxx_std_23) +target_compile_definitions(ligo-test PRIVATE + $<$:LIGO_EXPECT_DEBUG_ASSERTS> + $<$>:LIGO_EXPECT_NDEBUG> +) +add_test(NAME ligo COMMAND ligo-test) +set_tests_properties(ligo PROPERTIES TIMEOUT 60) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^ligo$ + --output-on-failure + --timeout 60 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..9062eda --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,2256 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +BOOST_AUTO_TEST_CASE(ligo_asserts_follow_build_type) { +#if defined(LIGO_EXPECT_NDEBUG) && !defined(NDEBUG) + BOOST_FAIL("release-style builds must define NDEBUG"); +#endif +#if defined(LIGO_EXPECT_DEBUG_ASSERTS) && defined(NDEBUG) + BOOST_FAIL("debug builds must not define NDEBUG"); +#endif +} + + +namespace { + +auto test_network_failure(boost::system::error_code const& error) -> modulus::error { + return ligo::detail::make_error(ligo::detail::error_unavailable, error.message()); +} + +auto run_two_threads(boost::asio::io_context& io) -> void { + auto first = std::thread{[&] { + io.run(); + }}; + auto second = std::thread{[&] { + io.run(); + }}; + first.join(); + second.join(); +} + +auto start_io_threads(boost::asio::io_context& io) -> std::vector { + auto threads = std::vector{}; + threads.emplace_back([&] { + io.run(); + }); + threads.emplace_back([&] { + io.run(); + }); + return threads; +} + +auto join_io_threads(std::vector& threads) -> void { + for (auto& thread : threads) { + thread.join(); + } +} + +auto delayed_echo(std::chrono::milliseconds delay, boost::json::value payload) -> boost::asio::awaitable> { + auto timer = boost::asio::steady_timer{co_await boost::asio::this_coro::executor}; + timer.expires_after(delay); + co_await timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + co_return modulus::success(std::move(payload)); +} + +auto test_certificate_pem() -> std::string_view { + return R"pem(-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUJxLnHId20YVw1DjhofsjGza7C3AwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcyNDAzMjcyNFoYDzIxMjYw +NjMwMDMyNzI0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC3hGPOsWT4xHja/FgIs/Bj/aIjT+vJiFddPbwtgf68 +gm81cF6zq9oBVWqTXTLQPdURhLN30/k3W8miB4Z0TEfVjhUTwLpa9gPX56zccBaK +dmTE4Gv2+++vXtgDLi8Z8kv3hb1nFABuKtxhB+OYjcoqCEyhhLxrsT1p7WPB7MFQ +johh8mlLixIoIXHFFLuRgkXYN77NuXDv0HW0ur4/YwSyJSSdzpN+KmonrWWVZxgE +30HgZC2MTYStNT3ypXsy2qIcH9AryoshGtaDC0DE7MGv82e3p+8W9iSqPSfjYLWX +ajHszOFtQ0KVGVYoUL0gl+ZwEOn3ZgALJ8F56QzSZ5qJAgMBAAGjbzBtMB0GA1Ud +DgQWBBQ25CRCNG/7URDhEovtBOU0nclsmDAfBgNVHSMEGDAWgBQ25CRCNG/7URDh +EovtBOU0nclsmDAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAMVC7GWYzvuFrvxjeRqgvZru/QMun +6f6j7xPbsM2qyX9kXbEYwvb0U6GgVaXDTYUdn3UJ+e2iXDyjFMHALfJQIAshy58H +h+KSyrPQ1ketj5sO0q0sMWB0cU0nxiPxpOK+HIasdDBGubeTkTwz5AxyGUY9JdPx +F2oRnG1lHNhp2GzdoBCtymer/tgDCxqkg/FAvSJe0lTNJks6AQ1AdjHmT8R0Vhav +dkWkf0MzZNqoT1pIrPssXcnOAeB4KXhtVIgE9XgMgcX3K620eMwt7f4Qv7I0Lb3E +omXFqnkuoidk9CvoXhBkq8o2cwJj4IQ6SrQ6RXvD3DiusGxOXP/GBLi1+Q== +-----END CERTIFICATE----- +)pem"; +} + +auto test_private_key_pem() -> std::string_view { + return R"pem(-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3hGPOsWT4xHja +/FgIs/Bj/aIjT+vJiFddPbwtgf68gm81cF6zq9oBVWqTXTLQPdURhLN30/k3W8mi +B4Z0TEfVjhUTwLpa9gPX56zccBaKdmTE4Gv2+++vXtgDLi8Z8kv3hb1nFABuKtxh +B+OYjcoqCEyhhLxrsT1p7WPB7MFQjohh8mlLixIoIXHFFLuRgkXYN77NuXDv0HW0 +ur4/YwSyJSSdzpN+KmonrWWVZxgE30HgZC2MTYStNT3ypXsy2qIcH9AryoshGtaD +C0DE7MGv82e3p+8W9iSqPSfjYLWXajHszOFtQ0KVGVYoUL0gl+ZwEOn3ZgALJ8F5 +6QzSZ5qJAgMBAAECggEADCJ7bQfq2s43BZfpBE5Np3b2eSpIN4/F4xr5bfLqErH3 +Q4vWH0BOTodE4FCp0v47sAhby6EfGgt+NjIUMG141ISsHR3B1NDuqSCfHAZnXyl7 ++U6zpOW80BZP50nvAGK2pjvYLdAzRRWdc+cPJB/ADe51jrqVsPNL0czKK2EJ2Vcu +qa3YGAgeU485BG5kj9hfSY7ISYdL53iVGEvBn6ZMAyf/VfgWISKt1wItb4zD9xpw +GoxHnbEoCFO0xbBuqGaSSJZUYXDqmWUXntmocDG0I5TfCHVGI7k3HWoxv3PdgwST +oQSF5HTn7ZkLCUqn1CEMAqIqR9qfgOEoKV3WRqOK+QKBgQDqc+QWK6zZSWQrXQez +c0aGbNwhnsmiQFav+NGHJs7X6mP+8LKtYpsy8u9JP5YEBCOdPPy6NhIHo7MVPqTr +fJGWInlu2RMCBKOY/gtz2fZLUGGa0vheH89LXoDw2De892Pof0fHYakXVF+BIgIu +ROs7rome/J9DJdG9SZS8hcAh1QKBgQDIYhuSIosczJIs76l9x97Bo4ijVpm1xQnp +IH6PiUxFgXwPUYSxBBas3yR197LrbSQEC3ECzSIw8zGTR60+4NXIABymRtrcH0pB +CbGoPb9bCNFboLPIyoeItdg08BFQW5xjB/YtCf1VIvQ4MPKKy2+xmRTe0BIvQ6Oj +VaKURCh75QKBgQCO5k4qOTqkQbhdOdm8xdFBk85ka40IaOCtZyIGl1/ReJq0Q+8U +ARsJZcr1QV1lW0R9zTNZKCW7JEq98y3AHm/6K08KgiK0wzEXyhge58XXx8mF/XrG +m+rUTGiY46Cte7gYeKO+YccXYiTH53ueygD6onpdPiqwWbfKALQ4Z+lgdQKBgByl +cd2VVJ/cWvVTmb5iiWTTVSBMrBLBRWZvf8hTT3UMSskB1V9c/0TdbZ/ztj9TzfdK +D3DgFIg87BRrBchcahN6eJ4IuRcI3huoIB62HRoNi6h2s9z7nx6bKHqA62i6gAPk +Snwr1myKX7F8xd+zr5cLFGG6Cy94kvcxsdlSWRtFAoGANZrGt1EWuVq5nBy3M5+M +PNntbt0H4u/fLbsvZ7n8wqLwbvlWpjdXaUHSGm86rhIdO1oUGpTespZZ9VhorWBi +sDPpFj7fJUR44k3t5Bmebe71mkbCEj2cRRO/3Auw0c/kfyE28wCRyZ/AuNGtm8xj +vTua3WDpZe9NKsuywyLWkcY= +-----END PRIVATE KEY----- +)pem"; +} + +auto test_tls_server_config() -> ligo::security_config { + auto context = ligo::make_tls_server_context({ + .mode = ligo::security_mode::TLS, + }); + BOOST_REQUIRE(context); + auto certificate = test_certificate_pem(); + auto private_key = test_private_key_pem(); + auto error = boost::system::error_code{}; + (*context)->use_certificate_chain(boost::asio::buffer(certificate.data(), certificate.size()), error); + BOOST_REQUIRE_MESSAGE(!error, error.message()); + (*context)->use_private_key(boost::asio::buffer(private_key.data(), private_key.size()), boost::asio::ssl::context::file_format::pem, error); + BOOST_REQUIRE_MESSAGE(!error, error.message()); + return { + .mode = ligo::security_mode::TLS, + .verify = ligo::verify_mode::NONE, + .tls_context = std::move(*context), + }; +} + +auto test_tls_client_config() -> ligo::security_config { + return { + .mode = ligo::security_mode::TLS, + .verify = ligo::verify_mode::NONE, + }; +} + +auto write_test_pem_file(std::string name, std::string_view contents) -> std::string { + static auto counter = std::atomic_uint{}; + auto path = "/tmp/ligo-" + name + "-" + std::to_string(counter.fetch_add(1)) + ".pem"; + auto output = std::ofstream{path}; + BOOST_REQUIRE(output); + output << contents; + return path; +} + +auto test_tls_verified_client_config(std::string peer_name = "localhost") -> ligo::security_config { + return { + .mode = ligo::security_mode::TLS, + .verify = ligo::verify_mode::PEER, + .peer_name = std::move(peer_name), + .ca_file = write_test_pem_file("tls-ca", test_certificate_pem()), + }; +} + +auto test_dtls_server_config() -> ligo::security_config { + return { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::NONE, + .certificate_chain_file = write_test_pem_file("dtls-cert", test_certificate_pem()), + .private_key_file = write_test_pem_file("dtls-key", test_private_key_pem()), + }; +} + +auto test_dtls_client_config() -> ligo::security_config { + return { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::NONE, + }; +} + +auto test_dtls_verified_client_config(std::string peer_name = "localhost") -> ligo::security_config { + return { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::PEER, + .peer_name = std::move(peer_name), + .ca_file = write_test_pem_file("dtls-ca", test_certificate_pem()), + }; +} + +auto test_dtls_identified_client_config() -> ligo::security_config { + return { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::NONE, + .certificate_chain_file = write_test_pem_file("dtls-client-cert", test_certificate_pem()), + .private_key_file = write_test_pem_file("dtls-client-key", test_private_key_pem()), + }; +} + +auto test_dtls_server_verify_client_config() -> ligo::security_config { + return { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::PEER, + .ca_file = write_test_pem_file("dtls-client-ca", test_certificate_pem()), + .certificate_chain_file = write_test_pem_file("dtls-server-cert", test_certificate_pem()), + .private_key_file = write_test_pem_file("dtls-server-key", test_private_key_pem()), + }; +} + +auto http_client_call(std::string host, std::uint16_t port, ligo::http::request const& request, ligo::http::client_options options = {}) -> boost::asio::awaitable> { + auto client = ligo::http::client{ + { + .host = std::move(host), + .port = port, + }, + options, + }; + return client.call(request); +} + +auto http_client_call_selected(ligo::http::server& server, ligo::http::request const& request, ligo::http::client_options options = {}) -> boost::asio::awaitable> { + return http_client_call("127.0.0.1", server.selected_port(), request, options); +} + +auto http_client_call_selected_and_stop_impl(ligo::http::server& server, std::shared_ptr request, ligo::http::client_options options) -> boost::asio::awaitable> { + auto result = co_await http_client_call_selected(server, *request, options); + server.stop(); + co_return result; +} + +auto http_client_call_selected_and_stop(ligo::http::server& server, ligo::http::request const& request, ligo::http::client_options options = {}) -> boost::asio::awaitable> { + return http_client_call_selected_and_stop_impl(server, std::make_shared(request), options); +} + +auto http_async_stop_after(ligo::http::server& server, std::chrono::milliseconds delay, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto timer = boost::asio::steady_timer{co_await boost::asio::this_coro::executor}; + timer.expires_after(delay); + co_await timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + co_return co_await server.async_stop(timeout); +} + +auto raw_http_post(std::uint16_t port, std::string target, std::string body) -> boost::asio::awaitable> { + auto executor = co_await boost::asio::this_coro::executor; + auto resolver = boost::asio::ip::tcp::resolver{executor}; + auto socket = boost::asio::ip::tcp::socket{executor}; + auto [resolve_error, endpoints] = co_await resolver.async_resolve("127.0.0.1", std::to_string(port), boost::asio::as_tuple(boost::asio::use_awaitable)); + if (resolve_error) { + auto status = test_network_failure(resolve_error); + co_return ligo::detail::fail(status); + } + auto [connect_error, _] = co_await boost::asio::async_connect(socket, endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto status = test_network_failure(connect_error); + co_return ligo::detail::fail(status); + } + auto request = boost::beast::http::request{ + boost::beast::http::verb::post, + target, + 11, + }; + request.set(boost::beast::http::field::content_type, "application/json"); + request.body() = std::move(body); + request.prepare_payload(); + auto [write_error, written] = co_await boost::beast::http::async_write(socket, request, boost::asio::as_tuple(boost::asio::use_awaitable)); + static_cast(written); + if (write_error) { + auto status = test_network_failure(write_error); + co_return ligo::detail::fail(status); + } + auto buffer = boost::beast::flat_buffer{}; + auto response = boost::beast::http::response{}; + auto [read_error, read] = co_await boost::beast::http::async_read(socket, buffer, response, boost::asio::as_tuple(boost::asio::use_awaitable)); + static_cast(read); + if (read_error) { + auto status = test_network_failure(read_error); + co_return ligo::detail::fail(status); + } + auto parse_error = boost::system::error_code{}; + auto value = boost::json::parse(response.body(), parse_error); + if (parse_error) { + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, parse_error.message()); + } + co_return modulus::success(std::move(value)); +} + +auto raw_http_post_selected_and_stop(ligo::http::server& server, std::string target, std::string body) -> boost::asio::awaitable> { + auto result = co_await raw_http_post(server.selected_port(), std::move(target), std::move(body)); + server.stop(); + co_return result; +} + +auto raw_http_get(std::uint16_t port, std::string target) -> boost::asio::awaitable, modulus::error>> { + auto executor = co_await boost::asio::this_coro::executor; + auto resolver = boost::asio::ip::tcp::resolver{executor}; + auto socket = boost::asio::ip::tcp::socket{executor}; + auto [resolve_error, endpoints] = co_await resolver.async_resolve("127.0.0.1", std::to_string(port), boost::asio::as_tuple(boost::asio::use_awaitable)); + if (resolve_error) { + auto status = test_network_failure(resolve_error); + co_return ligo::detail::fail>(status); + } + auto [connect_error, _] = co_await boost::asio::async_connect(socket, endpoints, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (connect_error) { + auto status = test_network_failure(connect_error); + co_return ligo::detail::fail>(status); + } + auto request = boost::beast::http::request{ + boost::beast::http::verb::get, + target, + 11, + }; + auto [write_error, written] = co_await boost::beast::http::async_write(socket, request, boost::asio::as_tuple(boost::asio::use_awaitable)); + static_cast(written); + if (write_error) { + auto status = test_network_failure(write_error); + co_return ligo::detail::fail>(status); + } + auto buffer = boost::beast::flat_buffer{}; + auto response = boost::beast::http::response{}; + auto [read_error, read] = co_await boost::beast::http::async_read(socket, buffer, response, boost::asio::as_tuple(boost::asio::use_awaitable)); + static_cast(read); + if (read_error) { + auto status = test_network_failure(read_error); + co_return ligo::detail::fail>(status); + } + co_return modulus::success(std::move(response)); +} + +auto raw_http_get_selected_and_stop(ligo::http::server& server, std::string target) -> boost::asio::awaitable, modulus::error>> { + auto result = co_await raw_http_get(server.selected_port(), std::move(target)); + server.stop(); + co_return result; +} + +auto tcp_client_call(std::string host, std::uint16_t port, ligo::tcp::request const& request, ligo::tcp::client_options options = {}) -> boost::asio::awaitable> { + auto client = ligo::tcp::client{ + { + .host = std::move(host), + .port = port, + }, + options, + }; + return client.call(request); +} + +auto tcp_client_call_selected(ligo::tcp::server& server, ligo::tcp::request const& request, ligo::tcp::client_options options = {}) -> boost::asio::awaitable> { + return tcp_client_call("127.0.0.1", server.selected_port(), request, options); +} + +auto tcp_client_call_selected_and_stop_impl(ligo::tcp::server& server, std::shared_ptr request, ligo::tcp::client_options options) -> boost::asio::awaitable> { + auto result = co_await tcp_client_call_selected(server, *request, options); + server.stop(); + co_return result; +} + +auto tcp_client_call_selected_and_stop(ligo::tcp::server& server, ligo::tcp::request const& request, ligo::tcp::client_options options = {}) -> boost::asio::awaitable> { + return tcp_client_call_selected_and_stop_impl(server, std::make_shared(request), options); +} + +auto tcp_async_stop_after(ligo::tcp::server& server, std::chrono::milliseconds delay, std::chrono::milliseconds timeout) -> boost::asio::awaitable> { + auto timer = boost::asio::steady_timer{co_await boost::asio::this_coro::executor}; + timer.expires_after(delay); + co_await timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); + co_return co_await server.async_stop(timeout); +} + +auto accept_and_hold(std::shared_ptr acceptor, std::shared_ptr socket, std::chrono::milliseconds duration) -> boost::asio::awaitable { + auto [accept_error] = co_await acceptor->async_accept(*socket, boost::asio::as_tuple(boost::asio::use_awaitable)); + if (accept_error) { + co_return; + } + auto timer = boost::asio::steady_timer{co_await boost::asio::this_coro::executor}; + timer.expires_after(duration); + co_await timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable)); +} + +auto make_udp_socket(boost::asio::io_context& io) -> std::unique_ptr { + return std::make_unique(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }); +} + +auto udp_client_adopt(std::unique_ptr socket, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config = {}, std::uint64_t session_id = 1) -> std::expected { + return ligo::udp::client::adopt(std::move(socket), std::move(peer), { + .reliability = config, + .session_id = session_id, + }); +} + +auto udp_client_bind(boost::asio::io_context& io, boost::asio::ip::udp::endpoint local, boost::asio::ip::udp::endpoint peer, ligo::udp_reliability_config config = {}, std::uint64_t session_id = 1) -> std::expected { + return ligo::udp::client::bind(io, { + .local = std::move(local), + .peer = std::move(peer), + }, { + .reliability = config, + .session_id = session_id, + }); +} + +auto config_http_tcp_calls_and_stop(ligo::http::server& http_server, ligo::tcp::server& tcp_server) -> boost::asio::awaitable> { + auto tcp_payload = boost::json::object{}; + tcp_payload["ok"] = true; + auto tcp_request = std::make_shared(); + tcp_request->route = "echo"; + tcp_request->payload = std::move(tcp_payload); + auto tcp_options = ligo::tcp::client_options{}; + tcp_options.timeout = std::chrono::milliseconds{1000}; + auto tcp_result = co_await tcp_client_call_selected(tcp_server, *tcp_request, tcp_options); + if (!tcp_result) { + static_cast(co_await tcp_server.async_stop(std::chrono::milliseconds{1000})); + static_cast(co_await http_server.async_stop(std::chrono::milliseconds{1000})); + co_return tcp_result; + } + auto http_payload = boost::json::object{}; + http_payload["ok"] = true; + auto http_request = std::make_shared(); + http_request->target = "/echo"; + http_request->payload = std::move(http_payload); + auto http_options = ligo::http::client_options{}; + http_options.timeout = std::chrono::milliseconds{1000}; + auto http_result = co_await http_client_call_selected(http_server, *http_request, http_options); + static_cast(co_await tcp_server.async_stop(std::chrono::milliseconds{1000})); + static_cast(co_await http_server.async_stop(std::chrono::milliseconds{1000})); + co_return http_result; +} + +} + +BOOST_AUTO_TEST_CASE(ligo_config_drives_http_tcp_and_udp_setup) { + auto io = boost::asio::io_context{}; + auto udp_client_socket = make_udp_socket(io); + auto udp_server_socket = make_udp_socket(io); + auto udp_client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + udp_client_socket->local_endpoint().port(), + }; + auto udp_server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + udp_server_socket->local_endpoint().port(), + }; + auto config_text = std::string{ + "{\"endpoints\":[", + }+ "{\"name\":\"http\",\"protocol\":\"http\",\"bind\":\"127.0.0.1:0\",\"timeout_ms\":1000}," + "{\"name\":\"tcp\",\"protocol\":\"tcp\",\"bind\":\"127.0.0.1:0\",\"timeout_ms\":1000}," + "{\"name\":\"udp-client\",\"protocol\":\"udp\",\"bind\":\"127.0.0.1:" + std::to_string(udp_client_endpoint.port()) + "\",\"connect\":\"127.0.0.1:" + std::to_string(udp_server_endpoint.port()) + "\",\"udp\":{\"resend_interval_ms\":5,\"max_in_flight\":2,\"session_timeout_ms\":1000}}," + "{\"name\":\"udp-server\",\"protocol\":\"udp\",\"bind\":\"127.0.0.1:" + std::to_string(udp_server_endpoint.port()) + "\",\"connect\":\"127.0.0.1:" + std::to_string(udp_client_endpoint.port()) + "\",\"udp\":{\"resend_interval_ms\":5,\"max_in_flight\":2,\"session_timeout_ms\":1000}}" + "]}"; + auto configs = ligo::parse_endpoint_configs(boost::json::parse(config_text)); + BOOST_REQUIRE(configs); + BOOST_REQUIRE((configs->size()) == (4u)); + auto http_router = ligo::service_router{}; + http_router.add_route("POST", "/echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto http_bind = ligo::http::server_endpoint_from_config((*configs)[0]); + BOOST_REQUIRE(http_bind); + auto http_server = ligo::http::server{ + io, + *http_bind, + std::move(http_router), + ligo::http::server_options_from_config((*configs)[0]), + }; + BOOST_REQUIRE(http_server.start()); + auto tcp_router = ligo::service_router{}; + tcp_router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto tcp_bind = ligo::tcp::server_endpoint_from_config((*configs)[1]); + BOOST_REQUIRE(tcp_bind); + auto tcp_server = ligo::tcp::server{ + io, + *tcp_bind, + std::move(tcp_router), + ligo::tcp::server_options_from_config((*configs)[1]), + }; + BOOST_REQUIRE(tcp_server.start()); + auto udp_client_parts = ligo::udp::client_endpoint_from_config((*configs)[2]); + auto udp_server_parts = ligo::udp::server_endpoint_from_config((*configs)[3]); + BOOST_REQUIRE(udp_client_parts); + BOOST_REQUIRE(udp_server_parts); + auto udp_client_result = ligo::udp::client::adopt(std::move(udp_client_socket), udp_client_parts->peer, ligo::udp::client_options_from_config((*configs)[2])); + auto udp_server_result = ligo::udp::server::adopt(std::move(udp_server_socket), udp_server_parts->peer, ligo::udp::server_options_from_config((*configs)[3])); + BOOST_REQUIRE(udp_client_result); + BOOST_REQUIRE(udp_server_result); + auto udp_client = std::move(*udp_client_result); + auto udp_server = std::move(*udp_server_result); + auto udp_payload = boost::json::object{}; + udp_payload["ok"] = true; + auto udp_sent = boost::asio::co_spawn(io, udp_client.send(std::move(udp_payload)), boost::asio::use_future); + auto udp_received = boost::asio::co_spawn(io, udp_server.receive(), boost::asio::use_future); + auto done = boost::asio::co_spawn(io, config_http_tcp_calls_and_stop(http_server, tcp_server), boost::asio::use_future); + io.run(); + BOOST_CHECK(udp_sent.get()); + auto udp_message = udp_received.get(); + BOOST_REQUIRE(udp_message); + BOOST_CHECK(udp_message->payload.as_object().at("ok").as_bool()); + auto result = done.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_config_parses_structured_security) { + auto value = boost::json::parse(R"json({"name": "mysql", "protocol": "tcp", "connect": "localhost:3306", "timeout_ms": 2500, "security": {"mode": "tls", "verify": "peer", "peer_name": "db.local", "ca_file": "/tmp/ca.pem", "certificate_chain_file": "/tmp/cert.pem", "private_key_file": "/tmp/key.pem"}, "http": {"max_body_size": 4096}})json"); + auto config = ligo::parse_endpoint_config(value); + BOOST_REQUIRE(config); + BOOST_CHECK((config->security.mode) == (ligo::security_mode::TLS)); + BOOST_CHECK((config->security.verify) == (ligo::verify_mode::PEER)); + BOOST_CHECK((config->security.peer_name) == ("db.local")); + BOOST_CHECK((config->security.ca_file) == ("/tmp/ca.pem")); + BOOST_CHECK((config->security.certificate_chain_file) == ("/tmp/cert.pem")); + BOOST_CHECK((config->security.private_key_file) == ("/tmp/key.pem")); + auto options = ligo::tcp::client_options_from_config(*config); + BOOST_CHECK((options.timeout) == (std::chrono::milliseconds{2500})); + BOOST_CHECK((options.security.mode) == (ligo::security_mode::TLS)); + auto http_options = ligo::http::client_options_from_config(*config); + BOOST_CHECK((http_options.max_body_size) == (4096U)); + auto http_server_options = ligo::http::server_options_from_config(*config); + BOOST_CHECK((http_server_options.max_body_size) == (4096U)); + auto round_trip = ligo::parse_endpoint_config(ligo::to_json(*config)); + BOOST_REQUIRE(round_trip); + BOOST_CHECK((round_trip->security.mode) == (ligo::security_mode::TLS)); + BOOST_CHECK((round_trip->security.verify) == (ligo::verify_mode::PEER)); + BOOST_CHECK((round_trip->http.max_body_size) == (4096U)); +} + +BOOST_AUTO_TEST_CASE(ligo_config_rejects_invalid_http_body_limit) { + auto bad_shape = ligo::parse_endpoint_config(boost::json::parse(R"json({"name": "http", "protocol": "http", "bind": "127.0.0.1:0", "http": 42})json")); + BOOST_CHECK(!bad_shape); + BOOST_CHECK((bad_shape.error().code()) == (ligo::detail::error_invalid_argument)); + auto zero_limit = ligo::parse_endpoint_config(boost::json::parse(R"json({"name": "http", "protocol": "http", "bind": "127.0.0.1:0", "http": {"max_body_size": 0}})json")); + BOOST_CHECK(!zero_limit); + BOOST_CHECK((zero_limit.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_config_rejects_unknown_keys_and_defaults_protocol) { + auto defaults = ligo::parse_endpoint_config(boost::json::parse(R"json({"name": "default-tcp", "connect": "127.0.0.1:7"})json")); + BOOST_REQUIRE(defaults); + BOOST_CHECK((defaults->protocol) == (ligo::protocol::TCP)); + auto unknown_root = ligo::parse_endpoint_configs(boost::json::parse(R"json({"endpoints": [], "endpointz": []})json")); + BOOST_REQUIRE(!unknown_root); + BOOST_CHECK(unknown_root.error().describe().find("config root has unknown key: endpointz") != std::string::npos); + auto unknown_endpoint = ligo::parse_endpoint_config(boost::json::parse(R"json({"name": "bad", "protocol": "tcp", "connect": "127.0.0.1:7", "timeot_ms": 1000})json")); + BOOST_REQUIRE(!unknown_endpoint); + BOOST_CHECK(unknown_endpoint.error().describe().find("endpoint has unknown key: timeot_ms") != std::string::npos); + auto unknown_nested = ligo::parse_endpoint_config(boost::json::parse(R"json({"name": "bad-security", "protocol": "tcp", "security": {"mode": "tls", "verfy": "peer"}})json")); + BOOST_REQUIRE(!unknown_nested); + BOOST_CHECK(unknown_nested.error().describe().find("endpoint security has unknown key: verfy") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(ligo_servers_bind_localhost_hostname) { + auto io = boost::asio::io_context{}; + auto http_server = ligo::http::server{ + io, + { + .host = "localhost", + .port = 0, + }, + ligo::service_router{}, + }; + auto http_started = http_server.start(); + BOOST_REQUIRE(http_started); + BOOST_CHECK((http_server.selected_port()) != (0)); + http_server.stop(); + auto tcp_server = ligo::tcp::server{ + io, + { + .host = "localhost", + .port = 0, + }, + ligo::service_router{}, + }; + auto tcp_started = tcp_server.start(); + BOOST_REQUIRE(tcp_started); + BOOST_CHECK((tcp_server.selected_port()) != (0)); + tcp_server.stop(); +} + +BOOST_AUTO_TEST_CASE(ligo_http_post_round_trip_dispatches_json_handler) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn( + io, + http_client_call_selected_and_stop( + server, + { + .target = "/echo", + .payload = boost::json::parse(R"json({"ok":true})json"), + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_http_get_response_route_serves_text_body) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_response_route("GET", "/setup", [](ligo::request_context context, boost::json::value) -> boost::asio::awaitable> { + co_return ligo::text_response("text/html; charset=utf-8", "" + context.route + ""); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, raw_http_get_selected_and_stop(server, "/setup"), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK((result->result()) == (boost::beast::http::status::ok)); + BOOST_CHECK((std::string{result->at(boost::beast::http::field::content_type)}) == ("text/html; charset=utf-8")); + BOOST_CHECK((result->body()) == ("/setup")); +} + +BOOST_AUTO_TEST_CASE(ligo_http_tls_post_round_trip_dispatches_json_handler) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = test_tls_server_config(), + }, + }; + auto started = server.start(); + BOOST_REQUIRE(started); + auto future = boost::asio::co_spawn( + io, + http_client_call_selected_and_stop( + server, + { + .target = "/echo", + .payload = boost::json::parse(R"json({"ok":true})json"), + }, + { + .timeout = std::chrono::milliseconds{1000}, + .security = test_tls_client_config(), + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_http_tls_server_requires_certificate_context) { + auto io = boost::asio::io_context{}; + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = { + .mode = ligo::security_mode::TLS, + }, + }, + }; + auto status = server.start(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_http_tls_server_rejects_empty_provided_context) { + auto io = boost::asio::io_context{}; + auto context = std::make_shared(boost::asio::ssl::context::tls_server); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = { + .mode = ligo::security_mode::TLS, + .tls_context = context, + }, + }, + }; + auto status = server.start(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_http_get_round_trip_dispatches_method_and_route) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("GET", "/method", [](ligo::request_context context, boost::json::value) -> boost::asio::awaitable> { + auto response = boost::json::object{}; + response["method"] = context.method; + response["route"] = context.route; + co_return modulus::success(std::move(response)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, http_client_call_selected_and_stop(server, { + .method = ligo::http::method::GET, + .target = "/method", + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK((result->as_object().at("method").as_string()) == ("GET")); + BOOST_CHECK((result->as_object().at("route").as_string()) == ("/method")); +} + +BOOST_AUTO_TEST_CASE(ligo_http_missing_route_returns_status) { + auto io = boost::asio::io_context{}; + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, http_client_call_selected_and_stop(server, { + .target = "/missing", + .payload = boost::json::object{}, + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_not_found)); +} + +BOOST_AUTO_TEST_CASE(ligo_http_handler_failure_returns_status) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/boom", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { + co_return ligo::detail::fail(ligo::detail::error_internal, "boom"); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, http_client_call_selected_and_stop(server, { + .target = "/boom", + .payload = boost::json::object{}, + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_internal)); +} + +BOOST_AUTO_TEST_CASE(ligo_http_invalid_json_returns_envelope_status) { + auto io = boost::asio::io_context{}; + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, raw_http_post_selected_and_stop(server, "/missing", "{"), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(!(result->as_object().at("ok").as_bool())); + BOOST_CHECK((result->as_object().at("code").as_string()) == ("invalid_argument")); +} + +BOOST_AUTO_TEST_CASE(ligo_http_server_rejects_oversized_request_body) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + { + .timeout = std::chrono::milliseconds{1000}, + .max_body_size = 32, + }, + }; + BOOST_REQUIRE(server.start()); + auto body = std::string{R"json({"data":")json"} + std::string(64, 'x') + R"json("})json"; + auto future = boost::asio::co_spawn(io, raw_http_post_selected_and_stop(server, "/echo", std::move(body)), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(!(result->as_object().at("ok").as_bool())); + BOOST_CHECK((result->as_object().at("code").as_string()) == ("invalid_argument")); +} + +BOOST_AUTO_TEST_CASE(ligo_http_client_rejects_oversized_response_body) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/large", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { + auto response = boost::json::object{}; + response["data"] = std::string(128, 'x'); + co_return modulus::success(std::move(response)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn( + io, + http_client_call_selected_and_stop( + server, + { + .target = "/large", + .payload = boost::json::object{}, + }, + { + .timeout = std::chrono::milliseconds{1000}, + .max_body_size = 32, + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!result); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_http_concurrent_client_calls_succeed) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto client = ligo::http::client{ + { + .host = "127.0.0.1", + .port = server.selected_port(), + }, + }; + auto work = boost::asio::make_work_guard(io); + auto runners = start_io_threads(io); + auto futures = std::vector>>{}; + for (auto index = 0; index < 16; ++index) { + auto payload = boost::json::object{}; + payload["index"] = index; + futures.push_back(boost::asio::co_spawn(io, client.call({ + .target = "/echo", + .payload = std::move(payload), + }), boost::asio::use_future)); + } + for (auto& future : futures) { + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->is_object()); + } + server.stop(); + work.reset(); + join_io_threads(runners); +} + +BOOST_AUTO_TEST_CASE(ligo_http_async_stop_cancels_active_session) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("POST", "/slow", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return co_await delayed_echo(std::chrono::milliseconds{50}, std::move(payload)); + }); + auto server = ligo::http::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto call = boost::asio::co_spawn( + io, + http_client_call_selected( + server, + { + .target = "/slow", + .payload = boost::json::object{}, + }, + { + .timeout = std::chrono::milliseconds{200}, + } + ), + boost::asio::use_future); + auto stopped = boost::asio::co_spawn(io, http_async_stop_after(server, std::chrono::milliseconds{5}, std::chrono::milliseconds{200}), boost::asio::use_future); + run_two_threads(io); + BOOST_CHECK(!(call.get())); + BOOST_CHECK(stopped.get()); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_round_trip_dispatches_json_handler) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn( + io, + tcp_client_call_selected_and_stop( + server, + { + .route = "echo", + .payload = boost::json::parse(R"json({"ok":true})json"), + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_round_trip_dispatches_json_handler) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = test_tls_server_config(), + }, + }; + auto started = server.start(); + BOOST_REQUIRE(started); + auto future = boost::asio::co_spawn( + io, + tcp_client_call_selected_and_stop( + server, + { + .route = "echo", + .payload = boost::json::parse(R"json({"ok":true})json"), + }, + { + .timeout = std::chrono::milliseconds{1000}, + .security = test_tls_client_config(), + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_verified_peer_succeeds) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::tcp::server{ + io.get_executor(), + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = test_tls_server_config(), + }, + }; + auto started = server.start(); + BOOST_REQUIRE(started); + auto future = boost::asio::co_spawn( + io, + tcp_client_call( + "127.0.0.1", + server.selected_port(), + { + .route = "echo", + .payload = boost::json::parse(R"json({"ok":true})json"), + }, + { + .timeout = std::chrono::milliseconds{1000}, + .security = test_tls_verified_client_config("localhost"), + } + ), + boost::asio::use_future); + auto stopper = boost::asio::steady_timer{io}; + stopper.expires_after(std::chrono::milliseconds{200}); + stopper.async_wait([&server](boost::system::error_code const&) { + server.stop(); + }); + io.run(); + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_hostname_mismatch_fails) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::tcp::server{ + io.get_executor(), + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = test_tls_server_config(), + }, + }; + auto started = server.start(); + BOOST_REQUIRE(started); + auto future = boost::asio::co_spawn( + io, + tcp_client_call( + "127.0.0.1", + server.selected_port(), + { + .route = "echo", + .payload = boost::json::object{}, + }, + { + .timeout = std::chrono::milliseconds{1000}, + .security = test_tls_verified_client_config("wrong.local"), + } + ), + boost::asio::use_future); + auto stopper = boost::asio::steady_timer{io}; + stopper.expires_after(std::chrono::milliseconds{200}); + stopper.async_wait([&server](boost::system::error_code const&) { + server.stop(); + }); + io.run(); + auto result = future.get(); + BOOST_CHECK(!result); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_server_requires_certificate_context) { + auto io = boost::asio::io_context{}; + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = { + .mode = ligo::security_mode::TLS, + }, + }, + }; + auto status = server.start(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_server_rejects_empty_provided_context) { + auto io = boost::asio::io_context{}; + auto context = std::make_shared(boost::asio::ssl::context::tls_server); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + { + .timeout = std::chrono::milliseconds{1000}, + .on_error = {}, + .security = { + .mode = ligo::security_mode::TLS, + .tls_context = context, + }, + }, + }; + auto status = server.start(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_missing_route_returns_status) { + auto io = boost::asio::io_context{}; + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + ligo::service_router{}, + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, tcp_client_call_selected_and_stop(server, { + .route = "missing", + .payload = boost::json::object{}, + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_not_found)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_handler_failure_returns_status) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "boom", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { + co_return ligo::detail::fail(ligo::detail::error_internal, "boom"); + }); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto future = boost::asio::co_spawn(io, tcp_client_call_selected_and_stop(server, { + .route = "boom", + .payload = boost::json::object{}, + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_internal)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_connection_refused_returns_unavailable) { + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn(io, tcp_client_call("127.0.0.1", 9, { + .route = "echo", + .payload = boost::json::object{}, + }), boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_stalled_peer_times_out) { + auto io = boost::asio::io_context{}; + auto acceptor = std::make_shared(io, boost::asio::ip::tcp::endpoint{ + boost::asio::ip::tcp::v4(), + 0, + }); + auto held_socket = std::make_shared(io); + boost::asio::co_spawn(io, accept_and_hold(acceptor, held_socket, std::chrono::milliseconds{50}), boost::asio::detached); + auto future = boost::asio::co_spawn( + io, + tcp_client_call( + "127.0.0.1", + acceptor->local_endpoint().port(), + { + .route = "echo", + .payload = boost::json::object{}, + }, + { + .timeout = std::chrono::milliseconds{20}, + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_timeout)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_concurrent_client_calls_succeed) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "echo", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return modulus::success(std::move(payload)); + }); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto client = ligo::tcp::client{ + { + .host = "127.0.0.1", + .port = server.selected_port(), + }, + }; + auto work = boost::asio::make_work_guard(io); + auto runners = start_io_threads(io); + auto futures = std::vector>>{}; + for (auto index = 0; index < 16; ++index) { + auto payload = boost::json::object{}; + payload["index"] = index; + futures.push_back(boost::asio::co_spawn(io, client.call({ + .route = "echo", + .payload = std::move(payload), + }), boost::asio::use_future)); + } + for (auto& future : futures) { + auto result = future.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->is_object()); + } + server.stop(); + work.reset(); + join_io_threads(runners); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_async_stop_cancels_active_session) { + auto io = boost::asio::io_context{}; + auto router = ligo::service_router{}; + router.add_route("TCP", "slow", [](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { + co_return co_await delayed_echo(std::chrono::milliseconds{50}, std::move(payload)); + }); + auto server = ligo::tcp::server{ + io, + { + .host = "127.0.0.1", + .port = 0, + }, + std::move(router), + }; + BOOST_REQUIRE(server.start()); + auto call = boost::asio::co_spawn( + io, + tcp_client_call_selected( + server, + { + .route = "slow", + .payload = boost::json::object{}, + }, + { + .timeout = std::chrono::milliseconds{200}, + } + ), + boost::asio::use_future); + auto stopped = boost::asio::co_spawn(io, tcp_async_stop_after(server, std::chrono::milliseconds{5}, std::chrono::milliseconds{200}), boost::asio::use_future); + run_two_threads(io); + BOOST_CHECK(!(call.get())); + BOOST_CHECK(stopped.get()); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_connect_returns_connected_socket) { + auto io = boost::asio::io_context{}; + auto acceptor = std::make_shared(io, boost::asio::ip::tcp::endpoint{ + boost::asio::ip::tcp::v4(), + 0, + }); + auto held_socket = std::make_shared(io); + boost::asio::co_spawn(io, accept_and_hold(acceptor, held_socket, std::chrono::milliseconds{10}), boost::asio::detached); + auto result = boost::asio::co_spawn( + io, + ligo::tcp::connect( + io.get_executor(), + { + .host = "127.0.0.1", + .port = acceptor->local_endpoint().port(), + }, + { + .timeout = std::chrono::milliseconds{1000}, + } + ), + boost::asio::use_future); + io.run(); + auto socket = result.get(); + BOOST_REQUIRE(socket); + BOOST_TEST(socket->is_open()); + auto ignored = boost::system::error_code{}; + socket->close(ignored); +} + +BOOST_AUTO_TEST_CASE(ligo_net_connect_returns_connected_socket) { + auto io = boost::asio::io_context{}; + auto acceptor = std::make_shared(io, boost::asio::ip::tcp::endpoint{ + boost::asio::ip::tcp::v4(), + 0, + }); + auto held_socket = std::make_shared(io); + boost::asio::co_spawn(io, accept_and_hold(acceptor, held_socket, std::chrono::milliseconds{10}), boost::asio::detached); + auto result = boost::asio::co_spawn( + io, + ligo::net::connect( + io.get_executor(), + { + .host = "127.0.0.1", + .port = acceptor->local_endpoint().port(), + }, + { + .timeout = std::chrono::milliseconds{1000}, + } + ), + boost::asio::use_future); + io.run(); + auto socket = result.get(); + BOOST_REQUIRE(socket); + BOOST_TEST(socket->is_open()); + auto ignored = boost::system::error_code{}; + socket->close(ignored); +} + +BOOST_AUTO_TEST_CASE(ligo_net_listener_accept_returns_socket) { + auto io = boost::asio::io_context{}; + auto listener_result = ligo::net::listener::bind(io.get_executor(), { + .host = "127.0.0.1", + .port = 0, + }); + BOOST_REQUIRE(listener_result); + auto listener = std::move(*listener_result); + BOOST_CHECK((listener.selected_port()) != (0)); + auto accepted = boost::asio::co_spawn(io, listener.async_accept(), boost::asio::use_future); + auto connected = boost::asio::co_spawn( + io, + ligo::net::connect_tcp( + io.get_executor(), + { + .host = "127.0.0.1", + .port = listener.selected_port(), + }, + { + .timeout = std::chrono::milliseconds{1000}, + } + ), + boost::asio::use_future); + io.run(); + auto accepted_socket = accepted.get(); + auto connected_socket = connected.get(); + BOOST_REQUIRE(accepted_socket); + BOOST_REQUIRE(connected_socket); + BOOST_CHECK(accepted_socket->is_open()); + BOOST_CHECK(connected_socket->is_open()); + auto ignored = boost::system::error_code{}; + accepted_socket->close(ignored); + connected_socket->close(ignored); + listener.close(); +} + +BOOST_AUTO_TEST_CASE(ligo_net_byte_stream_round_trips_over_tcp) { + auto io = boost::asio::io_context{}; + auto listener_result = ligo::net::listener::bind(io.get_executor(), { + .host = "127.0.0.1", + .port = 0, + }); + BOOST_REQUIRE(listener_result); + auto listener = std::move(*listener_result); + auto accepted = boost::asio::co_spawn(io, listener.async_accept(), boost::asio::use_future); + auto connected = boost::asio::co_spawn( + io, + ligo::net::connect_tcp( + io.get_executor(), + { + .host = "127.0.0.1", + .port = listener.selected_port(), + }, + { + .timeout = std::chrono::milliseconds{1000}, + } + ), + boost::asio::use_future); + io.run(); + auto accepted_socket = accepted.get(); + auto connected_socket = connected.get(); + BOOST_REQUIRE(accepted_socket); + BOOST_REQUIRE(connected_socket); + auto accepted_stream = ligo::net::byte_stream::from_socket(std::move(*accepted_socket)); + auto connected_stream = ligo::net::byte_stream::from_socket(std::move(*connected_socket)); + BOOST_REQUIRE(accepted_stream); + BOOST_REQUIRE(connected_stream); + auto payload = std::string{"hello"}; + auto buffer = std::string(payload.size(), '\0'); + io.restart(); + auto read = boost::asio::co_spawn(io, accepted_stream->async_read_some(boost::asio::buffer(buffer), std::chrono::milliseconds{1000}), boost::asio::use_future); + auto written = boost::asio::co_spawn(io, connected_stream->async_write_all(boost::asio::buffer(payload), std::chrono::milliseconds{1000}), boost::asio::use_future); + io.run(); + auto read_result = read.get(); + BOOST_REQUIRE(read_result); + BOOST_CHECK((*read_result) == payload.size()); + BOOST_CHECK(buffer == payload); + BOOST_CHECK(written.get()); + auto ignored = boost::system::error_code{}; + accepted_stream->close(ignored); + connected_stream->close(ignored); + listener.close(); +} + +BOOST_AUTO_TEST_CASE(ligo_net_listener_tls_accept_rejects_non_tls_security_modes) { + auto io = boost::asio::io_context{}; + auto listener_result = ligo::net::listener::bind(io.get_executor(), { + .host = "127.0.0.1", + .port = 0, + }); + BOOST_REQUIRE(listener_result); + auto listener = std::move(*listener_result); + auto none = boost::asio::co_spawn(io, listener.async_accept_tls_configured({ + .mode = ligo::security_mode::NONE, + }, std::chrono::milliseconds{1000}), boost::asio::use_future); + io.run(); + auto result = none.get(); + BOOST_CHECK(!result); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_invalid_argument)); + listener.close(); +} + +BOOST_AUTO_TEST_CASE(ligo_net_listener_tls_handshake_succeeds) { + auto io = boost::asio::io_context{}; + auto listener_result = ligo::net::listener::bind(io.get_executor(), { + .host = "127.0.0.1", + .port = 0, + }); + BOOST_REQUIRE(listener_result); + auto listener = std::move(*listener_result); + auto accepted = boost::asio::co_spawn(io, listener.async_accept_tls_handshaken(test_tls_server_config(), std::chrono::milliseconds{1000}), boost::asio::use_future); + auto connected = boost::asio::co_spawn( + io, + ligo::net::connect_tls_handshaken( + io.get_executor(), + { + .host = "127.0.0.1", + .port = listener.selected_port(), + }, + { + .timeout = std::chrono::milliseconds{1000}, + }, + test_tls_client_config() + ), + boost::asio::use_future); + io.run(); + auto accepted_socket = accepted.get(); + auto connected_socket = connected.get(); + BOOST_REQUIRE(accepted_socket); + BOOST_REQUIRE(connected_socket); + BOOST_CHECK(accepted_socket->is_open()); + BOOST_CHECK(connected_socket->is_open()); + auto ignored = boost::system::error_code{}; + accepted_socket->close(ignored); + connected_socket->close(ignored); + listener.close(); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_raw_connect_rejects_secure_modes) { + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn( + io, + ligo::tcp::connect( + io.get_executor(), + { + .host = "127.0.0.1", + .port = 3306, + }, + { + .timeout = std::chrono::milliseconds{1000}, + .security = { + .mode = ligo::security_mode::TLS, + }, + } + ), + boost::asio::use_future); + io.run(); + auto result = future.get(); + BOOST_CHECK(!result); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_connect_rejects_non_tls_security_modes) { + auto io = boost::asio::io_context{}; + auto none = boost::asio::co_spawn( + io, + ligo::tcp::connect_tls_handshaken( + io.get_executor(), + { + .host = "127.0.0.1", + .port = 3306, + }, + { + .timeout = std::chrono::milliseconds{1000}, + }, + { + .mode = ligo::security_mode::NONE, + } + ), + boost::asio::use_future); + auto dtls = boost::asio::co_spawn( + io, + ligo::tcp::connect_tls_configured( + io.get_executor(), + { + .host = "127.0.0.1", + .port = 3306, + }, + { + .timeout = std::chrono::milliseconds{1000}, + }, + { + .mode = ligo::security_mode::DTLS, + } + ), + boost::asio::use_future); + io.run(); + auto none_result = none.get(); + BOOST_CHECK(!none_result); + BOOST_CHECK((none_result.error().code()) == (ligo::detail::error_invalid_argument)); + auto dtls_result = dtls.get(); + BOOST_CHECK(!dtls_result); + BOOST_CHECK((dtls_result.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_socket_handle_is_move_only_and_release_is_checked) { + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + static_assert(std::is_move_assignable_v); + auto io = boost::asio::io_context{}; + auto handle = ligo::tcp::socket_handle{ + boost::asio::ip::tcp::socket{io}, + }; + auto socket = handle.socket(); + BOOST_REQUIRE(socket); + auto released = handle.release_socket(); + BOOST_REQUIRE(released); + BOOST_CHECK(!(handle.is_open())); + auto released_again = handle.release_socket(); + BOOST_CHECK(!(released_again)); + BOOST_CHECK((released_again.error().code()) == (ligo::detail::error_unavailable)); + auto socket_again = handle.socket(); + BOOST_CHECK(!(socket_again)); + BOOST_CHECK((socket_again.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_tcp_tls_socket_release_is_checked) { + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + static_assert(std::is_move_assignable_v); + auto io = boost::asio::io_context{}; + auto context = ligo::make_tls_client_context({ + .mode = ligo::security_mode::TLS, + .verify = ligo::verify_mode::NONE, + }); + BOOST_REQUIRE(context); + auto handle = ligo::tcp::tls_socket{ + std::move(*context), + boost::asio::ip::tcp::socket{io}, + }; + auto stream_ref = handle.stream(); + BOOST_REQUIRE(stream_ref); + auto stream = handle.release_stream(); + BOOST_REQUIRE(stream); + BOOST_REQUIRE(stream->context); + auto stream_again = handle.release_stream(); + BOOST_CHECK(!(stream_again)); + BOOST_CHECK((stream_again.error().code()) == (ligo::detail::error_unavailable)); + auto stream_ref_again = handle.stream(); + BOOST_CHECK(!(stream_ref_again)); + BOOST_CHECK((stream_ref_again.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_reliable_round_trip_succeeds) { + auto io = boost::asio::io_context{}; + auto client_socket = make_udp_socket(io); + auto server_socket = make_udp_socket(io); + auto client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + client_socket->local_endpoint().port(), + }; + auto server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + server_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{1000}, + }; + auto client_result = ligo::udp::client::adopt(std::move(client_socket), server_endpoint, { + .reliability = config, + .security = test_dtls_client_config(), + .session_id = 142, + }); + auto server_result = ligo::udp::server::adopt(std::move(server_socket), client_endpoint, { + .reliability = config, + .security = test_dtls_server_config(), + .session_id = 142, + }); + BOOST_REQUIRE(client_result); + BOOST_REQUIRE(server_result); + auto client = std::move(*client_result); + auto server = std::move(*server_result); + auto sent = boost::asio::co_spawn(io, client.send(boost::json::parse(R"json({"secure":true})json")), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, server.receive(), boost::asio::use_future); + io.run(); + auto sent_status = sent.get(); + BOOST_CHECK(sent_status); + auto result = received.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->payload.as_object().at("secure").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_verified_peer_succeeds) { + auto io = boost::asio::io_context{}; + auto client_socket = make_udp_socket(io); + auto server_socket = make_udp_socket(io); + auto client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + client_socket->local_endpoint().port(), + }; + auto server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + server_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{1000}, + }; + auto client_result = ligo::udp::client::adopt(std::move(client_socket), server_endpoint, { + .reliability = config, + .security = test_dtls_verified_client_config("localhost"), + .session_id = 143, + }); + auto server_result = ligo::udp::server::adopt(std::move(server_socket), client_endpoint, { + .reliability = config, + .security = test_dtls_server_config(), + .session_id = 143, + }); + BOOST_REQUIRE(client_result); + BOOST_REQUIRE(server_result); + auto client = std::move(*client_result); + auto server = std::move(*server_result); + auto sent = boost::asio::co_spawn(io, client.send(boost::json::parse(R"json({"secure":true})json")), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, server.receive(), boost::asio::use_future); + io.run(); + auto status = sent.get(); + BOOST_CHECK(status); + auto result = received.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->payload.as_object().at("secure").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_server_peer_verification_succeeds_with_client_certificate) { + auto io = boost::asio::io_context{}; + auto client_socket = make_udp_socket(io); + auto server_socket = make_udp_socket(io); + auto client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + client_socket->local_endpoint().port(), + }; + auto server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + server_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{1000}, + }; + auto client_result = ligo::udp::client::adopt(std::move(client_socket), server_endpoint, { + .reliability = config, + .security = test_dtls_identified_client_config(), + .session_id = 145, + }); + auto server_result = ligo::udp::server::adopt(std::move(server_socket), client_endpoint, { + .reliability = config, + .security = test_dtls_server_verify_client_config(), + .session_id = 145, + }); + BOOST_REQUIRE(client_result); + BOOST_REQUIRE(server_result); + auto client = std::move(*client_result); + auto server = std::move(*server_result); + auto sent = boost::asio::co_spawn(io, client.send(boost::json::parse(R"json({"secure":true})json")), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, server.receive(), boost::asio::use_future); + io.run(); + auto status = sent.get(); + BOOST_CHECK(status); + auto result = received.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->payload.as_object().at("secure").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_server_peer_verification_fails_without_client_certificate) { + auto io = boost::asio::io_context{}; + auto client_socket = make_udp_socket(io); + auto server_socket = make_udp_socket(io); + auto client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + client_socket->local_endpoint().port(), + }; + auto server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + server_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto client_result = ligo::udp::client::adopt(std::move(client_socket), server_endpoint, { + .reliability = config, + .security = test_dtls_client_config(), + .session_id = 146, + }); + auto server_result = ligo::udp::server::adopt(std::move(server_socket), client_endpoint, { + .reliability = config, + .security = test_dtls_server_verify_client_config(), + .session_id = 146, + }); + BOOST_REQUIRE(client_result); + BOOST_REQUIRE(server_result); + auto client = std::move(*client_result); + auto server = std::move(*server_result); + auto sent = boost::asio::co_spawn(io, client.send(boost::json::object{}), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, server.receive(), boost::asio::use_future); + auto stopper = boost::asio::steady_timer{io}; + stopper.expires_after(std::chrono::milliseconds{200}); + stopper.async_wait([&client, &server](boost::system::error_code const&) { + client.close(); + server.close(); + }); + io.run(); + auto status = sent.get(); + BOOST_CHECK(!status); + auto result = received.get(); + BOOST_CHECK(!result); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_untrusted_peer_fails) { + auto io = boost::asio::io_context{}; + auto client_socket = make_udp_socket(io); + auto server_socket = make_udp_socket(io); + auto client_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + client_socket->local_endpoint().port(), + }; + auto server_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + server_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto client_result = ligo::udp::client::adopt(std::move(client_socket), server_endpoint, { + .reliability = config, + .security = { + .mode = ligo::security_mode::DTLS, + .verify = ligo::verify_mode::PEER, + .peer_name = "localhost", + }, + .session_id = 144, + }); + auto server_result = ligo::udp::server::adopt(std::move(server_socket), client_endpoint, { + .reliability = config, + .security = test_dtls_server_config(), + .session_id = 144, + }); + BOOST_REQUIRE(client_result); + BOOST_REQUIRE(server_result); + auto client = std::move(*client_result); + auto server = std::move(*server_result); + auto sent = boost::asio::co_spawn(io, client.send(boost::json::object{}), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, server.receive(), boost::asio::use_future); + auto stopper = boost::asio::steady_timer{io}; + stopper.expires_after(std::chrono::milliseconds{200}); + stopper.async_wait([&client, &server](boost::system::error_code const&) { + client.close(); + server.close(); + }); + io.run(); + auto status = sent.get(); + BOOST_CHECK(!status); + auto result = received.get(); + BOOST_CHECK(!result); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_dtls_server_requires_identity) { + auto io = boost::asio::io_context{}; + auto peer = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + 9, + }; + auto server = ligo::udp::server::bind(io.get_executor(), { + .local = { + boost::asio::ip::udp::v4(), + 0, + }, + .peer = peer, + }, { + .security = { + .mode = ligo::security_mode::DTLS, + }, + }); + BOOST_CHECK(!server); + BOOST_CHECK((server.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_reliable_round_trip_succeeds) { + auto io = boost::asio::io_context{}; + auto left_socket = make_udp_socket(io); + auto right_socket = make_udp_socket(io); + auto left_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + left_socket->local_endpoint().port(), + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto left_result = udp_client_adopt(std::move(left_socket), right_endpoint, config, 42); + auto right_result = udp_client_adopt(std::move(right_socket), left_endpoint, config, 42); + BOOST_REQUIRE(left_result); + BOOST_REQUIRE(right_result); + auto left = std::move(*left_result); + auto right = std::move(*right_result); + auto sent = boost::asio::co_spawn(io, left.send(boost::json::parse(R"json({"ok":true})json")), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, right.receive(), boost::asio::use_future); + io.run(); + BOOST_CHECK(sent.get()); + auto result = received.get(); + BOOST_REQUIRE(result); + BOOST_CHECK(result->payload.as_object().at("ok").as_bool()); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_bidirectional_concurrent_send_receive_succeeds) { + auto io = boost::asio::io_context{}; + auto left_socket = make_udp_socket(io); + auto right_socket = make_udp_socket(io); + auto left_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + left_socket->local_endpoint().port(), + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto left_result = udp_client_adopt(std::move(left_socket), right_endpoint, config, 84); + auto right_result = udp_client_adopt(std::move(right_socket), left_endpoint, config, 84); + BOOST_REQUIRE(left_result); + BOOST_REQUIRE(right_result); + auto left = std::move(*left_result); + auto right = std::move(*right_result); + auto left_sent = boost::asio::co_spawn(io, left.send(boost::json::parse(R"json({"from":"left"})json")), boost::asio::use_future); + auto left_received = boost::asio::co_spawn(io, left.receive(), boost::asio::use_future); + auto right_sent = boost::asio::co_spawn(io, right.send(boost::json::parse(R"json({"from":"right"})json")), boost::asio::use_future); + auto right_received = boost::asio::co_spawn(io, right.receive(), boost::asio::use_future); + io.run(); + BOOST_CHECK(left_sent.get()); + BOOST_CHECK(right_sent.get()); + auto left_payload = left_received.get(); + auto right_payload = right_received.get(); + BOOST_REQUIRE(left_payload); + BOOST_REQUIRE(right_payload); + BOOST_CHECK((left_payload->payload.as_object().at("from").as_string()) == ("right")); + BOOST_CHECK((right_payload->payload.as_object().at("from").as_string()) == ("left")); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_wrong_session_times_out) { + auto io = boost::asio::io_context{}; + auto receiver = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto receiver_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + receiver.local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 1, + .session_timeout = std::chrono::milliseconds{20}, + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, receiver_endpoint, config, 7); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto future = boost::asio::co_spawn(io, channel.send(boost::json::object{}), boost::asio::use_future); + io.run(); + auto status = future.get(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_timeout)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_oversized_payload_returns_invalid_argument) { + auto io = boost::asio::io_context{}; + auto receiver = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto receiver_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + receiver.local_endpoint().port(), + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, receiver_endpoint); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto payload = boost::json::value{ + boost::json::string{std::string(70000, 'x')}, + }; + auto future = boost::asio::co_spawn(io, channel.send(std::move(payload)), boost::asio::use_future); + io.run(); + auto status = future.get(); + BOOST_CHECK(!(status)); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_close_wakes_pending_receive) { + auto io = boost::asio::io_context{}; + auto right_socket = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket.local_endpoint().port(), + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, right_endpoint); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto received = boost::asio::co_spawn(io, channel.receive(), boost::asio::use_future); + auto timer = boost::asio::steady_timer{io}; + timer.expires_after(std::chrono::milliseconds{5}); + timer.async_wait([&channel](boost::system::error_code const&) { + channel.close(); + }); + io.run(); + auto result = received.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_async_close_drains_pending_receive) { + auto io = boost::asio::io_context{}; + auto right_socket = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket.local_endpoint().port(), + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, right_endpoint); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto received = boost::asio::co_spawn(io, channel.receive(), boost::asio::use_future); + auto closed = boost::asio::co_spawn(io, channel.async_close(std::chrono::milliseconds{1000}), boost::asio::use_future); + io.run(); + BOOST_CHECK(closed.get()); + auto result = received.get(); + BOOST_CHECK(!(result)); + BOOST_CHECK((result.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_async_close_drains_pending_send) { + auto io = boost::asio::io_context{}; + auto receiver = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto receiver_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + receiver.local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 1, + .session_timeout = std::chrono::milliseconds{1000}, + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, receiver_endpoint, config, 171); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto sent = boost::asio::co_spawn(io, channel.send(boost::json::object{}), boost::asio::use_future); + auto closed = boost::asio::co_spawn(io, channel.async_close(std::chrono::milliseconds{1000}), boost::asio::use_future); + io.run(); + BOOST_CHECK(closed.get()); + auto status = sent.get(); + BOOST_CHECK(!status); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_unavailable)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_async_close_moved_from_channel_is_ok) { + auto io = boost::asio::io_context{}; + auto right_socket = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket.local_endpoint().port(), + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, right_endpoint); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto moved = std::move(channel); + static_cast(moved); + auto closed = boost::asio::co_spawn(io, channel.async_close(std::chrono::milliseconds{1000}), boost::asio::use_future); + io.run(); + BOOST_CHECK(closed.get()); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_async_close_rejects_invalid_timeout) { + auto io = boost::asio::io_context{}; + auto right_socket = boost::asio::ip::udp::socket{ + io, + { + boost::asio::ip::udp::v4(), + 0, + }, + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket.local_endpoint().port(), + }; + auto channel_result = udp_client_bind(io, boost::asio::ip::udp::endpoint{ + boost::asio::ip::udp::v4(), + 0, + }, right_endpoint); + BOOST_REQUIRE(channel_result); + auto channel = std::move(*channel_result); + auto closed = boost::asio::co_spawn(io, channel.async_close(std::chrono::milliseconds{0}), boost::asio::use_future); + io.run(); + auto status = closed.get(); + BOOST_CHECK(!status); + BOOST_CHECK((status.error().code()) == (ligo::detail::error_invalid_argument)); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_rapid_sequential_round_trips_succeed) { + auto io = boost::asio::io_context{}; + auto left_socket = make_udp_socket(io); + auto right_socket = make_udp_socket(io); + auto left_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + left_socket->local_endpoint().port(), + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 2, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto left_result = udp_client_adopt(std::move(left_socket), right_endpoint, config, 242); + auto right_result = udp_client_adopt(std::move(right_socket), left_endpoint, config, 242); + BOOST_REQUIRE(left_result); + BOOST_REQUIRE(right_result); + auto left = std::move(*left_result); + auto right = std::move(*right_result); + auto work = boost::asio::make_work_guard(io); + auto runners = start_io_threads(io); + for (auto index = 0; index < 32; ++index) { + auto payload = boost::json::object{}; + payload["index"] = index; + auto sent = boost::asio::co_spawn(io, left.send(std::move(payload)), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, right.receive(), boost::asio::use_future); + auto status = sent.get(); + auto message = received.get(); + BOOST_REQUIRE(status); + BOOST_REQUIRE(message); + BOOST_CHECK((message->payload.as_object().at("index").as_int64()) == (index)); + } + left.close(); + right.close(); + work.reset(); + join_io_threads(runners); +} + +BOOST_AUTO_TEST_CASE(ligo_udp_multithreaded_io_context_round_trip_succeeds) { + auto io = boost::asio::io_context{}; + auto left_socket = make_udp_socket(io); + auto right_socket = make_udp_socket(io); + auto left_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + left_socket->local_endpoint().port(), + }; + auto right_endpoint = boost::asio::ip::udp::endpoint{ + boost::asio::ip::make_address("127.0.0.1"), + right_socket->local_endpoint().port(), + }; + auto config = ligo::udp_reliability_config{ + .resend_interval = std::chrono::milliseconds{5}, + .max_in_flight = 4, + .session_timeout = std::chrono::milliseconds{100}, + }; + auto left_result = udp_client_adopt(std::move(left_socket), right_endpoint, config, 128); + auto right_result = udp_client_adopt(std::move(right_socket), left_endpoint, config, 128); + BOOST_REQUIRE(left_result); + BOOST_REQUIRE(right_result); + auto left = std::move(*left_result); + auto right = std::move(*right_result); + auto sent = boost::asio::co_spawn(io, left.send(boost::json::parse(R"json({"ok":true})json")), boost::asio::use_future); + auto received = boost::asio::co_spawn(io, right.receive(), boost::asio::use_future); + run_two_threads(io); + BOOST_CHECK(sent.get()); + auto message = received.get(); + BOOST_REQUIRE(message); + BOOST_CHECK(message->payload.as_object().at("ok").as_bool()); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..aab2edb --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE ligo +#include