diff --git a/.github/scripts/test-memory-sanitizers.py b/.github/scripts/test-memory-sanitizers.py new file mode 100644 index 0000000..a5cd7a3 --- /dev/null +++ b/.github/scripts/test-memory-sanitizers.py @@ -0,0 +1,132 @@ +"""Run native memory checks with a consistently instrumented dependency graph.""" + +import argparse +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess + + +def run(command, environment, timeout, stdout=None): + timeout_tool = shutil.which( + "gtimeout" if platform.system() == "Darwin" else "timeout" + ) + if timeout_tool is None: + raise RuntimeError("Memory checks require GNU coreutils timeout") + subprocess.run( + [timeout_tool, "--signal=TERM", "--kill-after=10s", str(timeout), *command], + env=environment, + stdout=stdout, + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--jobs", type=int, choices=range(1, 17), default=2) + parser.add_argument("--shared", action="store_true") + parser.add_argument("--dynamic-plugins", action="store_true") + args = parser.parse_args() + if platform.system() not in ("Linux", "Darwin"): + parser.error("Memory checks require Linux or macOS") + source = Path(__file__).resolve().parents[2] + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + command = [ + "conan", + "install", + str(source), + "--build=missing", + "--output-folder=" + str(output / "dependencies"), + "-s", + "build_type=Debug", + "-s", + "compiler.cppstd=20", + "-o", + "enable_testing=True", + "-o", + "enable_strict_warnings=True", + "-o", + "warnings_as_errors=True", + "-o", + "shared=" + str(args.shared), + "-o", + "static_plugins=" + str(not args.dynamic_plugins), + "-c:h", + "tools.build:jobs=" + str(args.jobs), + "--format=json", + ] + configurations = { + "tools.build:cflags": ["-fsanitize=address", "-fno-omit-frame-pointer"], + "tools.build:cxxflags": ["-fsanitize=address", "-fno-omit-frame-pointer"], + "tools.build:sharedlinkflags": ["-fsanitize=address"], + "tools.build:exelinkflags": ["-fsanitize=address"], + } + configurations["tools.info.package_id:confs"] = list(configurations) + for name, value in configurations.items(): + command.extend(["-c:h", name + "=" + json.dumps(value)]) + with (output / "dependencies.json").open("w") as graph: + # Third-party build tools keep process caches; LSan is mandatory at runtime. + run( + command, + environment | {"ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1"}, + 5400, + graph, + ) + toolchains = list((output / "dependencies").rglob("conan_toolchain.cmake")) + if len(toolchains) != 1: + raise RuntimeError("Expected exactly one instrumented Conan toolchain") + build = output / "build" + flags = "-fsanitize=address,undefined" + run( + [ + "cmake", + "-S", + str(source), + "-B", + str(build), + "-G", + "Ninja", + "-DCMAKE_TOOLCHAIN_FILE=" + str(toolchains[0]), + "-DCMAKE_BUILD_TYPE=Debug", + "-DCMAKE_C_FLAGS=" + flags + " -fno-omit-frame-pointer", + "-DCMAKE_CXX_FLAGS=" + flags + " -fno-omit-frame-pointer", + "-DCMAKE_EXE_LINKER_FLAGS=" + flags, + "-DCMAKE_SHARED_LINKER_FLAGS=" + flags, + "-DCMAKE_MODULE_LINKER_FLAGS=" + flags, + "-DRSTREAM_TEST_TIMEOUT_SCALE=2", + "-DRSTREAM_TEST_TIMEOUT_SECONDS=300", + ], + environment, + 180, + ) + run( + ["cmake", "--build", str(build), "--parallel", str(args.jobs)], + environment, + 1800, + ) + detect_leaks = "1" if platform.system() == "Linux" else "0" + run( + [ + "ctest", + "--test-dir", + str(build), + "--output-on-failure", + "--output-junit", + str(output / "results.junit.xml"), + ], + environment + | { + "ASAN_OPTIONS": "detect_leaks=" + detect_leaks + ":halt_on_error=1", + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1", + }, + 1800, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/conan.yml b/.github/workflows/conan.yml index 7b053f1..886008b 100644 --- a/.github/workflows/conan.yml +++ b/.github/workflows/conan.yml @@ -117,7 +117,7 @@ jobs: shell: bash run: | set -euo pipefail - conan create --build=missing --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default \ + conan create --build=missing --build="rstream/*" --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default \ -s:b compiler.cppstd=20 \ -s:h compiler.cppstd=20 \ -o "rstream/*:shared=${{ matrix.shared }}" \ @@ -226,7 +226,7 @@ jobs: shell: bash run: | set -euo pipefail - conan create --build=missing --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default \ + conan create --build=missing --build="rstream/*" --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default \ -s:b compiler.cppstd=20 \ -s:h compiler.cppstd=20 \ -o "rstream/*:shared=${{ matrix.shared }}" \ @@ -258,7 +258,7 @@ jobs: windows: name: Windows ${{ matrix.linkage }} libraries if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} - runs-on: windows-latest + runs-on: windows-2022 env: CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_COMPILERCHECK: content @@ -317,11 +317,14 @@ jobs: ccache --set-config=max_size=1G ccache --set-config=compression=true ccache --zero-stats + - name: Check required Windows test configuration + shell: pwsh + run: python test/test_conan_windows_asan.py - name: Build and test Conan package shell: pwsh run: | foreach ($staticPlugins in @("True", "False")) { - conan create --build=missing --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default ` + conan create --build=missing --build="rstream/*" --user conan --channel "${{ inputs.channel }}" -pr:b default -pr:h default ` -s:b compiler.cppstd=20 ` -s:h compiler.cppstd=20 ` -o "rstream/*:shared=${{ matrix.shared }}" ` @@ -329,6 +332,7 @@ jobs: -o "rstream/*:enable_testing=True" ` -o "rstream/*:enable_strict_warnings=True" ` -o "rstream/*:warnings_as_errors=True" ` + -c "rstream/*:tools.cmake.cmaketoolchain:extra_variables={'RSTREAM_TEST_WINDOWS_PIPE_ASAN': {'value': True, 'cache': True, 'type': 'BOOL'}}" ` . if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index a309327..823022d 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -393,6 +393,22 @@ jobs: runs-on: ubuntu-latest environment: stable-release steps: + - name: Checkout trusted WebTTY certification policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: rstreamlabs/rstream-engine + ref: main + token: ${{ secrets.WEBTTY_REPOSITORIES_TOKEN }} + path: .webtty-policy + persist-credentials: false + - uses: ./.webtty-policy/.github/actions/verify-webtty-release + with: + component: cpp + candidate-sha: ${{ github.sha }} + assembly: ${{ vars.WEBTTY_RELEASE_ASSEMBLY }} + run-id: ${{ vars.WEBTTY_CERTIFICATION_RUN_ID }} + allowed-actor: ${{ vars.CI_ALLOWED_ACTOR }} + token: ${{ secrets.WEBTTY_REPOSITORIES_TOKEN }} - run: printf 'Approved C++ package candidates for %s\n' "${GITHUB_SHA}" publish-linux-windows: diff --git a/.github/workflows/reliability.yml b/.github/workflows/reliability.yml index 42eb337..a9eb4e7 100644 --- a/.github/workflows/reliability.yml +++ b/.github/workflows/reliability.yml @@ -21,8 +21,6 @@ jobs: fail-fast: false matrix: include: - - name: Address and undefined behavior sanitizers - preset: asan - name: Thread sanitizer preset: tsan steps: @@ -37,6 +35,71 @@ jobs: run: cmake --build --preset "${{ matrix.preset }}" - name: Test run: ctest --preset "${{ matrix.preset }}" + memory-checks: + name: ${{ matrix.name }} + if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - name: Linux address, undefined behavior and leak checks + runner: ubuntu-24.04 + cache: conan-linux-asan-v1 + test_step: Check runtime with instrumented dependencies + options: "" + - name: Address and undefined behavior sanitizers + runner: macos-latest + cache: conan-macos-asan-v1 + test_step: Test + options: --shared --dynamic-plugins + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install Linux build prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config + - name: Install macOS build prerequisites + if: runner.os == 'macOS' + run: brew install cmake coreutils ninja pkg-config + - name: Configure Conan + run: | + python3 -m pip install conan==2.31.2 + conan profile detect --force + conan config install conan/config + - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: memory-cache + with: + path: ~/.conan2/p + key: ${{ matrix.cache }}-${{ runner.arch }}-${{ hashFiles('conanfile.py', 'conan/config/**', '.github/scripts/test-memory-sanitizers.py') }} + restore-keys: ${{ matrix.cache }}-${{ runner.arch }}- + - name: ${{ matrix.test_step }} + run: python3 .github/scripts/test-memory-sanitizers.py --output out/memory-sanitizers --jobs 3 ${{ matrix.options }} + - name: Preserve memory check evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: memory-sanitizers-${{ runner.os }}-${{ github.sha }} + path: | + out/memory-sanitizers/dependencies.json + out/memory-sanitizers/results.junit.xml + if-no-files-found: error + retention-days: 90 + - name: Trim dependency build caches + if: ${{ always() && !cancelled() }} + run: conan cache clean "*" --source --build --download --temp + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + if: ${{ always() && !cancelled() && steps.memory-cache.outputs.cache-hit != 'true' }} + with: + path: ~/.conan2/p + key: ${{ steps.memory-cache.outputs.cache-primary-key }} static-analysis: name: Static analysis if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} diff --git a/.gitleaks.toml b/.gitleaks.toml index 7e0b734..11ffa5c 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -13,3 +13,22 @@ commits = [ paths = ['''.*demo/stun/test/test_stun_parsing\.cpp'''] regexTarget = "match" regexes = ['''(?:password|integrity_key)\s*=\s*"[^"]+"'''] + +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "Exact public keys, signatures and identifiers from the isolated WebTTY approval fixture; no private keys or authentication tokens." +condition = "AND" +paths = ['''(^|/)test/webtty/fixtures/workspace-approved-client\.json$'''] +regexTarget = "line" +regexes = [ + '''^\s*"workspaceTrustKeysetId":\ "cmtorkj0w0001f4s7ibymzn7r",\s*$''', + '''^\s*"workspaceTrustPublicSigningKey":\ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqnyUNNjrBJlT3IeiR5_dFnTqaURvuKr2l4uPxHv2NzV3hwriJEbNFRMPlwfRIvAls3J4hYTEdLGLHldtCwddKg"\s*$''', + '''^\s*"device_public_encryption_key":\ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtLfGX25FV2Rxvc52BlCsIJ_6eDzGmMbjtH6PRw2gFf22lJVTQyresVtx6SPBOa8IrUPrwKfNUSEIuEznez2H9g",\s*$''', + '''^\s*"device_public_signing_key":\ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfyoJ8mkp7UIh88xxmuULgqDD11diVcieAgk0kBUoVHq6NR1p0eq5YUuWg7G4A_7OpEDa1qA6_sj9uNBeN43CnQ",\s*$''', + '''^\s*"trust_keyset_id":\ "cmtorkj0w0001f4s7ibymzn7r",\s*$''', + '''^\s*"trust_keyset_signature":\ "XdspZHqxzF0E7cVIHvrS7XFBr3GGpsQ95AGrRYSyulF\-LfKnqy5cWy\-qo1I09vqvXqdmzf7nReqlyciMdq\-2OQ",\s*$''', + '''^\s*"keyset_id":\ "cmtorkj0w0001f4s7ibymzn7r",\s*$''', + '''^\s*"webtty_key_algorithm":\ "webtty\-x25519\-hpke\-v1",\s*$''', +] diff --git a/README.md b/README.md index 2261d5c..4627b34 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Conan Center dependency policy, constrained-system requirements, and complete validation procedure are defined in [docs/001-sdk-engineering.md](docs/001-sdk-engineering.md). +Windows CLI builds require docopt.cpp compiled with Boost.Regex. The Conan recipe selects and validates this variant and shares the SDK’s supported Boost version with docopt. MSVC’s `std::regex` can overflow the stack while parsing the WebTTY help text. If supplying dependencies directly to CMake, build docopt with `USE_BOOST_REGEX=ON`; the CLI startup tests verify both `--help` and `--version` for the client and server. No process-wide stack-size increase is required. + ## Build from source The recommended source build uses Conan to provision third-party dependencies and then builds the package: diff --git a/bin/inspect/main.cpp b/bin/inspect/main.cpp index a1e14fe..6ccd0db 100644 --- a/bin/inspect/main.cpp +++ b/bin/inspect/main.cpp @@ -68,14 +68,14 @@ int main(int argc, char** argv) auto args = docopt::docopt(USAGE, {argv + 1, argv + argc}, true, version); if (args["version"].asBool()) { if (args["--verbose"].asBool()) { - auto version = rstream::core::get_project_info(); + auto project_info = rstream::core::get_project_info(); if (args["--json"].asBool()) { nlohmann::json json; - json << version; + json << project_info; std::cout << json.dump(2) << std::endl; } else { - std::cout << version << std::endl; + std::cout << project_info << std::endl; } } else { diff --git a/bin/nperf/lib/cpp/rstream/nperf/client.cpp b/bin/nperf/lib/cpp/rstream/nperf/client.cpp index 91e55fa..b4a31ee 100644 --- a/bin/nperf/lib/cpp/rstream/nperf/client.cpp +++ b/bin/nperf/lib/cpp/rstream/nperf/client.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include "client.hpp" #include @@ -1194,7 +1202,11 @@ void client::impl::base::session::do_handshake_websocket(const io::address& addr // set the control callback. This will be called // on every incoming ping, pong, and close frame { - auto completion_handler = std::bind(&session::on_control_callback, shared_from_this(), std::placeholders::_1, std::placeholders::_2); + auto completion_handler = [weak = weak_from_this()](boost::beast::websocket::frame_type kind, const boost::beast::string_view& payload) { + if (auto ptr = weak.lock()) { + ptr->on_control_callback(kind, payload); + } + }; m_websocket->control_callback(rstream::core::wrap_function(m_strand, completion_handler)); } // we're sending binary data diff --git a/bin/nperf/lib/cpp/rstream/nperf/server.cpp b/bin/nperf/lib/cpp/rstream/nperf/server.cpp index e554a6e..e6db24f 100644 --- a/bin/nperf/lib/cpp/rstream/nperf/server.cpp +++ b/bin/nperf/lib/cpp/rstream/nperf/server.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include "server.hpp" #include @@ -875,7 +883,11 @@ void server::impl::session::do_accept_websocket() // set the control callback. This will be called // on every incoming ping, pong, and close frame { - auto completion_handler = std::bind(&session::on_control_callback, shared_from_this(), std::placeholders::_1, std::placeholders::_2); + auto completion_handler = [weak = weak_from_this()](boost::beast::websocket::frame_type kind, const boost::beast::string_view& payload) { + if (auto ptr = weak.lock()) { + ptr->on_control_callback(kind, payload); + } + }; m_websocket->control_callback(boost::asio::bind_executor(m_strand, completion_handler)); } // we're sending binary data diff --git a/bin/webtty/bin/CMakeLists.txt b/bin/webtty/bin/CMakeLists.txt index 5609b6c..a69e3a5 100644 --- a/bin/webtty/bin/CMakeLists.txt +++ b/bin/webtty/bin/CMakeLists.txt @@ -5,6 +5,20 @@ add_subdirectory(client) add_subdirectory(server) +if(ENABLE_TESTING) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + math(EXPR WEBTTY_STARTUP_TIMEOUT "10 * ${RSTREAM_TEST_TIMEOUT_SCALE}") + foreach(WEBTTY_BINARY client server) + foreach(WEBTTY_OPTION help version) + add_test(NAME ${PROJECT_NAME}-test-webtty-${WEBTTY_BINARY}-${WEBTTY_OPTION} + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_startup.py + $ --${WEBTTY_OPTION} ${WEBTTY_STARTUP_TIMEOUT}) + rstream_configure_test(${PROJECT_NAME}-test-webtty-${WEBTTY_BINARY}-${WEBTTY_OPTION}) + endforeach() + set_property(GLOBAL APPEND PROPERTY RSTREAM_TEST_TARGETS ${PROJECT_NAME}-webtty-${WEBTTY_BINARY}) + endforeach() +endif() + if(UNIX) foreach(WEBTTY_BINARY client server) install(CODE " diff --git a/bin/webtty/bin/client/README.md b/bin/webtty/bin/client/README.md index fceb6ff..dd4cbf9 100644 --- a/bin/webtty/bin/client/README.md +++ b/bin/webtty/bin/client/README.md @@ -18,3 +18,22 @@ Use `--known-server ` when the local known-server file contains several se Use `--identity`, `--identity-file`, `RSTREAM_WEBTTY_IDENTITY`, or `RSTREAM_WEBTTY_IDENTITY_FILE` when the server requires a signed client proof. The client also reads target-scoped `client_identity` associations from `~/.rstream/webtty/known_servers.json` and loads the matching local identity from `~/.rstream/webtty/identities/.identity.json`. Explicit identity flags and environment variables override the known-server association. If authenticated E2E is required and no client identity can be resolved, the client fails before opening the terminal. The C++ client supports explicit-key WebTTY E2E and the protocol-level client credential field used by workspace-managed sessions. The standalone C++ CLI does not call the control plane; workspace-managed resolution is performed by the rstream CLI and `rstream ui`, which load trusted workspace devices from `~/.rstream/workspaces//devices/`. When a credential is produced by another trusted workflow, pass it with `--client-credential-file` or `RSTREAM_WEBTTY_CLIENT_CREDENTIAL_FILE` together with the matching client endpoint identity. + +### Transport discovery + +For `rstrm://` URLs the CLI reads `/api/tunnels` on the configured engine and +selects `rstream.webtty.transport` before starting WebTTY. Discovery has a +bounded deadline and response size and is cancelled by SIGINT/SIGTERM. It does +not contact the control plane. Explicit transport overrides must match an +advertised label; unlabeled datagram WebTTY tunnels are recognized as legacy +WebTransport servers. This C++ runtime supports `plain` and `websocket`; +WebTransport targets fail with a capability error. + +For an engine-only device using a stream-only token, pass `--no-discovery` and +`--transport`. Configure server trust, client identity and, for workspace E2E, +a signed `--client-credential-file` locally. No security downgrade or automatic +transport retry occurs when a proof fails. The server still validates the +credential and the engine still enforces the token's permissions. + +`RSTREAM_DATA_DIR` overrides the absolute local WebTTY/workspace state directory +(default `~/.rstream`), independently of `RSTREAM_CONFIG`. diff --git a/bin/webtty/bin/client/main.cpp b/bin/webtty/bin/client/main.cpp index 82e158a..8f43f04 100644 --- a/bin/webtty/bin/client/main.cpp +++ b/bin/webtty/bin/client/main.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include #include #include @@ -12,6 +20,7 @@ #include #include +#include #include #include @@ -41,7 +50,8 @@ this program is distributed with the rstream C++ tools. See https://rstream.io/d -e --env=ARG pass environment variable -w --workdir=ARG set the working directory -u --user=ARG username or UID - --transport=ARG WebTTY transport to use [default: websocket] + --transport=ARG WebTTY transport override (default: discover for rstrm) + --no-discovery skip engine metadata; requires --transport and local security --auth-token-file=ARG read local WebTTY bearer token from file --e2e require end-to-end encrypted WebTTY terminal content --identity=ARG named local WebTTY client identity @@ -280,8 +290,21 @@ int run(int argc, char** argv) } boost::asio::io_context io_context(jobs); boost::asio::signal_set signal_set(io_context, SIGINT, SIGTERM); + auto requested_transport = args.at("--transport") ? args.at("--transport").asString() : ""; + const bool no_discovery = args.at("--no-discovery").asBool(); + if (no_discovery && requested_transport.empty()) { + throw std::runtime_error("--no-discovery requires --transport (plain, websocket)"); + } rstream::webtty::protocol::type protocol_type; - rstream::webtty::protocol::parse_type(protocol_type, args.at("--transport").asString()); + rstream::webtty::protocol::parse_type(protocol_type, requested_transport.empty() ? "websocket" : requested_transport); + rstream::io::address address(args.at("--uri").asString()); + std::optional discovered; + if (address.m_url.scheme() == "rstrm" && !no_discovery) { + discovered = rstream::webtty::cli::discover_webtty_server(io_context, signal_set, address, requested_transport); + rstream::webtty::protocol::parse_type(protocol_type, discovered->m_transport); + address.m_url.set_host(discovered->m_target); + address.m_str = boost::none; + } auto auth_token_file = args.at("--auth-token-file") ? args.at("--auth-token-file").asString() : ""; auto auth_token = rstream::webtty::cli::read_auth_token(auth_token_file); boost::optional auth_token_option; @@ -292,16 +315,16 @@ int run(int argc, char** argv) throw std::runtime_error("plain WebTTY transport does not support HTTP bearer tokens"); } rstream::webtty::client::config config = { - .m_address = rstream::io::address(args.at("--uri").asString()), - .m_websocket_target = protocol_type == rstream::webtty::protocol::type::websocket ? boost::optional("/") : boost::none, + .m_address = address, + .m_websocket_target = protocol_type == rstream::webtty::protocol::type::websocket ? boost::optional(discovered ? discovered->m_exec_path : "/") : boost::none, .m_auth_token = auth_token_option, .m_protocol_config = { - .m_protocol_type = protocol_type, - .m_options = {}, - .m_env_vars = {}, - .m_cmd_args = {}, - .m_workdir = {}, - .m_username = {}, + .m_protocol_type = protocol_type, + .m_options = {}, + .m_env_vars = {}, + .m_cmd_args = {}, + .m_workdir = {}, + .m_username = {}, }, }; { @@ -348,13 +371,16 @@ int run(int argc, char** argv) rstream::webtty::protocol::parse_username(config.m_protocol_config.m_username, username.asString()); } } - const bool e2e_requested = args.at("--e2e").asBool(); - auto identity_name = args.at("--identity") ? args.at("--identity").asString() : ""; - auto identity_file = args.at("--identity-file") ? args.at("--identity-file").asString() : ""; - auto client_credential_file = args.at("--client-credential-file") ? args.at("--client-credential-file").asString() : ""; - auto known_server_name = args.at("--known-server") ? args.at("--known-server").asString() : ""; - auto known_servers_file = args.at("--known-servers-file") ? args.at("--known-servers-file").asString() : ""; - auto known_server_resolution = read_known_server_resolution(args.at("--known-server-key"), known_servers_file, known_server_name, args.at("--uri").asString(), e2e_requested); + const bool e2e_requested = args.at("--e2e").asBool(); + auto identity_name = args.at("--identity") ? args.at("--identity").asString() : ""; + auto identity_file = args.at("--identity-file") ? args.at("--identity-file").asString() : ""; + auto client_credential_file = args.at("--client-credential-file") ? args.at("--client-credential-file").asString() : ""; + auto known_server_name = args.at("--known-server") ? args.at("--known-server").asString() : ""; + auto known_servers_file = args.at("--known-servers-file") ? args.at("--known-servers-file").asString() : ""; + auto known_server_resolution = read_known_server_resolution(args.at("--known-server-key"), known_servers_file, known_server_name, args.at("--uri").asString(), e2e_requested); + if (discovered && discovered->m_requires_known_server && (known_server_resolution.m_recipients.empty() || known_server_resolution.m_endpoint_identities.empty())) { + throw std::runtime_error("WebTTY server requires authenticated E2E; configure its known endpoint identity locally"); + } rstream::webtty::settings_client settings = { .m_common = { .m_mtu = 1024 * 1024, @@ -424,7 +450,7 @@ int run(int argc, char** argv) auto n = jobs - 1; threads.reserve(n); for (decltype(n) i = 0; i < n; ++i) { - threads.emplace_back(std::bind((boost::asio::io_context::count_type(boost::asio::io_context::*)()) & boost::asio::io_context::run, &io_context)); + threads.emplace_back(std::bind((boost::asio::io_context::count_type (boost::asio::io_context::*)())&boost::asio::io_context::run, &io_context)); } } io_context.run(); diff --git a/bin/webtty/bin/common/webtty_cli.hpp b/bin/webtty/bin/common/webtty_cli.hpp index 2ea8a07..8e576a1 100644 --- a/bin/webtty/bin/common/webtty_cli.hpp +++ b/bin/webtty/bin/common/webtty_cli.hpp @@ -56,6 +56,7 @@ constexpr const int key_file_version = 1; struct server_enrollment { int m_version = 0; std::string m_server_id; + std::string m_server_name; std::string m_workspace_id; std::string m_project_id; std::string m_api_url; @@ -214,6 +215,14 @@ inline byte_vector read_client_credential(const std::string& raw_file) inline std::filesystem::path rstream_home() { + const auto root = getenv_trimmed("RSTREAM_DATA_DIR"); + if (!root.empty()) { + const std::filesystem::path path(root); + if (!path.is_absolute()) { + throw std::runtime_error("RSTREAM_DATA_DIR must be an absolute path"); + } + return path.lexically_normal(); + } return std::filesystem::path(home_dir()) / ".rstream"; } @@ -877,6 +886,7 @@ inline server_enrollment load_server_enrollment(const std::string& raw_path) throw std::runtime_error("unsupported WebTTY server enrollment version"); } enrollment.m_server_id = root["serverId"].as(); + enrollment.m_server_name = root["serverName"] ? root["serverName"].as() : ""; enrollment.m_workspace_id = root["workspaceId"] ? root["workspaceId"].as() : ""; enrollment.m_project_id = root["projectId"].as(); enrollment.m_api_url = root["apiUrl"] ? root["apiUrl"].as() : ""; diff --git a/bin/webtty/bin/common/webtty_discovery.hpp b/bin/webtty/bin/common/webtty_discovery.hpp new file mode 100644 index 0000000..9237ac1 --- /dev/null +++ b/bin/webtty/bin/common/webtty_discovery.hpp @@ -0,0 +1,190 @@ +// See LICENSE file in the project root for license information. + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace rstream::webtty::cli { + +struct discovered_server { + std::string m_transport; + std::string m_target; + std::string m_exec_path; + bool m_requires_known_server = false; +}; + +inline std::string inventory_string(const nlohmann::json& object, const char* key, const char* fallback = "") +{ + const auto field = object.find(key); + if (field == object.end() || field->is_null()) { + return fallback; + } + if (!field->is_string()) { + throw std::runtime_error("invalid WebTTY engine inventory field " + std::string(key)); + } + return field->get(); +} + +inline discovered_server select_discovered_server(const nlohmann::json& inventory, const std::string& target, const std::string& requested) +{ + if (!inventory.is_array()) { + throw std::runtime_error("invalid WebTTY engine inventory"); + } + std::optional match; + for (const auto& server : inventory) { + if (!server.is_object() || inventory_string(server, "status", "") != "online") { + continue; + } + const auto labels_entry = server.find("labels"); + const auto labels = labels_entry == server.end() || labels_entry->is_null() ? nlohmann::json::object() : *labels_entry; + if (!labels.is_object()) { + throw std::runtime_error("invalid WebTTY engine inventory labels"); + } + if (inventory_string(server, "protocol", "") != "webtty" && inventory_string(labels, "application-protocol", "") != "rstream.webtty") { + continue; + } + const auto id = inventory_string(server, "id", ""); + const auto name = inventory_string(server, "name", ""); + const auto server_id = inventory_string(labels, "rstream.webtty.server_id", ""); + if (target != id && target != name && target != server_id && target != inventory_string(labels, "rstream.webtty.server_name", "")) { + continue; + } + if (match) { + throw std::runtime_error("multiple WebTTY servers match; use the tunnel ID"); + } + const auto kind = inventory_string(server, "type", ""); + const auto http_version = inventory_string(server, "http_version", ""); + const bool advertised = labels.contains("rstream.webtty.transport"); + const auto transport = advertised ? inventory_string(labels, "rstream.webtty.transport") : kind == "datagram" ? "webtransport" + : requested.empty() ? "websocket" + : requested; + if (transport != "plain" && transport != "websocket" && transport != "webtransport") { + throw std::runtime_error("server advertises an invalid WebTTY transport"); + } + if (!kind.empty() && (transport == "webtransport") != (kind == "datagram")) { + throw std::runtime_error("server WebTTY transport conflicts with its tunnel type"); + } + if (transport == "webtransport" && !http_version.empty() && http_version != "h3") { + throw std::runtime_error("server WebTransport requires HTTP/3"); + } + if (!requested.empty() && requested != transport) { + throw std::runtime_error("requested WebTTY transport conflicts with server transport " + transport); + } + if (transport == "webtransport") { + throw std::runtime_error("server requires WebTransport, which is not implemented by the C++ WebTTY client; use the Go client"); + } + match = discovered_server{ + transport, + server_id.empty() ? id : server_id, + inventory_string(labels, "rstream.webtty.exec.path", "/"), + inventory_string(labels, "rstream.webtty.e2e", "") == "required" || inventory_string(labels, "rstream.webtty.client_proof", "") == "required" || !inventory_string(labels, "rstream.webtty.host_key_id", "").empty(), + }; + } + if (!match) { + throw std::runtime_error("online WebTTY server not found in engine inventory; for stream-only tokens use --no-discovery with --transport and local security"); + } + return *match; +} + +inline discovered_server discover_webtty_server(boost::asio::io_context& context, boost::asio::signal_set& signals, const io::address& target, const std::string& requested, std::chrono::steady_clock::duration timeout = std::chrono::seconds(5)) +{ + io_rstrm::settings_socket settings; + boost::system::error_code error; + io_rstrm::parse_settings_socket(target.m_url, settings, error); + if (error) { + throw boost::system::system_error(error); + } + const auto endpoint = io_rstrm::make_endpoint(target.m_url).value(); + if (endpoint.m_server_address_from_uri_param && !settings.m_config.m_no_token && !settings.m_config.m_token_from_uri_param) { + throw std::runtime_error("an explicit engine URI requires an explicit token or no-token option"); + } + auto engine = endpoint.m_server_address; + const auto token = io_rstrm::get_rstream_token(settings.m_config, engine).value(); + engine.m_url.params().erase("ssl.alpn_protos"); + engine.m_url.params().append({"ssl.alpn_protos", "http/1.1"}); + io::stream::resolver resolver(context.get_executor()); + io::stream::socket socket(context.get_executor()); + boost::asio::steady_timer deadline(context, timeout); + boost::beast::flat_buffer buffer(64 * 1024); + boost::beast::http::response_parser response; + response.body_limit(1024 * 1024); + response.header_limit(16 * 1024); + boost::beast::http::request request(boost::beast::http::verb::get, "/api/tunnels", 11); + const auto sni = engine.m_url.params().find("ssl.sni"); + request.set(boost::beast::http::field::host, sni == engine.m_url.params().end() ? engine.host() : std::string((*sni).value)); + request.set(boost::beast::http::field::connection, "close"); + if (token) { + request.set(boost::beast::http::field::authorization, "Bearer " + *token); + } + bool done = false; + const auto finish = [&](const boost::system::error_code& result) { + if (done) { + return; + } + done = true; + error = result; + resolver.cancel(); + boost::system::error_code ignored; + socket.close(ignored); + deadline.cancel(); + signals.cancel(ignored); + }; + deadline.async_wait([&](const boost::system::error_code& result) { + if (!result) { + finish(boost::asio::error::timed_out); + } + }); + signals.async_wait([&](const boost::system::error_code& result, int) { + if (!result) { + finish(boost::asio::error::operation_aborted); + } + }); + resolver.async_resolve(engine.m_url, [&](const boost::system::error_code& result, const auto& endpoints) { + if (result || done) { + finish(result); + return; + } + boost::asio::async_connect(socket, endpoints, [&](const boost::system::error_code& result, const auto&) { + if (result || done) { + finish(result); + return; + } + boost::beast::http::async_write(socket, request, [&](const boost::system::error_code& result, std::size_t) { + if (result || done) { + finish(result); + return; + } + boost::beast::http::async_read(socket, buffer, response, [&](const boost::system::error_code& result, std::size_t) { finish(result); }); + }); + }); + }); + context.run(); + context.restart(); + if (error) { + throw std::runtime_error("WebTTY engine discovery failed: " + error.message()); + } + if (response.get().result() != boost::beast::http::status::ok) { + throw std::runtime_error("WebTTY engine discovery was rejected; use --no-discovery with --transport and local security for stream-only tokens"); + } + const auto inventory = nlohmann::json::parse(response.get().body(), nullptr, false); + return select_discovered_server(inventory, std::string(target.m_url.host()), requested); +} + +} // namespace rstream::webtty::cli diff --git a/bin/webtty/bin/common/webtty_workspace_trust.hpp b/bin/webtty/bin/common/webtty_workspace_trust.hpp new file mode 100644 index 0000000..752c74d --- /dev/null +++ b/bin/webtty/bin/common/webtty_workspace_trust.hpp @@ -0,0 +1,180 @@ +// See LICENSE file in the project root for license information. + +#pragma once + +#include + +#include "webtty_cli.hpp" + +namespace rstream { +namespace webtty { +namespace cli { + +inline std::string workspace_json_string(const nlohmann::json& value, const std::string& key) +{ + auto it = value.find(key); + if (it == value.end() || !it->is_string()) { + return ""; + } + return it->get(); +} + +inline std::string workspace_canonical_json(const nlohmann::json& value) +{ + if (value.is_null()) { + return "null"; + } + if (value.is_string() || value.is_boolean()) { + return value.dump(); + } + if (value.is_number_integer() || value.is_number_unsigned()) { + return value.dump(); + } + if (value.is_number_float()) { + throw std::runtime_error("workspace-managed WebTTY credential contains an unsupported JSON number"); + } + if (value.is_array()) { + std::string out = "["; + for (std::size_t i = 0; i < value.size(); ++i) { + if (i > 0) { + out += ","; + } + out += workspace_canonical_json(value[i]); + } + out += "]"; + return out; + } + if (value.is_object()) { + std::string out = "{"; + bool first = true; + for (auto it = value.begin(); it != value.end(); ++it) { + if (it.value().is_null()) { + continue; + } + if (!first) { + out += ","; + } + first = false; + out += nlohmann::json(it.key()).dump(); + out += ":"; + out += workspace_canonical_json(it.value()); + } + out += "}"; + return out; + } + throw std::runtime_error("workspace-managed WebTTY credential contains unsupported JSON"); +} + +inline std::string workspace_sha256_base64url(const nlohmann::json& value) +{ + auto canonical = workspace_canonical_json(value); + unsigned char digest[SHA256_DIGEST_LENGTH] = {}; + SHA256(reinterpret_cast(canonical.data()), canonical.size(), digest); + return rstream::webtty::cli::base64url_encode(rstream::webtty::byte_vector(digest, digest + SHA256_DIGEST_LENGTH)); +} + +inline std::string workspace_public_key_fingerprint(const std::string& public_encryption_key, const std::string& public_signing_key) +{ + nlohmann::json payload = { + {"public_encryption_key", public_encryption_key}, + {"public_signing_key", public_signing_key}, + {"type", "workspace.public_keys"}, + {"v", 1}, + }; + return "sha256:" + workspace_sha256_base64url(payload); +} + +inline void verify_workspace_signature(const std::string& public_signing_key, const nlohmann::json& payload, const std::string& signature, const std::string& label) +{ + auto public_key = rstream::webtty::cli::base64url_decode(public_signing_key, 0, label + " public signing key"); + auto sig = rstream::webtty::cli::base64url_decode(signature, 0, label + " signature"); + auto canonical = workspace_canonical_json(payload); + std::error_code error_code; + rstream::webtty::verify_p256_sha256_signature(public_key, rstream::webtty::byte_vector(canonical.begin(), canonical.end()), sig, error_code); + if (error_code) { + throw std::runtime_error(label + " signature is invalid"); + } +} + +inline bool workspace_trust_payload_matches(const rstream::webtty::cli::server_enrollment& enrollment, const nlohmann::json& credential, const nlohmann::json& trust_payload) +{ + if (workspace_json_string(trust_payload, "workspace_id") != enrollment.m_workspace_id) { + return false; + } + auto device_fingerprint = workspace_json_string(credential, "device_fingerprint"); + auto device_key_id = workspace_json_string(credential, "device_key_id"); + auto type = workspace_json_string(trust_payload, "type"); + if (type == "workspace.keyset.setup") { + return workspace_json_string(trust_payload, "keyset_fingerprint") == enrollment.m_workspace_trust_keyset_fingerprint && workspace_json_string(trust_payload, "keyset_public_signing_key") == enrollment.m_workspace_trust_public_signing_key && workspace_json_string(trust_payload, "device_fingerprint") == device_fingerprint; + } + if (type == "workspace.device.approve") { + return workspace_json_string(trust_payload, "keyset_id") == enrollment.m_workspace_trust_keyset_id && workspace_json_string(trust_payload, "target_device_key_id") == device_key_id && workspace_json_string(trust_payload, "target_fingerprint") == device_fingerprint; + } + if (type == "workspace.recovery_kit.use") { + return workspace_json_string(trust_payload, "keyset_id") == enrollment.m_workspace_trust_keyset_id && workspace_json_string(trust_payload, "device_fingerprint") == device_fingerprint; + } + return false; +} + +inline boost::optional verify_workspace_client_credential(const rstream::webtty::cli::server_enrollment& enrollment, + const rstream::webtty::byte_vector& client_key_id, + const rstream::webtty::byte_vector& client_public_key, + const rstream::webtty::byte_vector& credential) +{ + if (credential.empty()) { + return boost::none; + } + auto envelope = nlohmann::json::parse(std::string(credential.begin(), credential.end())); + if (!envelope.is_object() || envelope.value("v", 0) != 1 || !envelope.contains("payload") || !envelope["payload"].is_object()) { + throw std::runtime_error("workspace-managed WebTTY client credential is invalid"); + } + const auto& payload = envelope["payload"]; + if (workspace_json_string(payload, "type") != "workspace.webtty.client.credential") { + throw std::runtime_error("workspace-managed WebTTY client credential has an unsupported type"); + } + if (workspace_json_string(payload, "workspace_id") != enrollment.m_workspace_id || workspace_json_string(payload, "project_id") != enrollment.m_project_id || workspace_json_string(payload, "server_id") != enrollment.m_server_id) { + throw std::runtime_error("workspace-managed WebTTY client credential does not match this server"); + } + if (workspace_json_string(payload, "trust_keyset_id") != enrollment.m_workspace_trust_keyset_id) { + throw std::runtime_error("workspace-managed WebTTY client credential keyset does not match server enrollment"); + } + if (workspace_json_string(payload, "client_signing_key_id") != rstream::webtty::cli::base64url_encode(client_key_id)) { + throw std::runtime_error("workspace-managed WebTTY client credential signing key id does not match proof"); + } + auto public_signing_key = workspace_json_string(payload, "client_signing_public_key"); + auto device_public_signing_key = workspace_json_string(payload, "device_public_signing_key"); + if (public_signing_key.empty() || public_signing_key != device_public_signing_key) { + throw std::runtime_error("workspace-managed WebTTY client credential signing key does not match trusted device"); + } + auto public_signing_key_bytes = rstream::webtty::cli::base64url_decode(public_signing_key, 0, "workspace-managed WebTTY client signing public key"); + if (public_signing_key_bytes != client_public_key) { + throw std::runtime_error("workspace-managed WebTTY client credential signing key does not match proof"); + } + auto fingerprint = workspace_public_key_fingerprint(workspace_json_string(payload, "device_public_encryption_key"), device_public_signing_key); + if (fingerprint != workspace_json_string(payload, "device_fingerprint")) { + throw std::runtime_error("workspace-managed WebTTY client credential device fingerprint does not match public keys"); + } + if (!payload.contains("trust_payload") || !payload["trust_payload"].is_object()) { + throw std::runtime_error("workspace-managed WebTTY client credential trust payload is invalid"); + } + const auto& trust_payload = payload["trust_payload"]; + if (workspace_sha256_base64url(trust_payload) != workspace_json_string(payload, "trust_payload_hash")) { + throw std::runtime_error("workspace-managed WebTTY client credential trust payload hash does not match payload"); + } + if (!workspace_trust_payload_matches(enrollment, payload, trust_payload)) { + throw std::runtime_error("workspace-managed WebTTY client credential trust payload does not match device"); + } + verify_workspace_signature(enrollment.m_workspace_trust_public_signing_key, + trust_payload, + workspace_json_string(payload, "trust_keyset_signature"), + "workspace-managed WebTTY device trust"); + verify_workspace_signature(public_signing_key, + payload, + workspace_json_string(envelope, "signature"), + "workspace-managed WebTTY client credential"); + return client_public_key; +} + +} // namespace cli +} // namespace webtty +} // namespace rstream diff --git a/bin/webtty/bin/server/README.md b/bin/webtty/bin/server/README.md index 21c6c28..6f4b6b2 100644 --- a/bin/webtty/bin/server/README.md +++ b/bin/webtty/bin/server/README.md @@ -25,4 +25,15 @@ Providing `--identity`, `--identity-file`, `RSTREAM_WEBTTY_IDENTITY`, or `RSTREA Workspace-managed E2E is driven by trusted workspace devices. The enrollment file contains the workspace trust pins required to verify signed client credentials locally. At runtime, the C++ server verifies the credential embedded in `ClientProof`; it does not call the control plane. Do not configure explicit authorized-client keys for a workspace-managed server. +Workspace credentials support both ASN.1 DER signatures from native clients and IEEE P1363 signatures from browser device approvals. Verification binds the credential to the enrolled workspace, project, server, trusted keyset, and client signing key. + Execution modes are `spawn` and `login`. Registered servers default to `login`; set `--login-user ` to the name of the existing local OS account that will own every session. This is not an rstream account or the connecting operator's username. Run `id -un` on the target Linux/macOS host, or `$env:USERNAME` in PowerShell on the target Windows host, and pass that exact result. rstream does not create the account, and a fixed username is resolved before the server starts listening. Use `--allow-client-user` only when clients are deliberately allowed to select the OS user, or `--execution-mode spawn` for the lightweight child-process model. C++ login mode does not handle passwords. On POSIX it applies the target user, primary group, and supplementary groups through the local process credentials, which requires suitable service privileges. On Windows, login mode accepts the same account that runs the server and rejects attempts to switch accounts. Login sessions receive a conservative administrative environment and do not automatically inherit SSH agent sockets, cloud credentials, or rstream tokens. WebTransport is not implemented in the C++ server; use the Go server for WebTransport. + +### Advertised transport + +Lightweight and registered servers publish +`rstream.webtty.transport=plain|websocket`, based on the actual listener setting. +The label is included before the registered server admission signature. +Lightweight `plain` tunnels are private raw bytestreams; `--publish` is rejected +for this combination. Both supported transports work through a private +`rstrm://` dial. C++ WebTransport is not implemented and is rejected explicitly. diff --git a/bin/webtty/bin/server/main.cpp b/bin/webtty/bin/server/main.cpp index a1fd5ed..1b6fa16 100644 --- a/bin/webtty/bin/server/main.cpp +++ b/bin/webtty/bin/server/main.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -74,171 +75,6 @@ void apply_labels(std::map& labels, const std::vector< } } -std::string workspace_json_string(const nlohmann::json& value, const std::string& key) -{ - auto it = value.find(key); - if (it == value.end() || !it->is_string()) { - return ""; - } - return it->get(); -} - -std::string workspace_canonical_json(const nlohmann::json& value) -{ - if (value.is_null()) { - return "null"; - } - if (value.is_string() || value.is_boolean()) { - return value.dump(); - } - if (value.is_number_integer() || value.is_number_unsigned()) { - return value.dump(); - } - if (value.is_number_float()) { - throw std::runtime_error("workspace-managed WebTTY credential contains an unsupported JSON number"); - } - if (value.is_array()) { - std::string out = "["; - for (std::size_t i = 0; i < value.size(); ++i) { - if (i > 0) { - out += ","; - } - out += workspace_canonical_json(value[i]); - } - out += "]"; - return out; - } - if (value.is_object()) { - std::string out = "{"; - bool first = true; - for (auto it = value.begin(); it != value.end(); ++it) { - if (it.value().is_null()) { - continue; - } - if (!first) { - out += ","; - } - first = false; - out += nlohmann::json(it.key()).dump(); - out += ":"; - out += workspace_canonical_json(it.value()); - } - out += "}"; - return out; - } - throw std::runtime_error("workspace-managed WebTTY credential contains unsupported JSON"); -} - -std::string workspace_sha256_base64url(const nlohmann::json& value) -{ - auto canonical = workspace_canonical_json(value); - unsigned char digest[SHA256_DIGEST_LENGTH] = {}; - SHA256(reinterpret_cast(canonical.data()), canonical.size(), digest); - return rstream::webtty::cli::base64url_encode(rstream::webtty::byte_vector(digest, digest + SHA256_DIGEST_LENGTH)); -} - -std::string workspace_public_key_fingerprint(const std::string& public_encryption_key, const std::string& public_signing_key) -{ - nlohmann::json payload = { - {"public_encryption_key", public_encryption_key}, - {"public_signing_key", public_signing_key}, - {"type", "workspace.public_keys"}, - {"v", 1}, - }; - return "sha256:" + workspace_sha256_base64url(payload); -} - -void verify_workspace_signature(const std::string& public_signing_key, const nlohmann::json& payload, const std::string& signature, const std::string& label) -{ - auto public_key = rstream::webtty::cli::base64url_decode(public_signing_key, 0, label + " public signing key"); - auto sig = rstream::webtty::cli::base64url_decode(signature, 0, label + " signature"); - auto canonical = workspace_canonical_json(payload); - std::error_code error_code; - rstream::webtty::verify_p256_sha256_signature(public_key, rstream::webtty::byte_vector(canonical.begin(), canonical.end()), sig, error_code); - if (error_code) { - throw std::runtime_error(label + " signature is invalid"); - } -} - -bool workspace_trust_payload_matches(const rstream::webtty::cli::server_enrollment& enrollment, const nlohmann::json& credential, const nlohmann::json& trust_payload) -{ - if (workspace_json_string(trust_payload, "workspace_id") != enrollment.m_workspace_id) { - return false; - } - auto device_fingerprint = workspace_json_string(credential, "device_fingerprint"); - auto device_key_id = workspace_json_string(credential, "device_key_id"); - auto type = workspace_json_string(trust_payload, "type"); - if (type == "workspace.keyset.setup") { - return workspace_json_string(trust_payload, "keyset_fingerprint") == enrollment.m_workspace_trust_keyset_fingerprint && workspace_json_string(trust_payload, "keyset_public_signing_key") == enrollment.m_workspace_trust_public_signing_key && workspace_json_string(trust_payload, "device_fingerprint") == device_fingerprint; - } - if (type == "workspace.device.approve") { - return workspace_json_string(trust_payload, "keyset_id") == enrollment.m_workspace_trust_keyset_id && workspace_json_string(trust_payload, "target_device_key_id") == device_key_id && workspace_json_string(trust_payload, "target_fingerprint") == device_fingerprint; - } - if (type == "workspace.recovery_kit.use") { - return workspace_json_string(trust_payload, "keyset_id") == enrollment.m_workspace_trust_keyset_id && workspace_json_string(trust_payload, "device_fingerprint") == device_fingerprint; - } - return false; -} - -boost::optional verify_workspace_client_credential(const rstream::webtty::cli::server_enrollment& enrollment, - const rstream::webtty::byte_vector& client_key_id, - const rstream::webtty::byte_vector& client_public_key, - const rstream::webtty::byte_vector& credential) -{ - if (credential.empty()) { - return boost::none; - } - auto envelope = nlohmann::json::parse(std::string(credential.begin(), credential.end())); - if (!envelope.is_object() || envelope.value("v", 0) != 1 || !envelope.contains("payload") || !envelope["payload"].is_object()) { - throw std::runtime_error("workspace-managed WebTTY client credential is invalid"); - } - const auto& payload = envelope["payload"]; - if (workspace_json_string(payload, "type") != "workspace.webtty.client.credential") { - throw std::runtime_error("workspace-managed WebTTY client credential has an unsupported type"); - } - if (workspace_json_string(payload, "workspace_id") != enrollment.m_workspace_id || workspace_json_string(payload, "project_id") != enrollment.m_project_id || workspace_json_string(payload, "server_id") != enrollment.m_server_id) { - throw std::runtime_error("workspace-managed WebTTY client credential does not match this server"); - } - if (workspace_json_string(payload, "trust_keyset_id") != enrollment.m_workspace_trust_keyset_id) { - throw std::runtime_error("workspace-managed WebTTY client credential keyset does not match server enrollment"); - } - if (workspace_json_string(payload, "client_signing_key_id") != rstream::webtty::cli::base64url_encode(client_key_id)) { - throw std::runtime_error("workspace-managed WebTTY client credential signing key id does not match proof"); - } - auto public_signing_key = workspace_json_string(payload, "client_signing_public_key"); - auto device_public_signing_key = workspace_json_string(payload, "device_public_signing_key"); - if (public_signing_key.empty() || public_signing_key != device_public_signing_key) { - throw std::runtime_error("workspace-managed WebTTY client credential signing key does not match trusted device"); - } - auto public_signing_key_bytes = rstream::webtty::cli::base64url_decode(public_signing_key, 0, "workspace-managed WebTTY client signing public key"); - if (public_signing_key_bytes != client_public_key) { - throw std::runtime_error("workspace-managed WebTTY client credential signing key does not match proof"); - } - auto fingerprint = workspace_public_key_fingerprint(workspace_json_string(payload, "device_public_encryption_key"), device_public_signing_key); - if (fingerprint != workspace_json_string(payload, "device_fingerprint")) { - throw std::runtime_error("workspace-managed WebTTY client credential device fingerprint does not match public keys"); - } - if (!payload.contains("trust_payload") || !payload["trust_payload"].is_object()) { - throw std::runtime_error("workspace-managed WebTTY client credential trust payload is invalid"); - } - const auto& trust_payload = payload["trust_payload"]; - if (workspace_sha256_base64url(trust_payload) != workspace_json_string(payload, "trust_payload_hash")) { - throw std::runtime_error("workspace-managed WebTTY client credential trust payload hash does not match payload"); - } - if (!workspace_trust_payload_matches(enrollment, payload, trust_payload)) { - throw std::runtime_error("workspace-managed WebTTY client credential trust payload does not match device"); - } - verify_workspace_signature(enrollment.m_workspace_trust_public_signing_key, - trust_payload, - workspace_json_string(payload, "trust_keyset_signature"), - "workspace-managed WebTTY device trust"); - verify_workspace_signature(public_signing_key, - payload, - workspace_json_string(envelope, "signature"), - "workspace-managed WebTTY client credential"); - return client_public_key; -} - int run(int argc, char** argv) { auto args = docopt::docopt(USAGE, {argv + 1, argv + argc}, true, version); @@ -489,7 +325,7 @@ int run(int argc, char** argv) settings.m_client_proof_credential_verifier = [enrollment_value = *enrollment](const rstream::webtty::byte_vector& client_key_id, const rstream::webtty::byte_vector& client_public_key, const rstream::webtty::byte_vector& credential) -> boost::optional { - return verify_workspace_client_credential(enrollment_value, client_key_id, client_public_key, credential); + return rstream::webtty::cli::verify_workspace_client_credential(enrollment_value, client_key_id, client_public_key, credential); }; } if (!(enrollment && enrollment->m_encryption_policy == "workspace_managed")) { @@ -536,17 +372,19 @@ int run(int argc, char** argv) } } if (use_web) { - if (protocol_type != rstream::webtty::protocol::type::websocket) { - throw std::runtime_error("transport must be set to websocket when using rstream tunnels"); + if (protocol_type == rstream::webtty::protocol::type::plain && !enrollment && args.at("--publish").asBool()) { + throw std::runtime_error("lightweight plain WebTTY only supports private dialing; remove --publish or use websocket"); } if (rstream::webtty::cli::argv_has(argc, argv, "--uri")) { throw std::runtime_error("--uri cannot be combined with --rstream, --server-id, or --server-enrollment"); } rstream::webtty::webtty_uri_options uri_options; + uri_options.m_transport = protocol_type; uri_options.m_managed = enrollment.has_value(); uri_options.m_publish = publish; uri_options.m_execution_mode = execution_mode; uri_options.m_server_id = server_id; + uri_options.m_server_name = enrollment ? enrollment->m_server_name : ""; uri_options.m_host_key_id = host_key_id; uri_options.m_encryption_policy = enrollment ? enrollment->m_encryption_policy : ""; uri_options.m_labels = labels; diff --git a/bin/webtty/bin/test_cli_startup.py b/bin/webtty/bin/test_cli_startup.py new file mode 100644 index 0000000..955e093 --- /dev/null +++ b/bin/webtty/bin/test_cli_startup.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +# See LICENSE file in the project root for license information. + +from pathlib import Path +import subprocess +import sys + + +def main(): + binary = Path(sys.argv[1]).resolve() + argument = sys.argv[2] + if argument not in ("--help", "--version"): + raise ValueError("Expected --help or --version") + timeout = int(sys.argv[3]) + if timeout <= 0: + raise ValueError("Expected a positive startup timeout") + result = subprocess.run( + [str(binary), argument], capture_output=True, text=True, timeout=timeout + ) + if result.returncode != 0: + raise RuntimeError( + f"{binary.name} {argument} failed ({result.returncode:#x}): " + + result.stderr[:1024] + ) + if binary.stem not in result.stdout or ( + argument == "--help" and "usage:" not in result.stdout.lower() + ): + raise RuntimeError(f"{binary.name} {argument} returned incomplete output") + + +if __name__ == "__main__": + main() diff --git a/bin/webtty/lib/rstream/webtty/auth_proof.cpp b/bin/webtty/lib/rstream/webtty/auth_proof.cpp index c9cfc6e..93b0000 100644 --- a/bin/webtty/lib/rstream/webtty/auth_proof.cpp +++ b/bin/webtty/lib/rstream/webtty/auth_proof.cpp @@ -374,6 +374,31 @@ void verify_digest_signature(const byte_vector& public_key, const byte_vector& d } } +byte_vector p256_p1363_signature_to_der(const byte_vector& signature) +{ + if (signature.size() != 64) { + return {}; + } + std::unique_ptr r(BN_bin2bn(signature.data(), 32, nullptr), BN_free); + std::unique_ptr s(BN_bin2bn(signature.data() + 32, 32, nullptr), BN_free); + std::unique_ptr parsed(ECDSA_SIG_new(), ECDSA_SIG_free); + if (!r || !s || !parsed || ECDSA_SIG_set0(parsed.get(), r.get(), s.get()) != 1) { + return {}; + } + r.release(); + s.release(); + auto size = i2d_ECDSA_SIG(parsed.get(), nullptr); + if (size <= 0 || size > 72) { + return {}; + } + byte_vector der(static_cast(size)); + auto cursor = der.data(); + if (i2d_ECDSA_SIG(parsed.get(), &cursor) != size) { + return {}; + } + return der; +} + } // namespace void hash_webtty_client_proof_transcript(byte_vector& dst, const client_proof_transcript& transcript, std::error_code& error_code) @@ -513,7 +538,15 @@ void verify_p256_sha256_signature(const byte_vector& public_key, const byte_vect error_code.clear(); unsigned char digest[SHA256_DIGEST_LENGTH] = {}; SHA256(data_ptr(message), message.size(), digest); - verify_digest_signature(public_key, byte_vector(digest, digest + SHA256_DIGEST_LENGTH), signature, error_code); + auto hash = byte_vector(digest, digest + SHA256_DIGEST_LENGTH); + verify_digest_signature(public_key, hash, signature, error_code); + if (error_code && signature.size() == 64) { + auto der = p256_p1363_signature_to_der(signature); + if (!der.empty()) { + error_code.clear(); + verify_digest_signature(public_key, hash, der, error_code); + } + } } void verify_webtty_client_proof_transcript(const byte_vector& public_key, const client_proof_transcript& transcript, const byte_vector& signature, std::error_code& error_code) diff --git a/bin/webtty/lib/rstream/webtty/client.cpp b/bin/webtty/lib/rstream/webtty/client.cpp index 42fc08b..8431ef4 100644 --- a/bin/webtty/lib/rstream/webtty/client.cpp +++ b/bin/webtty/lib/rstream/webtty/client.cpp @@ -1,6 +1,12 @@ // See LICENSE file in the project root for license information. -#include "client.hpp" +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif #include #include @@ -19,6 +25,8 @@ #include #include + +#include "client.hpp" #ifndef RSTREAM_WITH_IO_STREAMS #include #endif @@ -58,6 +66,11 @@ #include #include "detail/convert.hpp" +#ifdef __APPLE__ +#include + +#include "detail/fifo_reader.hpp" +#endif #include "error.hpp" #include "terminal.hpp" @@ -472,6 +485,10 @@ class RSTREAM_GNUC_INTERNAL client::impl : public std::enable_shared_from_this m_fifo_reader; +#endif + stream_type m_stream_std_out; stream_type m_stream_std_err; @@ -535,7 +552,7 @@ client::impl::impl(const executor_type& executor, const config& config, const se m_settings(settings), m_state(state::null), m_resolver(executor), - m_socket(executor), + m_socket(m_strand), #ifdef _WIN32 m_stream_std_in(executor), m_stream_std_out(executor), @@ -560,10 +577,10 @@ client::impl::impl(const executor_type& executor, const config& config, const se m_payloader = std::make_shared(m_socket); } if (m_websocket) { - m_queue = std::make_shared>(*m_websocket); + m_queue = std::make_shared>(*m_websocket, boost::asio::strand(m_strand)); } else { - m_queue = std::make_shared>(*m_payloader); + m_queue = std::make_shared>(*m_payloader, boost::asio::strand(m_strand)); } if (m_config.m_protocol_config.m_options.m_allocate_tty) { #ifdef _WIN32 @@ -585,6 +602,18 @@ client::impl::impl(const executor_type& executor, const config& config, const se throw boost::system::system_error(error_code); } #endif +#ifdef __APPLE__ + if (m_config.m_protocol_config.m_options.m_interactive) { + struct stat info; + if (::fstat(m_stream_std_in.native_handle(), &info) == -1) { + throw boost::system::system_error(boost::system::error_code(errno, boost::system::system_category())); + } + if (S_ISFIFO(info.st_mode)) { + m_stream_std_in.native_non_blocking(true); + m_fifo_reader = std::make_unique(m_strand, m_stream_std_in.native_handle()); + } + } +#endif } void client::impl::async_run(async_run_completion_handler&& handler) @@ -929,6 +958,13 @@ void client::impl::close_resources() #ifdef DEBUG_BUILD assert(m_strand.running_in_this_thread()); #endif + if (m_websocket) { + m_websocket->set_option(boost::beast::websocket::stream_base::timeout{ + .handshake_timeout = boost::beast::websocket::stream_base::none(), + .idle_timeout = boost::beast::websocket::stream_base::none(), + .keep_alive_pings = false, + }); + } { boost::system::error_code tmp; m_resolver.cancel(); @@ -938,6 +974,11 @@ void client::impl::close_resources() m_stream_std_out.close(); m_stream_std_err.close(); #else +#ifdef __APPLE__ + if (m_fifo_reader) { + m_fifo_reader->close(); + } +#endif m_stream_std_in.close(tmp); m_stream_std_out.close(tmp); m_stream_std_err.close(tmp); @@ -1045,6 +1086,12 @@ void client::impl::do_read_std_in() return; } auto completion_handler = std::bind(&impl::on_read_std_in, shared_from_this(), std::placeholders::_1, std::placeholders::_2); +#ifdef __APPLE__ + if (m_fifo_reader) { + m_fifo_reader->async_read_some(boost::asio::mutable_buffer(m_buffer_std_in.map().get_data(), m_buffer_std_in.get_size()), boost::asio::bind_executor(m_strand, completion_handler)); + return; + } +#endif m_stream_std_in.async_read_some(boost::asio::mutable_buffer(m_buffer_std_in.map().get_data(), m_buffer_std_in.get_size()), boost::asio::bind_executor(m_strand, completion_handler)); } diff --git a/bin/webtty/lib/rstream/webtty/detail/fifo_reader.hpp b/bin/webtty/lib/rstream/webtty/detail/fifo_reader.hpp new file mode 100644 index 0000000..970516b --- /dev/null +++ b/bin/webtty/lib/rstream/webtty/detail/fifo_reader.hpp @@ -0,0 +1,231 @@ +// See LICENSE file in the project root for license information. + +#pragma once + +#ifdef __APPLE__ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace rstream { +namespace webtty { +namespace detail { + +// Darwin FIFO readiness is not reliably reported through kqueue. Only stdin +// FIFOs use this single-request select worker; network IO keeps its reactor. +// poll() also misses EOF for an empty Darwin FIFO, so it is not equivalent here. +// Even select can miss a concurrent writer close; bounded idle waits recheck EOF. +// Calls are serialized by the client strand. The borrowed descriptor must be +// nonblocking and remain open until close() has returned. +struct fifo_readiness_wait { + int operator()(int count, fd_set* descriptors, timeval* timeout) const + { + return ::select(count, descriptors, nullptr, nullptr, timeout); + } +}; + +template +class basic_fifo_reader { + public: + using executor_type = boost::asio::any_io_executor; + using completion_handler = core::completion_handler; + + basic_fifo_reader(const executor_type& executor, int descriptor, ReadinessWait readiness_wait = {}) + : m_executor(executor), + m_descriptor(descriptor), + m_wakeup_read(executor), + m_wakeup_write(executor), + m_readiness_wait(std::move(readiness_wait)) + { + if (descriptor < 0 || descriptor >= FD_SETSIZE) { + throw boost::system::system_error(boost::asio::error::fd_set_failure); + } + int wakeup[2]; + if (::pipe(wakeup) != 0) { + throw boost::system::system_error(system_error()); + } + // Owning descriptors close both ends if subsequent initialization fails. + boost::system::error_code error; + m_wakeup_read.assign(wakeup[0], error); + if (error) { + ::close(wakeup[0]); + ::close(wakeup[1]); + throw boost::system::system_error(error); + } + m_wakeup_write.assign(wakeup[1], error); + if (error) { + ::close(wakeup[1]); + throw boost::system::system_error(error); + } + for (int fd : wakeup) { + if (fd >= FD_SETSIZE) { + throw boost::system::system_error(boost::asio::error::fd_set_failure); + } + if (::fcntl(fd, F_SETFD, FD_CLOEXEC) == -1 || ::fcntl(fd, F_SETFL, O_NONBLOCK) == -1) { + throw boost::system::system_error(system_error()); + } + } + m_thread = std::thread([this] { run(); }); + } + + ~basic_fifo_reader() + { + close(); + } + + basic_fifo_reader(const basic_fifo_reader&) = delete; + basic_fifo_reader& operator=(const basic_fifo_reader&) = delete; + + void async_read_some(const boost::asio::mutable_buffer& buffer, completion_handler&& handler) + { + std::unique_lock lock(m_mutex); + if (m_stopped || m_pending || m_active) { + const boost::system::error_code error = m_stopped ? boost::asio::error::operation_aborted : boost::asio::error::already_started; + lock.unlock(); + core::invoke_completion_handler(m_executor, std::move(handler), error, std::size_t(0)); + return; + } + m_pending = std::make_unique(m_executor, buffer, std::move(handler)); + lock.unlock(); + m_ready.notify_one(); + } + + void close() + { + if (!m_thread.joinable()) { + return; + } + { + std::lock_guard lock(m_mutex); + m_stopped = true; + } + m_ready.notify_one(); + const char wakeup = 0; + while (::write(m_wakeup_write.native_handle(), &wakeup, 1) == -1 && errno == EINTR) { + } + m_thread.join(); + } + + private: + struct operation { + operation(const executor_type& executor, const boost::asio::mutable_buffer& buffer, completion_handler&& handler) + : m_buffer(buffer), + m_work(boost::asio::make_work_guard(boost::asio::get_associated_executor(handler, executor))), + m_handler(std::move(handler)) + { + } + + boost::asio::mutable_buffer m_buffer; + boost::asio::executor_work_guard m_work; + completion_handler m_handler; + }; + + static boost::system::error_code system_error() + { + return {errno, boost::system::system_category()}; + } + + boost::system::error_code read(const boost::asio::mutable_buffer& buffer, std::size_t& size) + { + if (buffer.size() == 0) { + return {}; + } + const auto wakeup = m_wakeup_read.native_handle(); + const auto count = std::max(m_descriptor, wakeup) + 1; + for (;;) { + const auto transferred = ::read(m_descriptor, buffer.data(), buffer.size()); + if (transferred > 0) { + size = static_cast(transferred); + return {}; + } + if (transferred == 0) { + return boost::asio::error::eof; + } + if (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) { + return system_error(); + } + fd_set descriptors; + FD_ZERO(&descriptors); + FD_SET(m_descriptor, &descriptors); + FD_SET(wakeup, &descriptors); + // Readable data and cancellation still wake immediately. One idle wakeup + // per second bounds a lost FIFO EOF notification without busy polling. + timeval timeout{1, 0}; + if (m_readiness_wait(count, &descriptors, &timeout) == -1) { + if (errno == EINTR) { + continue; + } + return system_error(); + } + if (FD_ISSET(wakeup, &descriptors)) { + return boost::asio::error::operation_aborted; + } + } + } + + void run() + { + for (;;) { + std::unique_ptr op; + bool stopped; + { + std::unique_lock lock(m_mutex); + m_ready.wait(lock, [this] { return m_stopped || m_pending; }); + stopped = m_stopped; + op = std::move(m_pending); + m_active = op != nullptr; + } + if (!op) { + return; + } + std::size_t size = 0; + boost::system::error_code error = stopped ? boost::asio::error::operation_aborted : read(op->m_buffer, size); + { + std::lock_guard lock(m_mutex); + m_active = false; + if (m_stopped) { + error = boost::asio::error::operation_aborted; + size = 0; + } + } + core::invoke_completion_handler(m_executor, std::move(op->m_handler), error, size); + } + } + + executor_type m_executor; + int m_descriptor; + boost::asio::posix::stream_descriptor m_wakeup_read; + boost::asio::posix::stream_descriptor m_wakeup_write; + [[no_unique_address]] ReadinessWait m_readiness_wait; + std::mutex m_mutex; + std::condition_variable m_ready; + std::thread m_thread; + std::unique_ptr m_pending; + bool m_active = false; + bool m_stopped = false; +}; + +using fifo_reader = basic_fifo_reader<>; + +} // namespace detail +} // namespace webtty +} // namespace rstream + +#endif diff --git a/bin/webtty/lib/rstream/webtty/server.cpp b/bin/webtty/lib/rstream/webtty/server.cpp index 69e475d..eeab1bd 100644 --- a/bin/webtty/lib/rstream/webtty/server.cpp +++ b/bin/webtty/lib/rstream/webtty/server.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include "server.hpp" #ifndef BOOST_PROCESS_VERSION @@ -1103,10 +1111,10 @@ server::impl::session::session(const executor_type& executor, socket_type&& sock m_payloader = std::make_shared(m_socket); } if (m_websocket) { - m_queue = std::make_shared>(*m_websocket); + m_queue = std::make_shared>(*m_websocket, boost::asio::strand(m_strand)); } else { - m_queue = std::make_shared>(*m_payloader); + m_queue = std::make_shared>(*m_payloader, boost::asio::strand(m_strand)); } } diff --git a/bin/webtty/lib/rstream/webtty/stream.cpp b/bin/webtty/lib/rstream/webtty/stream.cpp index 5a59e25..7117e48 100644 --- a/bin/webtty/lib/rstream/webtty/stream.cpp +++ b/bin/webtty/lib/rstream/webtty/stream.cpp @@ -8,6 +8,9 @@ #include #include +#ifdef _WIN32 +#include +#endif #include "error.hpp" #include "terminal.hpp" @@ -99,15 +102,6 @@ void close_handle(HANDLE& handle) } } -void cancel_thread_io(const std::shared_ptr& thread) -{ - if (thread != nullptr && thread->joinable()) { - if (!::CancelSynchronousIo(thread->native_handle()) && ::GetLastError() != ERROR_NOT_FOUND) { - // Closing the associated pipe below remains the final cancellation path. - } - } -} - std::error_code operation_aborted_error() { return std::error_code(ERROR_OPERATION_ABORTED, std::system_category()); @@ -318,19 +312,17 @@ void pty_windows::stop() } m_cv_read_op.notify_one(); m_cv_write_op.notify_one(); - cancel_thread_io(reading_thread); - cancel_thread_io(writing_thread); + if (reading_thread != nullptr) { + rstream::core::windows::detail::cancel_and_join(*reading_thread); + } + if (writing_thread != nullptr) { + rstream::core::windows::detail::cancel_and_join(*writing_thread); + } close_handle(in_write); close_handle(out_read); if (console != nullptr) { ::ClosePseudoConsole(console); } - if (reading_thread != nullptr && reading_thread->joinable()) { - reading_thread->join(); - } - if (writing_thread != nullptr && writing_thread->joinable()) { - writing_thread->join(); - } } void pty_windows::reading_thread() @@ -474,7 +466,8 @@ void pty_posix::allocate(std::error_code& error_code) void pty_posix::set_window_size(const terminal_size& terminal_size, std::error_code& error_code) { try { - terminal(m_master_fd).resize(terminal_size); + auto fd = m_std_in_out.is_open() ? m_std_in_out.native_handle() : m_master_fd; + terminal(fd).resize(terminal_size); } catch (const std::system_error& system_error) { error_code = system_error.code(); diff --git a/bin/webtty/lib/rstream/webtty/stream.hpp b/bin/webtty/lib/rstream/webtty/stream.hpp index 18e4823..c91c7b3 100644 --- a/bin/webtty/lib/rstream/webtty/stream.hpp +++ b/bin/webtty/lib/rstream/webtty/stream.hpp @@ -279,8 +279,10 @@ void pty_windows::on_setup(boost::process::extend::windows_executor build_webtty_labels(const webtty_uri_options& } }; set_label("application-protocol", "rstream.webtty"); + set_label("rstream.webtty.transport", options.m_transport == protocol::type::plain ? "plain" : "websocket"); set_label("rstream.webtty.capabilities", "exec"); set_label("rstream.webtty.execution.mode", options.m_execution_mode == execution_mode::login ? "login" : "spawn"); set_label("rstream.webtty.exec.path", "/"); set_label("rstream.webtty.server_id", options.m_server_id); + set_label("rstream.webtty.server_name", options.m_server_name); set_label("rstream.webtty.host_key_id", options.m_host_key_id); set_label("rstream.webtty.encryption_policy", options.m_encryption_policy); if (!options.m_host_key_id.empty()) { @@ -864,15 +869,21 @@ std::map build_webtty_labels(const webtty_uri_options& std::string build_webtty_uri(const webtty_uri_options& options) { + const bool publish = options.m_publish && (options.m_managed || options.m_transport != protocol::type::plain); std::string uri = "rstrm://"; if (options.m_managed && !options.m_server_id.empty()) { uri.append(pct_encode(options.m_server_id)); } uri.append("?rstrm.publish="); - uri.append(options.m_publish ? "true" : "false"); - uri.append("&rstrm.protocol="); - uri.append(options.m_managed ? "webtty&rstrm.type=bytestream" : "http"); - if (options.m_publish) { + uri.append(publish ? "true" : "false"); + uri.append("&rstrm.type=bytestream"); + if (options.m_managed) { + uri.append("&rstrm.protocol=webtty"); + } + else if (options.m_transport == protocol::type::websocket) { + uri.append("&rstrm.protocol=http"); + } + if (publish) { uri.append("&rstrm.token_auth=true"); } auto labels = build_webtty_labels(options); diff --git a/bin/webtty/lib/rstream/webtty/webtty.hpp b/bin/webtty/lib/rstream/webtty/webtty.hpp index e7e4341..615515d 100644 --- a/bin/webtty/lib/rstream/webtty/webtty.hpp +++ b/bin/webtty/lib/rstream/webtty/webtty.hpp @@ -303,10 +303,12 @@ void get_user_info(user_info& user_info, const username& username, std::error_co } // namespace protocol struct webtty_uri_options { + protocol::type m_transport = protocol::type::websocket; bool m_managed = false; bool m_publish = true; execution_mode m_execution_mode = execution_mode::spawn; std::string m_server_id; + std::string m_server_name; std::string m_host_key_id; std::string m_encryption_policy; std::string m_server_admission_label; diff --git a/cmake/tests.cmake b/cmake/tests.cmake index 6b28bde..4e255a4 100644 --- a/cmake/tests.cmake +++ b/cmake/tests.cmake @@ -12,6 +12,8 @@ else() set(BIN_OUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/test") endif() +option(RSTREAM_TEST_WINDOWS_PIPE_ASAN "Instrument native Windows pipe tests with MSVC AddressSanitizer" OFF) + set(RSTREAM_TEST_TIMEOUT_SCALE "1" CACHE STRING "Timeout scale for instrumented tests") set(RSTREAM_TEST_TIMEOUT_SECONDS "120" CACHE STRING "Maximum duration of one CTest test") diff --git a/conanfile.py b/conanfile.py index fe31b82..d6ab425 100755 --- a/conanfile.py +++ b/conanfile.py @@ -5,6 +5,7 @@ """ import os +import xml.etree.ElementTree as ET from conan import ConanFile from conan.errors import ConanInvalidConfiguration @@ -184,6 +185,9 @@ def configure(self): # component pruning belongs to the root build profile so this recipe remains # composable when an application also has a direct Boost requirement. self.options["boost"].without_url = False + if self.settings.os == "Windows": + self.options["docopt.cpp"].boost_regex = True + self.options["boost"].without_regex = False protobuf_ref = str(self.options.get_safe("protobuf_ref") or "").strip() if protobuf_ref and protobuf_ref != "none": shared_runtime = self.option_enabled(self.options.shared) or not self.option_enabled( @@ -222,6 +226,9 @@ def requirements(self): "boost/[>=1.81.0 <1.90.0]", transitive_headers=True, transitive_libs=True, + # The Windows docopt Boost.Regex variant pins an older Boost. + # Keep it on the same supported Boost version as the SDK. + force=self.settings.os == "Windows", ) self.requires("nlohmann_json/[>=3.11.2]", transitive_headers=True, transitive_libs=True) self.requires("spdlog/[>=1.12.0]", transitive_headers=True, transitive_libs=True) @@ -244,6 +251,13 @@ def requirements(self): def validate(self): self.validate_dependency_overrides() + if self.settings.os == "Windows" and not self.option_enabled( + self.dependencies["docopt.cpp"].options.get_safe("boost_regex", default=False) + ): + raise ConanInvalidConfiguration( + "Windows CLI tools require docopt.cpp with boost_regex=True to avoid " + "MSVC std::regex stack overflow while parsing the command help." + ) if self.option_enabled(self.options.static_libstdcxx) and not self.option_enabled( self.options.static_plugins ): @@ -276,15 +290,64 @@ def generate(self): def layout(self): conan.tools.cmake.cmake_layout(self) + def windows_pipe_asan_requested(self): + extra_variables = self.conf.get( + "tools.cmake.cmaketoolchain:extra_variables", default={}, check_type=dict + ) + requested = extra_variables.get("RSTREAM_TEST_WINDOWS_PIPE_ASAN", False) + if isinstance(requested, dict): + requested = requested.get("value", False) + return self.option_enabled(requested) + + @staticmethod + def verify_windows_pipe_asan_result(path): + expected = "rstream-test-core-windows-blocking-handle-asan" + tests = [ + test for test in ET.parse(path).iter("testcase") + if test.get("name") == expected + ] + if len(tests) != 1 or any( + tests[0].find(status) is not None + for status in ("failure", "error", "skipped") + ): + raise ConanInvalidConfiguration( + "The required Windows pipe AddressSanitizer test did not run successfully." + ) + def build(self): + require_pipe_asan = self.windows_pipe_asan_requested() + if require_pipe_asan and ( + self.settings.os != "Windows" + or not self.option_enabled(self.options.enable_testing) + or self.conf.get("tools.build:skip_test", default=False, check_type=bool) + ): + raise ConanInvalidConfiguration( + "Windows pipe AddressSanitizer qualification requires native Windows tests without skips." + ) cmake = conan.tools.cmake.CMake(self) cmake.configure() + if require_pipe_asan: + # Fail immediately if CMake ignored the requested test configuration. + cmake.build(target="rstream-test-core-windows-blocking-handle-asan") cmake.build() if self.option_enabled(self.options.enable_testing): test_environment = conan.tools.env.Environment() test_environment.define("CTEST_OUTPUT_ON_FAILURE", "1") with test_environment.vars(self).apply(): - cmake.test() + if require_pipe_asan: + report = "windows-pipe-tests.xml" + report_path = os.path.join(self.build_folder, report) + if os.path.exists(report_path): + os.remove(report_path) + # Keep the package suite's existing serial execution. + cmake.ctest(cli_args=[ + "--parallel", "1", "--output-on-failure", "--no-tests=error", + "--output-junit", report, + ]) + self.verify_windows_pipe_asan_result(report_path) + self.output.info("Required Windows pipe AddressSanitizer runtime test passed.") + else: + cmake.test() def package(self): cmake = conan.tools.cmake.CMake(self) diff --git a/demo/stun/lib/rstream/stun/attribute.hpp b/demo/stun/lib/rstream/stun/attribute.hpp index 5c55e59..5de7d31 100644 --- a/demo/stun/lib/rstream/stun/attribute.hpp +++ b/demo/stun/lib/rstream/stun/attribute.hpp @@ -102,7 +102,7 @@ class attribute_value : public helpers::message_base { public: attribute_type get_attribute_type() const { - return get_attribute_type(); + return stun::get_attribute_type(); } }; @@ -112,7 +112,7 @@ attribute attribute::make(const T& value) class attribute attribute; attribute.m_data_type = data_type::parsed; attribute.m_header.get_type() = get_attribute_msg_type(get_attribute_type()); - attribute.m_header.get_length() = helpers::byte_size_long_value(value); + attribute.m_header.get_length() = helpers::checked_length(helpers::byte_size_long_value(value)); attribute.m_data = std::make_shared(value); return attribute; } diff --git a/demo/stun/lib/rstream/stun/helpers/message.hpp b/demo/stun/lib/rstream/stun/helpers/message.hpp index 96c7af2..6d64ce1 100644 --- a/demo/stun/lib/rstream/stun/helpers/message.hpp +++ b/demo/stun/lib/rstream/stun/helpers/message.hpp @@ -3,7 +3,10 @@ #pragma once #include +#include +#include #include +#include #include @@ -18,17 +21,24 @@ namespace rstream { namespace stun { namespace helpers { +inline std::uint16_t checked_length(std::size_t size, std::uint16_t previous = 0) +{ + if (size > static_cast(std::numeric_limits::max()) - previous) { + throw std::length_error("STUN length exceeds its 16-bit field"); + } + return static_cast(size + previous); +} + template void parse_value(T& dst, const rstream::core::memory memory, std::size_t& offset) { auto diff = sizeof(T); - auto data = &((const std::uint8_t*)memory.get_const_data())[offset]; - if ((offset + diff) > memory.get_size()) { + if (offset > memory.get_size() || diff > memory.get_size() - offset) { throw rstream::core::system_error(rstream::io::error::code::deserialization_error, "data has invalid size"); } - auto value = *((const T*)data); + auto data = static_cast(memory.get_const_data()) + offset; + std::memcpy(&dst, data, sizeof(T)); offset += diff; - dst = value; } template diff --git a/demo/stun/lib/rstream/stun/message.cpp b/demo/stun/lib/rstream/stun/message.cpp index e128ed3..0efd81c 100644 --- a/demo/stun/lib/rstream/stun/message.cpp +++ b/demo/stun/lib/rstream/stun/message.cpp @@ -46,8 +46,8 @@ static const boost::bimap m_stun_methods_str = boost:: std::string to_string(msg_transaction_id msg_transaction_id) { std::stringstream str; - for (auto i = 0; i != msg_transaction_id.size(); ++i) { - str << (boost::format("%02x") % (int)msg_transaction_id.data()[i]); + for (auto value : msg_transaction_id) { + str << (boost::format("%02x") % static_cast(value)); } return str.str(); } @@ -435,7 +435,7 @@ const attributes& message_builder::get_attributes() const const message& message_builder::build() { - m_message.get_header().get_payload_length() = byte_size_long_value(get_attributes()); + m_message.get_header().get_payload_length() = helpers::checked_length(byte_size_long_value(get_attributes())); return m_message; } @@ -581,7 +581,7 @@ void serialize_value(void* dst, const attributes& src, std::size_t& { std::uint16_t payload_length = 0; for (const auto& attribute : src) { - payload_length += byte_size_long_value(attribute); + payload_length = checked_length(byte_size_long_value(attribute), payload_length); set_message_header_payload_length(dst, payload_length); serialize_value(dst, attribute, offset); } @@ -639,7 +639,7 @@ void parse_value
(header& dst, const rstream::core::memory memory, std::s if (dst.get_payload_length() & 0x03) { throw rstream::core::system_error(rstream::io::error::code::deserialization_error, "invalid payload size"); } - if (size != STUN_HEADER_SIZE + dst.get_payload_length()) { + if (size != static_cast(STUN_HEADER_SIZE) + dst.get_payload_length()) { throw rstream::core::system_error(rstream::io::error::code::deserialization_error, "invalid message size"); } ::memcpy(&dst.get_transaction_id(), &((const std::uint8_t*)memory.get_const_data())[offset], STUN_TRANSACTION_ID_SIZE); @@ -669,7 +669,7 @@ void parse_value(attribute_header& dst, const rstream::core::m parse_value(dst.get_length(), memory, offset); dst.get_type() = ntohs(dst.get_type()); dst.get_length() = ntohs(dst.get_length()); - if (size < STUN_ATTRIBUTE_HEADER_SIZE + dst.get_length()) { + if (size < static_cast(STUN_ATTRIBUTE_HEADER_SIZE) + dst.get_length()) { throw rstream::core::system_error(rstream::io::error::code::deserialization_error, "invalid attribute size"); } } diff --git a/demo/stun/test/test_stun_parsing.cpp b/demo/stun/test/test_stun_parsing.cpp index 16a8b38..3dca19b 100644 --- a/demo/stun/test/test_stun_parsing.cpp +++ b/demo/stun/test/test_stun_parsing.cpp @@ -1,7 +1,9 @@ // See LICENSE file in the project root for license information. #include +#include #include +#include #include #include @@ -145,7 +147,7 @@ void test_2() 0xae, }; const std::string software = "test vector"; - const auto address = std::make_pair("192.0.2.1", 32853); + const auto address = std::make_pair("192.0.2.1", std::uint16_t{32853}); const std::string integrity_key = "VOkJxbRl1RmTxUk/WvJxBt"; // create buffer auto memory = rstream::core::make_memory_wrapped(test_message, sizeof(test_message)); @@ -212,7 +214,7 @@ void test_3() 0xae, }; const std::string software = "test vector"; - const auto address = std::make_pair("2001:db8:1234:5678:11:2233:4455:6677", 32853); + const auto address = std::make_pair("2001:db8:1234:5678:11:2233:4455:6677", std::uint16_t{32853}); const std::string integrity_key = "VOkJxbRl1RmTxUk/WvJxBt"; // create buffer auto memory = rstream::core::make_memory_wrapped(test_message, sizeof(test_message)); @@ -319,7 +321,7 @@ void test_5() void test_6() { std::cout << "running '" << RSTREAM_STRFUNC << "'" << std::endl; - const auto address = std::make_pair("192.0.2.1", 32853); + const auto address = std::make_pair("192.0.2.1", std::uint16_t{32853}); auto builder = rstream::stun::message_builder(rstream::stun::stun_class::request, rstream::stun::stun_method::binding); { attribute_value_mapped_address attribute; @@ -356,7 +358,7 @@ void test_6() void test_7() { std::cout << "running '" << RSTREAM_STRFUNC << "'" << std::endl; - const auto address = std::make_pair("2001:db8:1234:5678:11:2233:4455:6677", 32853); + const auto address = std::make_pair("2001:db8:1234:5678:11:2233:4455:6677", std::uint16_t{32853}); auto builder = rstream::stun::message_builder(rstream::stun::stun_class::request, rstream::stun::stun_method::binding); { attribute_value_mapped_address attribute; @@ -478,6 +480,73 @@ void test_11() compare(attribute.get_length(), static_cast(0)); } +void test_attribute_value_type() +{ + const attribute_value_software software{}; + const attribute_value_username username{}; + const attribute_value_priority priority{}; + compare(software.get_attribute_type() == attribute_type::software, true); + compare(username.get_attribute_type() == attribute_type::username, true); + compare(priority.get_attribute_type() == attribute_type::priority, true); +} + +void test_length_limits() +{ + auto reject = [](auto operation) { + auto rejected = false; + try { + operation(); + } + catch (const std::length_error&) { + rejected = true; + } + compare(rejected, true); + }; + attribute_value_software software; + software.get_value().resize(65535, 'a'); + compare(attribute::make(software).get_header().get_length(), std::uint16_t(65535)); + software.get_value().push_back('a'); + reject([&] { attribute::make(software); }); + software.get_value().resize(65528); + message_builder maximum(stun_class::request, stun_method::binding); + maximum.add_attribute(software); + compare(maximum.build().get_header().get_payload_length(), std::uint16_t(65532)); + software.get_value().resize(32764); + message_builder overflow(stun_class::request, stun_method::binding); + overflow.add_attribute(software); + overflow.add_attribute(software); + reject([&] { overflow.build(); }); + message unbuilt; + unbuilt.get_attributes() = overflow.get_attributes(); + reject([&] { unbuilt.serialize_to_memory(); }); +} + +void test_unaligned_scalar_and_invalid_offsets() +{ + alignas(std::uint64_t) const std::uint8_t input[] = {0, 1, 2, 3, 4, 5, 6, 7, 8}; + const rstream::core::memory memory(input, sizeof(input), 0, nullptr); + std::uint64_t expected = 0; + std::memcpy(&expected, input + 1, sizeof(expected)); + std::uint64_t actual = 0; + std::size_t offset = 1; + helpers::parse_value(actual, memory, offset); + compare(actual, expected); + compare(offset, sizeof(input)); + for (auto invalid : {std::size_t(2), sizeof(input), std::numeric_limits::max()}) { + offset = invalid; + auto rejected = false; + try { + helpers::parse_value(actual, memory, offset); + } + catch (const rstream::core::system_error&) { + rejected = true; + } + compare(rejected, true); + compare(offset, invalid); + compare(actual, expected); + } +} + void run() { test_1(); @@ -491,6 +560,9 @@ void run() test_9(); test_10(); test_11(); + test_attribute_value_type(); + test_length_limits(); + test_unaligned_scalar_and_invalid_offsets(); } int main(int argc, char** argv) diff --git a/docs/001-sdk-engineering.md b/docs/001-sdk-engineering.md index 8b5124b..621c160 100644 --- a/docs/001-sdk-engineering.md +++ b/docs/001-sdk-engineering.md @@ -19,6 +19,18 @@ The supported platform contract includes: - glibc Linux packages built against the configured Yocto SDK baseline; - musl Linux packages for standalone, broadly portable binaries. +The required native Windows package matrix uses the Visual Studio 2022 runner +baseline (`windows-2022`) for all library and plugin combinations, with strict +warnings and warnings as errors. A runner upgrade is a toolchain change to +qualify explicitly. The external Conan consumer can select Boost 1.83 within +the supported range and rebuild the SDK, even when the initial package used +Boost 1.89. Optimized MSVC builds can report C4702 in Boost 1.83's +`const_buffer` conversion. Affected application and test translation units +suppress only that diagnostic while defining the external Asio buffer header, +then restore the warning state before any SDK definitions. No runtime code or +optimization settings change. Windows 11 ConPTY runtime checks remain part of +the cross-language WebTTY matrix. + Library linkage and plugin loading are independent choices: | SDK libraries | Plugins | Required | @@ -79,6 +91,45 @@ partial I/O, peer failure, reconnect, close, destruction, and handler reentrancy where applicable. ThreadSanitizer and repeated lifecycle tests are acceptance gates for changes to shared state. +An `any_completion_handler` allocator borrows storage owned by the handler. +It cannot allocate a shared operation control block that can outlive completion, +including blocks retained by cancellation weak pointers or a pending timer. +Those operations use the object's owning allocator. Templated operations use +`core::shared_operation_allocator`: ordinary custom allocator associations are +preserved, while an erased allocator uses the owning fallback. Intermediate I/O +and completion still preserve executor, allocator and cancellation associations. +This does not add an allocation, thread or lock; it changes ownership of the +existing control block. Tests must also exercise completion followed by late +cancellation and concurrent completion on another executor. + +Windows synchronous pipe workers stop accepting operations before shutdown. +Cancellation is retried while waiting for the dedicated worker to exit, because +an initial `CancelSynchronousIo` can arrive before `ReadFile` or `WriteFile`. +Pipe handles stay open until the worker is joined; `CloseHandle` with a pending +synchronous read can block. The retry is confined to shutdown and waits on the +thread handle, so completion wakes it immediately; active I/O gains no polling, +thread, allocation or lock. Deterministic tests hold each read/write behind an +event until the first cancellation has missed, then require cancellation and +preservation of both pipe handles. This applies to ConPTY and the common Windows +console/pipe adapter. + +The native Windows package matrix also enables `RSTREAM_TEST_WINDOWS_PIPE_ASAN`. +This MSVC Release/RelWithDebInfo target compiles the pipe adapter and its tests +with AddressSanitizer, including their Boost headers, without linking an +uninstrumented SDK copy. A stateful allocator and late cancellation exercise +control-block lifetime after the erased completion handler has been destroyed. +The option is off for ordinary consumers and requires the MSVC ASan component +when enabled; both consumer-selected Boost versions are exercised in CI. +The workflow passes a typed CMake cache boolean so legacy `option()` policies +cannot reset the request. Conan builds the required target before the full SDK +and checks its fresh JUnit result after the suite runs. A missing, skipped or +failed required test cannot qualify the package. The workflow configuration +and report checks have a regression test in `test/test_conan_windows_asan.py`. +All native package jobs force the SDK build while reusing cached dependencies. +`--build=missing` alone can reuse the SDK binary and bypass its internal tests +on a repeated CI run. The regression suite checks the actual Conan cache +behavior and requires execution even when that binary is already cached. + ## rstream runtime contract The SDK must preserve the rstream contract from configuration input to runtime @@ -159,9 +210,11 @@ limited to generated or third-party code and must not hide project warnings. ### Sanitizers and concurrency ```bash -cmake --preset asan -cmake --build --preset asan -ctest --preset asan +# Linux: ASan, UBSan and LSan with static libraries/plugins. +python3 .github/scripts/test-memory-sanitizers.py --output out/memory-sanitizers --jobs 3 + +# macOS: ASan and UBSan with shared SDK libraries/dynamic plugins. +python3 .github/scripts/test-memory-sanitizers.py --output out/memory-sanitizers --jobs 3 --shared --dynamic-plugins cmake --preset tsan cmake --build --preset tsan @@ -172,6 +225,16 @@ ctest --test-dir out/build/quality --repeat until-fail:20 \ -R 'core-(executor-binder|plugin-version)|io-common-(payloader-limits|queue|stream-tcp)|io-rstrm-(control-channel|handshake)|nperf-runtime|tunnel-proxy|webtty-.*runtime' ``` +Memory checks require Conan 2.31.2, Ninja and GNU coreutils (`timeout` on +Linux, `gtimeout` on macOS). The runner instruments the dependency graph and +includes instrumentation flags in Conan package identities, so cached release +libraries cannot be substituted. Protobuf's generated code and runtime must +use compatible instrumentation; mixing an ASan application with a prebuilt +Homebrew runtime can fail inside parsing or descriptor access. Use the `asan` +preset only with an already compatible dependency toolchain. Leak detection +is required on Linux; macOS does not provide that runtime. Both platforms keep +ASan and UBSan failures fatal and preserve JUnit and dependency provenance. + Run static analysis and the coverage preset for changes that affect public operations, ownership, state machines, or shared runtime code. Coverage is a diagnostic: new assertions must exercise failure and concurrency behavior, not @@ -273,3 +336,23 @@ A dependency update is complete only when: Do not solve a dependency update by disabling a topology, weakening warnings, removing a test, replacing a public dependency with a private one, or silently changing runtime behavior. + +### Darwin WebTTY stdin FIFOs + +WebTTY reads FIFO stdin through a single-request `select` worker on Darwin. +Regression tests reproduce missed `kqueue` read notifications and missed `poll` +EOF notifications for named FIFOs. A concurrent writer close can also leave +`select` waiting without an EOF notification. Its idle wait is bounded to one +second so the next nonblocking read observes EOF even if readiness is lost. +Data and cancellation still wake immediately; this adds at most one idle check +per second and no extra worker or payload queue. One borrowed buffer stays alive +through completion, and the next read follows network write completion. A wakeup +pipe cancels a pending wait before the input descriptor is closed. Closing joins the worker; it never waits for stdin EOF. The network +reactor, ordinary files and interactive terminals retain their existing paths. +This fallback consumes one worker and two wakeup descriptors per FIFO client; +select descriptor bounds are checked before use. Associated completion execution +is preserved on the client strand. Native, ASan/UBSan and TSan tests cover bulk +transfer, pending destruction, EOF, repeated close and descriptor bounds. +A deterministic fault-injection test suppresses FIFO readiness while preserving +the real cancellation descriptor, and requires EOF without the test deadline +having to cancel the reader. diff --git a/example/io-rstrm/client/example_io_rstrm_client.cpp b/example/io-rstrm/client/example_io_rstrm_client.cpp index 237c90d..40506b6 100644 --- a/example/io-rstrm/client/example_io_rstrm_client.cpp +++ b/example/io-rstrm/client/example_io_rstrm_client.cpp @@ -189,10 +189,9 @@ boost::asio::awaitable coro_tunnel(rstream::io_rstrm::client& client, std: } auto executor = co_await boost::asio::this_coro::executor; std::cout << "[tunnel] creating tunnel '" << name << "'..." << std::endl; - struct rstream::io_rstrm::tunnel_properties properties = { - .m_name = name, - .m_protocol = rstream::io_rstrm::protocol::tls, // plain TLS tunnel - }; + rstream::io_rstrm::tunnel_properties properties{}; + properties.m_name = name; + properties.m_protocol = rstream::io_rstrm::protocol::tls; auto tunnel = co_await client.async_create_tunnel(properties, boost::asio::use_awaitable); std::cout << "[tunnel] tunnel '" << name << "' created" << std::endl; auto await_listener = boost::asio::co_spawn(executor, coro_listener(tunnel), boost::asio::use_awaitable); diff --git a/example/io/http/CMakeLists.txt b/example/io/http/CMakeLists.txt index 8ba39a6..4c816a6 100644 --- a/example/io/http/CMakeLists.txt +++ b/example/io/http/CMakeLists.txt @@ -4,9 +4,23 @@ add_executable(${PROJECT_NAME}-example-io-http-client example_io_http_client.cpp) target_link_libraries(${PROJECT_NAME}-example-io-http-client docopt::docopt_s_maybe ${PROJECT_NAME}::${PROJECT_NAME}) +rstream_enable_runtime_plugins(${PROJECT_NAME}-example-io-http-client ${PROJECT_NAME}-plugin-io-generic ${PROJECT_NAME}-plugin-io-rstrm) add_executable(${PROJECT_NAME}-example-io-http-server example_io_http_server.cpp) target_link_libraries(${PROJECT_NAME}-example-io-http-server docopt::docopt_s_maybe ${PROJECT_NAME}::${PROJECT_NAME}) +rstream_enable_runtime_plugins(${PROJECT_NAME}-example-io-http-server ${PROJECT_NAME}-plugin-io-generic ${PROJECT_NAME}-plugin-io-rstrm) add_executable(${PROJECT_NAME}-example-io-http-upgrade example_io_http_upgrade.cpp) target_link_libraries(${PROJECT_NAME}-example-io-http-upgrade docopt::docopt_s_maybe ${PROJECT_NAME}::${PROJECT_NAME}) +rstream_enable_runtime_plugins(${PROJECT_NAME}-example-io-http-upgrade ${PROJECT_NAME}-plugin-io-generic ${PROJECT_NAME}-plugin-io-rstrm) + +if(ENABLE_TESTING) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + add_test(NAME ${PROJECT_NAME}-test-io-http-examples + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_http_examples.py + $ + $) + rstream_configure_test(${PROJECT_NAME}-test-io-http-examples) + set_property(GLOBAL APPEND PROPERTY RSTREAM_TEST_TARGETS + ${PROJECT_NAME}-example-io-http-server ${PROJECT_NAME}-example-io-http-client) +endif() diff --git a/example/io/http/example_io_http_client.cpp b/example/io/http/example_io_http_client.cpp index 5d9149c..40205a5 100644 --- a/example/io/http/example_io_http_client.cpp +++ b/example/io/http/example_io_http_client.cpp @@ -1,5 +1,6 @@ // See LICENSE file in the project root for license information. +#include #include #include #include @@ -22,7 +23,6 @@ #include #endif #include -#include #include #include @@ -50,7 +50,7 @@ rstream-example-io-http-client const auto version = std::string("rstream-example-io-http-client ") + RSTREAM_VERSION; -boost::asio::awaitable run(const rstream::io::address &address) +boost::asio::awaitable run(rstream::io::address address) { // resolve the hostname auto executor = co_await boost::asio::this_coro::executor; diff --git a/example/io/http/example_io_http_server.cpp b/example/io/http/example_io_http_server.cpp index ffe3a33..c4d52d3 100644 --- a/example/io/http/example_io_http_server.cpp +++ b/example/io/http/example_io_http_server.cpp @@ -1,5 +1,6 @@ // See LICENSE file in the project root for license information. +#include #include #include #include @@ -7,6 +8,7 @@ #include #include +#include #ifndef RSTREAM_WITH_IO_STREAMS #include #endif @@ -19,7 +21,6 @@ #include #endif #include -#include #include #include @@ -57,12 +58,10 @@ boost::asio::awaitable session(protocol::socket socket) co_await boost::beast::http::async_read(socket, buffer, req, boost::asio::use_awaitable); // prepare the response boost::beast::http::response res; - char hostname[1024]; - gethostname(hostname, 1024); res = {boost::beast::http::status::ok, req.version()}; res.set(boost::beast::http::field::content_type, "text/plain"); res.keep_alive(req.keep_alive()); - res.body() = hostname; + res.body() = boost::asio::ip::host_name(); res.prepare_payload(); // write the response back to the client co_await boost::beast::http::async_write(socket, res, boost::asio::use_awaitable); @@ -77,7 +76,7 @@ boost::asio::awaitable session(protocol::socket socket) } } -boost::asio::awaitable listener(const rstream::io::address &address) +boost::asio::awaitable listener(rstream::io::address address) { auto executor = co_await boost::asio::this_coro::executor; #ifdef RSTREAM_WITH_IO_STREAMS diff --git a/example/io/http/test_http_examples.py b/example/io/http/test_http_examples.py new file mode 100644 index 0000000..3e5f542 --- /dev/null +++ b/example/io/http/test_http_examples.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# See LICENSE file in the project root for license information. + +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time + + +def main(): + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + address = "127.0.0.1:" + str(reservation.getsockname()[1]) + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "server.log" + with log_path.open("w") as log: + server = subprocess.Popen( + [sys.argv[1], "--uri=" + address], stdout=log, stderr=subprocess.STDOUT + ) + try: + deadline = time.monotonic() + 15 + while "server started on" not in log_path.read_text(errors="replace"): + if server.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError( + "HTTP example failed to start: " + + log_path.read_text(errors="replace") + ) + time.sleep(0.05) + result = subprocess.run( + [sys.argv[2], "--uri=" + address], + capture_output=True, text=True, timeout=15, check=True, + ) + if result.stderr or "HTTP/1.1 200 OK" not in result.stdout: + raise RuntimeError("HTTP example response failed: " + repr(result)) + if not result.stdout.rstrip().endswith(socket.gethostname()): + raise RuntimeError("HTTP example response hostname mismatch") + finally: + if server.poll() is None: + server.kill() + server.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/lib/rstream/core/detail/metrics/wrapper.hpp b/lib/rstream/core/detail/metrics/wrapper.hpp index d4e8751..513644b 100644 --- a/lib/rstream/core/detail/metrics/wrapper.hpp +++ b/lib/rstream/core/detail/metrics/wrapper.hpp @@ -88,6 +88,8 @@ class wrapper : public wrapper_base { template class wrapper::handle : public wrapper_base { public: + ~handle() override; + std::string name() const override; std::string help() const override; metric::type type() const override; @@ -250,6 +252,12 @@ wrapper::handle::handle(wrapper_common::ptr parent, const detail::metrics::la { } +template +wrapper::handle::~handle() +{ + m_impl->deinit(); +} + template std::string wrapper::handle::name() const { diff --git a/lib/rstream/core/operation_allocator.hpp b/lib/rstream/core/operation_allocator.hpp new file mode 100644 index 0000000..8f66344 --- /dev/null +++ b/lib/rstream/core/operation_allocator.hpp @@ -0,0 +1,43 @@ +// See LICENSE file in the project root for license information. + +#pragma once + +#include +#include +#include + +#include +#include + +#include + +namespace rstream { +namespace core { +namespace detail { + +template +struct is_erased_handler_allocator : std::false_type {}; + +template +struct is_erased_handler_allocator> : std::true_type {}; + +} // namespace detail + +// An erased handler allocator borrows the handler. A shared operation's control +// block may survive completion (for example through cancellation weak pointers). +// Keep that storage on the object's owning allocator; ordinary custom allocator +// associations and the handler's dispatch/completion associations are preserved. +template +auto shared_operation_allocator(const Handler& handler, allocator::ptr fallback = {}) +{ + auto associated = boost::asio::get_associated_allocator(handler); + if constexpr (detail::is_erased_handler_allocator::value) { + return allocator::wrapper(std::move(fallback)); + } + else { + return associated; + } +} + +} // namespace core +} // namespace rstream diff --git a/lib/rstream/core/windows/blocking_handle.cpp b/lib/rstream/core/windows/blocking_handle.cpp index ba4771c..5559a1e 100644 --- a/lib/rstream/core/windows/blocking_handle.cpp +++ b/lib/rstream/core/windows/blocking_handle.cpp @@ -15,6 +15,8 @@ #include #include +#include "detail/cancel_io.hpp" + // clang-format off // To be included after boost headers. #include @@ -95,14 +97,13 @@ class blocking_handle::impl : public std::enable_shared_from_this { void async_read_some(const boost::asio::mutable_buffer& buffer, completion_handler&& handler) { - auto operation_allocator = boost::asio::get_associated_allocator(handler); - submit(std::allocate_shared(operation_allocator, operation::type::read, buffer, m_executor, std::move(handler))); + // Cancellation weak pointers can outlive the erased handler and its allocator. + submit(std::make_shared(operation::type::read, buffer, m_executor, std::move(handler))); } void async_write(const boost::asio::const_buffer& buffer, completion_handler&& handler) { - auto operation_allocator = boost::asio::get_associated_allocator(handler); - submit(std::allocate_shared(operation_allocator, operation::type::write, buffer, m_executor, std::move(handler))); + submit(std::make_shared(operation::type::write, buffer, m_executor, std::move(handler))); } void cancel() @@ -115,29 +116,22 @@ class blocking_handle::impl : public std::enable_shared_from_this { std::lock_guard close_lock(m_close_mutex); HANDLE handle = nullptr; std::shared_ptr pending; - bool cancel_active = false; { std::lock_guard lock(m_mutex); if (!m_running && m_handle == nullptr && !m_thread.joinable()) { return; } - m_running = false; - handle = m_handle; - m_handle = nullptr; - pending = std::move(m_pending); - cancel_active = m_active != nullptr; + m_running = false; + handle = m_handle; + m_handle = nullptr; + pending = std::move(m_pending); } m_cv.notify_one(); - if (cancel_active && m_thread.joinable()) { - ::CancelSynchronousIo(m_thread.native_handle()); - } + detail::cancel_and_join(m_thread); if (handle != nullptr) { ::CloseHandle(handle); } complete(pending, operation_aborted_error(), 0); - if (m_thread.joinable()) { - m_thread.join(); - } } private: diff --git a/lib/rstream/core/windows/detail/cancel_io.hpp b/lib/rstream/core/windows/detail/cancel_io.hpp new file mode 100644 index 0000000..5c86236 --- /dev/null +++ b/lib/rstream/core/windows/detail/cancel_io.hpp @@ -0,0 +1,47 @@ +// See LICENSE file in the project root for license information. + +#pragma once + +#ifdef _WIN32 + +#include + +#include + +namespace rstream { +namespace core { +namespace windows { +namespace detail { + +struct cancel_synchronous_io { + void operator()(HANDLE thread) const noexcept + { + // ERROR_NOT_FOUND is expected when the worker has not entered its I/O yet. + ::CancelSynchronousIo(thread); + } +}; + +// The caller must stop admission and wake the dedicated worker first. Keep its +// I/O handles open until this returns: cancellation can precede the system call, +// and closing a synchronous handle with a pending read can itself block. +template +void cancel_and_join(std::thread& thread, Cancel cancel = {}) +{ + if (!thread.joinable()) { + return; + } + const auto handle = thread.native_handle(); + do { + cancel(handle); + // Thread completion wakes this immediately. The timeout only retries a + // cancellation that raced with the worker entering ReadFile or WriteFile. + } while (::WaitForSingleObject(handle, 10) == WAIT_TIMEOUT); + thread.join(); +} + +} // namespace detail +} // namespace windows +} // namespace core +} // namespace rstream + +#endif diff --git a/lib/rstream/io-rstrm/acceptor.cpp b/lib/rstream/io-rstrm/acceptor.cpp index 2af38bd..36726cf 100644 --- a/lib/rstream/io-rstrm/acceptor.cpp +++ b/lib/rstream/io-rstrm/acceptor.cpp @@ -342,7 +342,7 @@ void acceptor::impl::async_accept(socket& peer, endpoint& endpoint, async_accept return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, peer, endpoint, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), peer, endpoint, std::move(handler)); { std::lock_guard lock(m_mutex); if (m_start_pending) { diff --git a/lib/rstream/io-rstrm/client.cpp b/lib/rstream/io-rstrm/client.cpp index 401487a..c783ff9 100644 --- a/lib/rstream/io-rstrm/client.cpp +++ b/lib/rstream/io-rstrm/client.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include #include #include @@ -625,8 +624,7 @@ void client::impl::set_control_callbacks(const control_callbacks& callbacks, boo void client::impl::async_connect(const io::address& address, async_connect_completion_handler&& handler) { - auto operation_allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(operation_allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); { std::lock_guard lock(m_mutex); if (!m_is_state_non_null) { @@ -668,9 +666,8 @@ void client::impl::async_connect(async_connect_completion_handler&& handler) void client::impl::async_create_tunnel(const tunnel_properties& properties, async_create_tunnel_completion_handler&& handler) { - auto operation_allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared( - operation_allocator, + const auto op = std::allocate_shared( + core::allocator::wrapper(m_allocator), normalize_tunnel_properties(properties), std::move(handler)); auto cancellation_slot = boost::asio::get_associated_cancellation_slot(op->m_handler); @@ -697,9 +694,8 @@ void client::impl::async_create_tunnel(const tunnel_properties& properties, asyn void client::impl::async_accept_tunnel(const std::string& tunnel_id, socket& peer, endpoint& endpoint, tunnel::async_accept_completion_handler&& handler) { - auto operation_allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared( - operation_allocator, + const auto op = std::allocate_shared( + core::allocator::wrapper(m_allocator), peer, endpoint, std::move(handler)); @@ -1065,7 +1061,7 @@ void client::impl::do_resolve_host() #ifdef RSTREAM_WITH_IO_STREAMS m_resolver.async_resolve(m_server_address.m_url, boost::asio::bind_executor(m_strand, completion_handler)); #else - m_resolver.async_resolve(m_server_address.host(), m_server_address.port(), boost::asio::bind_executor(m_strand, completion_handler)); + m_resolver.async_resolve(m_server_address.m_url.host_address(), m_server_address.port(), boost::asio::bind_executor(m_strand, completion_handler)); #endif } @@ -1505,7 +1501,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr return; } #ifdef DEBUG_BUILD - m_logger->trace("received message from peer\n{}", core::helpers::to_json_string(message)); + m_logger->trace("received message from peer [message_type={}]", static_cast(message.payload_case())); #endif boost::system::error_code error_code; if (!is_message_expected(m_state, message)) { @@ -1538,7 +1534,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr } else { #ifdef DEBUG_BUILD - m_logger->trace("received open response with no client ID\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received open response with no client ID"); #endif error_code = error::code::protocol_error; } @@ -1548,7 +1544,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr } else { #ifdef DEBUG_BUILD - m_logger->trace("received open response with no ok or error\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received open response with no ok or error"); #endif error_code = error::code::protocol_error; } @@ -1562,7 +1558,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr it = m_create_tunnel_ops.find(payload.request_id()); if (it == m_create_tunnel_ops.end()) { #ifdef DEBUG_BUILD - m_logger->trace("received tunnel response with no matching request\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received tunnel response with no matching request"); #endif error_code = error::code::protocol_error; } @@ -1571,7 +1567,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr detail::convert(tunnel_properties, payload.tunnel_properties()); if (!tunnel_properties.m_id) { #ifdef DEBUG_BUILD - m_logger->trace("received tunnel response with no tunnel ID\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received tunnel response with no tunnel ID"); #endif error_code = error::code::protocol_error; } @@ -1580,7 +1576,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr } if (!error_code && !error && m_tunnels.find(tunnel_properties.m_id.get()) != m_tunnels.end()) { #ifdef DEBUG_BUILD - m_logger->trace("received tunnel response with duplicate active tunnel ID\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received tunnel response with duplicate active tunnel ID"); #endif error_code = error::code::protocol_error; } @@ -1590,7 +1586,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr } else { #ifdef DEBUG_BUILD - m_logger->trace("received tunnel response with no properties or error\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received tunnel response with no properties or error"); #endif error_code = error::code::protocol_error; } @@ -1607,7 +1603,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr it = m_tunnels.find(payload.tunnel_id()); if (it == m_tunnels.end()) { #ifdef DEBUG_BUILD - m_logger->trace("received tunnel close response with no matching tunnel\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received tunnel close response with no matching tunnel"); #endif error_code = error::code::protocol_error; } @@ -1627,7 +1623,7 @@ void client::impl::on_read_incoming_message(generation_type generation, const pr it = m_tunnels.find(payload.tunnel_id()); if (it == m_tunnels.end()) { #ifdef DEBUG_BUILD - m_logger->trace("received proxy connection request with no matching tunnel\n{}", core::helpers::to_json_string(payload)); + m_logger->trace("received proxy connection request with no matching tunnel"); #endif error_code = error::code::protocol_error; } @@ -1704,7 +1700,7 @@ void client::impl::do_send_message(const protobuf::Message& message, const on_se return; } #ifdef DEBUG_BUILD - m_logger->trace("sending message to peer\n{}", core::helpers::to_json_string(message)); + m_logger->trace("sending message to peer [message_type={}]", static_cast(message.payload_case())); #endif core::buffer buffer; if (!core::detail::serialize_protobuf_message(message, buffer, m_allocator)) { diff --git a/lib/rstream/io-rstrm/detail/handshake.hpp b/lib/rstream/io-rstrm/detail/handshake.hpp index 6327476..cf235b6 100644 --- a/lib/rstream/io-rstrm/detail/handshake.hpp +++ b/lib/rstream/io-rstrm/detail/handshake.hpp @@ -12,8 +12,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -150,10 +150,9 @@ auto handshake::async_run(type type, const std::string& id_name, const b { return boost::asio::async_initiate( [this](auto&& handler, enum type type, const std::string& id_name, const boost::optional& token) { - using operation_type = async_run_operation>; - auto operation_allocator = boost::asio::get_associated_allocator(handler); + using operation_type = async_run_operation>; std::allocate_shared( - operation_allocator, + core::shared_operation_allocator(handler, m_allocator), m_next_layer, m_server_address, m_config, @@ -256,7 +255,7 @@ void handshake::async_run_operation::do_write_request(handler_type ha } else { #ifdef DEBUG_BUILD - m_logger->trace("sending message to peer\n{}", core::helpers::to_json_string(message)); + m_logger->trace("sending message to peer [message_type={}]", static_cast(message.payload_case())); #endif core::buffer buffer; if (!core::detail::serialize_protobuf_message(message, buffer, m_allocator)) { @@ -328,7 +327,7 @@ void handshake::async_run_operation::on_read_incoming_protobuf_messag { boost::system::error_code error_code; #ifdef DEBUG_BUILD - m_logger->trace("received message from peer\n{}", core::helpers::to_json_string(message)); + m_logger->trace("received message from peer [message_type={}]", static_cast(message.payload_case())); #endif if (m_type == type::stream_req && message.has_stream_rsp()) { const auto& rsp = message.stream_rsp(); diff --git a/lib/rstream/io-rstrm/detail/stable_domain.hpp b/lib/rstream/io-rstrm/detail/stable_domain.hpp index e35ff1d..2ca1911 100644 --- a/lib/rstream/io-rstrm/detail/stable_domain.hpp +++ b/lib/rstream/io-rstrm/detail/stable_domain.hpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -72,6 +73,11 @@ inline boost::optional generate_stable_domain(const io::address& se while (!host.empty() && host.back() == '.') { host.pop_back(); } + boost::system::error_code address_error; + boost::asio::ip::make_address(host, address_error); + if (!address_error) { + return {}; + } if (host.empty() || host.find(':') != std::string::npos) { return {}; } diff --git a/lib/rstream/io-rstrm/socket.cpp b/lib/rstream/io-rstrm/socket.cpp index df831ba..50ccc50 100644 --- a/lib/rstream/io-rstrm/socket.cpp +++ b/lib/rstream/io-rstrm/socket.cpp @@ -422,7 +422,7 @@ void socket::impl::async_connect(type type, const endpoint& endpoint, async_conn return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); { std::lock_guard lock(m_mutex); if (m_is_state_non_null) { @@ -471,7 +471,7 @@ void socket::impl::async_write_some(const boost::asio::const_buffer& buffer, asy return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); install_transfer_cancellation(op); boost::asio::dispatch( m_strand, @@ -486,7 +486,7 @@ void socket::impl::async_write_some(const const_buffer_sequence_type& buffer, as return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); install_transfer_cancellation(op); boost::asio::dispatch( m_strand, @@ -501,7 +501,7 @@ void socket::impl::async_read_some(const boost::asio::mutable_buffer& buffer, as return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); install_transfer_cancellation(op); boost::asio::dispatch( m_strand, @@ -516,7 +516,7 @@ void socket::impl::async_read_some(const mutable_buffer_sequence_type& buffer, a return; } auto allocator = boost::asio::get_associated_allocator(handler); - const auto op = std::allocate_shared(allocator, std::move(handler)); + const auto op = std::allocate_shared(core::allocator::wrapper(m_allocator), std::move(handler)); install_transfer_cancellation(op); boost::asio::dispatch( m_strand, @@ -782,7 +782,7 @@ void socket::impl::do_resolve_host() #ifdef RSTREAM_WITH_IO_STREAMS m_resolver.async_resolve(m_endpoint.m_server_address.m_url, std::move(internal_handler)); #else - m_resolver.async_resolve(m_endpoint.m_server_address.host(), m_endpoint.m_server_address.port(), std::move(internal_handler)); + m_resolver.async_resolve(m_endpoint.m_server_address.m_url.host_address(), m_endpoint.m_server_address.port(), std::move(internal_handler)); #endif } diff --git a/lib/rstream/io/acceptor_base.hpp b/lib/rstream/io/acceptor_base.hpp index 2f5c79a..16996b2 100644 --- a/lib/rstream/io/acceptor_base.hpp +++ b/lib/rstream/io/acceptor_base.hpp @@ -16,6 +16,7 @@ #include #include +#include #include "socket_base.hpp" @@ -80,8 +81,7 @@ class acceptor_base : public socket_base { [this](auto&& handler) { using operation_type = owning_accept_operation>; auto executor = io_object::get_executor(); - auto allocator = boost::asio::get_associated_allocator(handler); - auto operation = std::allocate_shared(allocator, executor, std::forward(handler)); + auto operation = std::allocate_shared(core::shared_operation_allocator(handler), executor, std::forward(handler)); auto cancellation_slot = boost::asio::get_associated_cancellation_slot(operation->m_handler); if (cancellation_slot.is_connected()) { const std::weak_ptr weak_operation = operation; diff --git a/lib/rstream/io/detail/http/upgrade.hpp b/lib/rstream/io/detail/http/upgrade.hpp index c3cc3b8..70e8cf2 100644 --- a/lib/rstream/io/detail/http/upgrade.hpp +++ b/lib/rstream/io/detail/http/upgrade.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -207,10 +208,9 @@ auto upgrade::async_handshake(const std::string& host, const std::string { return boost::asio::async_initiate( [this](auto&& handler, const std::string& host, const std::string& target) { - using operation_type = async_handshake_operation>; - auto operation_allocator = boost::asio::get_associated_allocator(handler); + using operation_type = async_handshake_operation>; std::allocate_shared( - operation_allocator, + core::shared_operation_allocator(handler, m_allocator), m_next_layer, m_allocator, host, @@ -227,10 +227,9 @@ auto upgrade::async_accept(BOOST_ASIO_MOVE_ARG(accept_handler) handler) { return boost::asio::async_initiate( [this](auto&& handler) { - using operation_type = async_accept_operation>; - auto operation_allocator = boost::asio::get_associated_allocator(handler); + using operation_type = async_accept_operation>; std::allocate_shared( - operation_allocator, + core::shared_operation_allocator(handler, m_allocator), m_next_layer, m_allocator, m_response_decorator) diff --git a/lib/rstream/io/detail/metrics/exposer.cpp b/lib/rstream/io/detail/metrics/exposer.cpp index a50c509..0eb9ba5 100644 --- a/lib/rstream/io/detail/metrics/exposer.cpp +++ b/lib/rstream/io/detail/metrics/exposer.cpp @@ -433,7 +433,7 @@ void exposer::impl::do_resolve_host() #ifdef RSTREAM_WITH_IO_STREAMS m_resolver.async_resolve(m_config.m_address.m_url, boost::asio::bind_executor(m_strand, completion_handler)); #else - m_resolver.async_resolve(m_config.m_address.host(), m_config.m_address.port(), boost::asio::bind_executor(m_strand, completion_handler)); + m_resolver.async_resolve(m_config.m_address.m_url.host_address(), m_config.m_address.port(), boost::asio::bind_executor(m_strand, completion_handler)); #endif } diff --git a/lib/rstream/io/detail/stream/acceptor_ssl.cpp b/lib/rstream/io/detail/stream/acceptor_ssl.cpp index 7f316ff..9091e28 100644 --- a/lib/rstream/io/detail/stream/acceptor_ssl.cpp +++ b/lib/rstream/io/detail/stream/acceptor_ssl.cpp @@ -261,8 +261,7 @@ void acceptor_ssl::impl::async_accept_internal(stream_socket& peer, endpoint& en it = m_async_accept_upstream_ops.erase(it); } else { - const auto allocator = boost::asio::get_associated_allocator(handler); - m_async_accept_downstream_op = std::allocate_shared(allocator, peer, endpoint, std::move(handler)); + m_async_accept_downstream_op = std::allocate_shared(core::allocator::wrapper(m_allocator), peer, endpoint, std::move(handler)); do_accept(); } } diff --git a/lib/rstream/io/detail/stream/stream_socket_ssl.cpp b/lib/rstream/io/detail/stream/stream_socket_ssl.cpp index 72e234b..94c7548 100644 --- a/lib/rstream/io/detail/stream/stream_socket_ssl.cpp +++ b/lib/rstream/io/detail/stream/stream_socket_ssl.cpp @@ -496,8 +496,7 @@ void stream_socket_ssl::impl:: assert(m_strand.running_in_this_thread()); #endif #endif - auto operation_allocator = boost::asio::get_associated_allocator(handler); - std::allocate_shared(operation_allocator, shared_from_this(), std::forward(handler))->run(); + std::allocate_shared(core::allocator::wrapper(m_allocator), shared_from_this(), std::move(handler))->run(); } void stream_socket_ssl::impl:: @@ -513,8 +512,7 @@ void stream_socket_ssl::impl:: assert(m_strand.running_in_this_thread()); #endif #endif - auto operation_allocator = boost::asio::get_associated_allocator(handler); - std::allocate_shared(operation_allocator, shared_from_this(), endpoint, std::forward(handler))->run(); + std::allocate_shared(core::allocator::wrapper(m_allocator), shared_from_this(), endpoint, std::move(handler))->run(); } void stream_socket_ssl::impl:: diff --git a/lib/rstream/io/payloader.hpp b/lib/rstream/io/payloader.hpp index 3bb4495..050e6bf 100644 --- a/lib/rstream/io/payloader.hpp +++ b/lib/rstream/io/payloader.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -128,10 +129,9 @@ auto payloader::async_recv(const core::buffer& buffer, BOOST_ASIO_MOVE_A { return boost::asio::async_initiate( [this](auto&& handler, const core::buffer& buffer) { - using operation_type = async_recv_operation>; - auto operation_allocator = boost::asio::get_associated_allocator(handler); + using operation_type = async_recv_operation>; std::allocate_shared( - operation_allocator, + core::shared_operation_allocator(handler, m_allocator), m_next_layer, buffer, std::forward(handler), m_control_cb) ->run(); }, diff --git a/lib/rstream/io/queue.hpp b/lib/rstream/io/queue.hpp index c3c86b4..5180dfd 100644 --- a/lib/rstream/io/queue.hpp +++ b/lib/rstream/io/queue.hpp @@ -83,6 +83,10 @@ class queue : public queue_base { template queue(arg_type& arg, core::allocator::ptr allocator = nullptr); + // Share the stream's strand with reads, close operations, and other writers. + template + queue(arg_type&& arg, boost::asio::strand strand, core::allocator::ptr allocator = nullptr); + next_layer_type& next_layer(); const next_layer_type& next_layer() const; @@ -110,6 +114,9 @@ class queue::impl : public std::enable_shared_from_this { template impl(arg_type& arg, core::allocator::ptr allocator); + template + impl(arg_type&& arg, boost::asio::strand strand, core::allocator::ptr allocator); + next_layer_type& next_layer(); const next_layer_type& next_layer() const; @@ -222,6 +229,13 @@ queue::queue(arg_type& arg, core::allocator::ptr allocator) m_impl = std::allocate_shared(core::allocator::wrapper(allocator), arg, allocator); } +template +template +queue::queue(arg_type&& arg, boost::asio::strand strand, core::allocator::ptr allocator) +{ + m_impl = std::allocate_shared(core::allocator::wrapper(allocator), std::forward(arg), std::move(strand), allocator); +} + template typename queue::next_layer_type& queue::next_layer() { @@ -280,6 +294,17 @@ queue::impl::impl(arg_type& arg, core::allocator::ptr allocator) { } +template +template +queue::impl::impl(arg_type&& arg, boost::asio::strand strand, core::allocator::ptr allocator) + : m_next_layer(std::forward(arg)), + m_strand(std::move(strand)), + m_allocator(allocator), + m_queue(allocator), + m_cancel_handlers(allocator) +{ +} + template typename queue::next_layer_type& queue::impl::next_layer() { @@ -303,7 +328,7 @@ void queue::impl::async_send(const core::buffer buffer, async_send_co { auto allocator = boost::asio::get_associated_allocator(handler); auto self = std::enable_shared_from_this::shared_from_this(); - auto task_ptr = std::allocate_shared(allocator, buffer, std::move(handler)); + auto task_ptr = std::allocate_shared(core::allocator::wrapper(m_allocator), buffer, std::move(handler)); task_ptr->arm(task_ptr, self, m_strand); boost::asio::dispatch(m_strand, boost::asio::bind_allocator(allocator, [self, task_ptr] { self->send(task_ptr); })); } diff --git a/plugin/io-generic/tcp/resolver.cpp b/plugin/io-generic/tcp/resolver.cpp index f97897d..41a9b26 100644 --- a/plugin/io-generic/tcp/resolver.cpp +++ b/plugin/io-generic/tcp/resolver.cpp @@ -40,7 +40,7 @@ void rstream::plugin::io_generic::tcp::resolver::async_resolve_internal(const bo bool inet4 = false; bool inet6 = false; bool no_resolve = false; - const std::string host(url.host()); + const std::string host(url.host_address()); const std::string port(url.port()); boost::system::error_code error_code; const auto params = rstream::io::detail::stream::url_params(url); diff --git a/test/core/common/CMakeLists.txt b/test/core/common/CMakeLists.txt index f976041..021a6b6 100644 --- a/test/core/common/CMakeLists.txt +++ b/test/core/common/CMakeLists.txt @@ -6,7 +6,21 @@ add_test_target(${PROJECT_NAME}-test-core-executor-binder test_core_executor_bin add_test_target(${PROJECT_NAME}-test-core-error test_core_error.cpp ${PROJECT_NAME}::core) add_test_target(${PROJECT_NAME}-test-core-log test_core_log.cpp ${PROJECT_NAME}::core) if(WIN32) + add_test_target(${PROJECT_NAME}-test-core-windows-cancel-io test_core_windows_cancel_io.cpp ${PROJECT_NAME}::core) add_test_target(${PROJECT_NAME}-test-core-windows-blocking-handle test_core_windows_blocking_handle.cpp ${PROJECT_NAME}::core) + if(RSTREAM_TEST_WINDOWS_PIPE_ASAN) + if(NOT MSVC OR NOT CMAKE_BUILD_TYPE MATCHES "^(Release|RelWithDebInfo)$") + message(FATAL_ERROR "Windows pipe ASan tests require a native MSVC Release or RelWithDebInfo build.") + endif() + add_test_target( + ${PROJECT_NAME}-test-core-windows-blocking-handle-asan + "test_core_windows_blocking_handle.cpp;${PROJECT_SOURCE_DIR}/lib/rstream/core/windows/blocking_handle.cpp" + ws2_32 mswsock) + target_include_directories(${PROJECT_NAME}-test-core-windows-blocking-handle-asan PRIVATE ${PROJECT_SOURCE_DIR}/lib) + target_compile_features(${PROJECT_NAME}-test-core-windows-blocking-handle-asan PRIVATE cxx_std_20) + target_compile_options(${PROJECT_NAME}-test-core-windows-blocking-handle-asan PRIVATE /fsanitize=address /Zi) + target_link_options(${PROJECT_NAME}-test-core-windows-blocking-handle-asan PRIVATE /INFERASANLIBS /DEBUG /INCREMENTAL:NO) + endif() endif() add_test_target(${PROJECT_NAME}-test-core-memory-buffer test_core_memory_buffer.cpp ${PROJECT_NAME}::core) add_test_target(${PROJECT_NAME}-test-core-protobuf test_core_protobuf.cpp ${PROJECT_NAME}::core) diff --git a/test/core/common/test_core_windows_blocking_handle.cpp b/test/core/common/test_core_windows_blocking_handle.cpp index 4dd6219..f9a525e 100644 --- a/test/core/common/test_core_windows_blocking_handle.cpp +++ b/test/core/common/test_core_windows_blocking_handle.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -158,6 +160,50 @@ static void check_cancellation_completes_once() assert(calls == 1); } +static void check_late_cancellation_releases_completed_operation() +{ + HANDLE input = nullptr; + HANDLE output = nullptr; + assert(::CreatePipe(&input, &output, nullptr, 0)); + handle_guard read(input); + handle_guard write(output); + boost::asio::io_context io_context; + rstream::core::windows::blocking_handle stream(io_context.get_executor()); + boost::system::error_code open_error; + stream.open(read.get(), rstream::core::windows::blocking_handle::access::read, open_error); + assert(!open_error); + boost::asio::cancellation_signal cancellation; + std::array buffer{}; + std::size_t completed = 0; + auto handler = [&](const boost::system::error_code& error, std::size_t size) { + assert(!error && size == 1 && buffer[0] == 'x'); + ++completed; + }; + // A stateful allocator makes borrowing the destroyed erased handler observable. + std::pmr::polymorphic_allocator allocator; + stream.async_read_some(boost::asio::buffer(buffer), boost::asio::bind_allocator(allocator, boost::asio::bind_cancellation_slot(cancellation.slot(), handler))); + const char data = 'x'; + DWORD written = 0; + assert(::WriteFile(write.get(), &data, 1, &written, nullptr)); + io_context.run(); + assert(completed == 1); + + // A second completed read guarantees the worker released the first operation. + // Only the cancellation slot's weak pointer may still retain its control block. + io_context.restart(); + stream.async_read_some(boost::asio::buffer(buffer), handler); + assert(::WriteFile(write.get(), &data, 1, &written, nullptr)); + io_context.run(); + assert(completed == 2); + cancellation.emit(boost::asio::cancellation_type::terminal); + cancellation.slot().clear(); + assert(stream.is_open()); + stream.close(); + io_context.restart(); + io_context.run(); + assert(completed == 2); +} + int main(int argc, char** argv) { (void)argc; @@ -165,6 +211,7 @@ int main(int argc, char** argv) check_read_from_non_overlapped_pipe(); check_write_to_non_overlapped_pipe(); check_cancellation_completes_once(); + check_late_cancellation_releases_completed_operation(); return 0; } diff --git a/test/core/common/test_core_windows_cancel_io.cpp b/test/core/common/test_core_windows_cancel_io.cpp new file mode 100644 index 0000000..ebb9f72 --- /dev/null +++ b/test/core/common/test_core_windows_cancel_io.cpp @@ -0,0 +1,77 @@ +// See LICENSE file in the project root for license information. + +#ifdef _WIN32 + +#include +#include +#include +#include +#include + +#include + +static void check_cancellation_before_system_call(bool writing) +{ + HANDLE input = nullptr; + HANDLE output = nullptr; + assert(::CreatePipe(&input, &output, nullptr, 4096)); + const auto gate = ::CreateEvent(nullptr, TRUE, FALSE, nullptr); + assert(gate != nullptr); + + std::array buffer{}; + DWORD error = ERROR_SUCCESS; + std::promise ready; + auto worker_ready = ready.get_future(); + std::thread worker([&] { + ready.set_value(); + assert(::WaitForSingleObject(gate, INFINITE) == WAIT_OBJECT_0); + DWORD transferred = 0; + const auto success = writing + ? ::WriteFile(output, buffer.data(), static_cast(buffer.size()), &transferred, nullptr) + : ::ReadFile(input, buffer.data(), static_cast(buffer.size()), &transferred, nullptr); + error = success ? ERROR_SUCCESS : ::GetLastError(); + }); + worker_ready.wait(); + std::size_t cancellations = 0; + auto completed = std::async(std::launch::async, [&] { + rstream::core::windows::detail::cancel_and_join(worker, [&](HANDLE thread) { + const auto cancelled = ::CancelSynchronousIo(thread); + const auto cancel_error = cancelled ? ERROR_SUCCESS : ::GetLastError(); + if (++cancellations == 1) { + // The first cancellation necessarily misses: release the worker only + // after it returns, so the system call begins during shutdown. + assert(!cancelled && cancel_error == ERROR_NOT_FOUND); + assert(::SetEvent(gate)); + } + }); + }); + const auto stopped = completed.wait_for(std::chrono::seconds(5)) == std::future_status::ready; + if (!stopped) { + // Unblock a regressed single-cancellation implementation before failing. + auto& peer = writing ? input : output; + ::CloseHandle(peer); + peer = nullptr; + } + completed.get(); + assert(stopped); + assert(!worker.joinable()); + assert(cancellations >= 2); + assert(error == ERROR_OPERATION_ABORTED); + DWORD flags = 0; + assert(::GetHandleInformation(input, &flags)); + assert(::GetHandleInformation(output, &flags)); + ::CloseHandle(input); + ::CloseHandle(output); + ::CloseHandle(gate); +} + +int main() +{ + std::thread empty; + rstream::core::windows::detail::cancel_and_join(empty, [](HANDLE) { assert(false); }); + check_cancellation_before_system_call(false); + check_cancellation_before_system_call(true); + return 0; +} + +#endif diff --git a/test/core/metrics/test_core_metrics.cpp b/test/core/metrics/test_core_metrics.cpp index f552627..6ee9b5a 100644 --- a/test/core/metrics/test_core_metrics.cpp +++ b/test/core/metrics/test_core_metrics.cpp @@ -319,6 +319,42 @@ void test_summary_concurrent_collection() compare(value->m_sample_count, static_cast(499)); } +class observed_counter : public rstream::core::metrics::counter { + public: + using counter::counter; + + observed_counter(const counter& value) + : counter(value) + { + } + + std::weak_ptr observer() const + { + return get_impl(); + } +}; + +void test_labelled_metrics_release_owned_children() +{ + for (bool duplicate : {false, true}) { + std::weak_ptr child_observer; + { + auto registry = std::make_shared(); + if (duplicate) { + rstream::core::metrics::counter existing("rstream_lifetime_test", "lifetime", {}, registry); + } + observed_counter counter("rstream_lifetime_test", "lifetime", {}, registry); + observed_counter child(counter.labels({{"route", "one"}})); + child.increment(7); + child_observer = child.observer(); + registry.reset(); + compare(child.value(), 7.0); + compare(child.name(), std::string("rstream_lifetime_test")); + } + compare(child_observer.expired(), true); + } +} + void run() { test_1(); @@ -329,6 +365,7 @@ void run() test_collectable_and_system_registry(); test_system_collector_is_thread_safe_singleton(); test_summary_concurrent_collection(); + test_labelled_metrics_release_owned_children(); } int main(int argc, char** argv) diff --git a/test/io/common/test_io_common_queue.cpp b/test/io/common/test_io_common_queue.cpp index 824e730..a3e1d9f 100644 --- a/test/io/common/test_io_common_queue.cpp +++ b/test/io/common/test_io_common_queue.cpp @@ -31,9 +31,10 @@ class controlled_transport { using executor_type = boost::asio::io_context::executor_type; using completion_handler = rstream::core::completion_handler; - explicit controlled_transport(const executor_type& executor, bool auto_complete = false) + explicit controlled_transport(const executor_type& executor, bool auto_complete = false, std::function on_start = {}) : m_executor(executor), - m_auto_complete(auto_complete) + m_auto_complete(auto_complete), + m_on_start(std::move(on_start)) { } @@ -47,6 +48,9 @@ class controlled_transport { { return boost::asio::async_initiate( [this](auto&& handler, const rstream::core::buffer buffer) { + if (m_on_start) { + m_on_start(); + } auto operation = pending_operation::create(m_executor, completion_handler(std::forward(handler)), [this] { std::lock_guard lock(m_mutex); --m_active; @@ -157,6 +161,7 @@ class controlled_transport { executor_type m_executor; bool m_auto_complete; + std::function m_on_start; mutable std::mutex m_mutex; std::size_t m_active = 0; std::size_t m_maximum_active = 0; @@ -446,6 +451,30 @@ static void check_deferred_operations_are_lazy() assert(cancel_calls == 1); } +static void check_shared_transport_strand() +{ + boost::asio::io_context io_context; + auto strand = boost::asio::make_strand(io_context); + controlled_transport transport(io_context.get_executor(), true, [&] { assert(strand.running_in_this_thread()); }); + rstream::io::queue queue(transport, strand); + std::atomic_size_t completed = 0; + for (std::size_t i = 0; i < 128; ++i) { + queue.async_send(make_buffer(static_cast(i)), [&](const boost::system::error_code& error_code) { + assert(!error_code); + ++completed; + }); + } + std::vector workers; + for (std::size_t i = 0; i < 4; ++i) { + workers.emplace_back([&] { io_context.run(); }); + } + for (auto& worker : workers) { + worker.join(); + } + assert(completed == 128); + assert(transport.maximum_active() == 1); +} + int main() { check_owned_move_only_transport(); @@ -457,5 +486,6 @@ int main() check_async_cancel_waits_for_active_send(); check_async_cancel_supports_multiple_waiters(); check_deferred_operations_are_lazy(); + check_shared_transport_strand(); return 0; } diff --git a/test/io/common/test_io_common_stream_rstrm.cpp b/test/io/common/test_io_common_stream_rstrm.cpp index 07c136f..fc22ebe 100644 --- a/test/io/common/test_io_common_stream_rstrm.cpp +++ b/test/io/common/test_io_common_stream_rstrm.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include @@ -111,6 +113,30 @@ static void check_acceptor_rejects_endpoint_from_another_plugin() assert_stream_invalid_argument(error_code); } +static void check_completed_transfer_releases_cancellation_storage() +{ + boost::asio::io_context io_context; + auto endpoint = resolve_one(io_context, "rstrm://viewer?server=tcp%3A%2F%2F127.0.0.1%3A9&rstream.no_token=true"); + rstream::io::stream::stream_socket socket(io_context.get_executor()); + boost::system::error_code error_code; + socket.open(endpoint, error_code); + assert(!error_code); + boost::asio::cancellation_signal cancellation; + char buffer = 0; + unsigned completions = 0; + socket.async_read_some(boost::asio::buffer(&buffer, 1), boost::asio::bind_cancellation_slot(cancellation.slot(), [&](const boost::system::error_code& error, std::size_t size) { + assert(error); + assert(size == 0); + ++completions; + })); + io_context.run(); + assert(completions == 1); + cancellation.emit(boost::asio::cancellation_type::all); + io_context.restart(); + io_context.run(); + assert(completions == 1); +} + int main(int argc, char** argv) { (void)argc; @@ -120,5 +146,6 @@ int main(int argc, char** argv) check_rstrm_acceptor_rejects_invalid_retry_parameter_before_network_io(); check_socket_rejects_endpoint_from_another_plugin(); check_acceptor_rejects_endpoint_from_another_plugin(); + check_completed_transfer_releases_cancellation_storage(); return 0; } diff --git a/test/io/common/test_io_common_stream_tcp.cpp b/test/io/common/test_io_common_stream_tcp.cpp index 71fb5b5..034ee15 100644 --- a/test/io/common/test_io_common_stream_tcp.cpp +++ b/test/io/common/test_io_common_stream_tcp.cpp @@ -70,6 +70,26 @@ static rstream::io::stream::resolver::results_type make_results(const std::vecto return results; } +static void check_numeric_resolver_hosts() +{ + for (const auto& host : {std::string("127.0.0.1"), std::string("[::1]")}) { + for (const auto& options : {std::string(), std::string("?tcp.no_resolve")}) { + boost::asio::io_context io_context; + rstream::io::stream::resolver resolver(io_context.get_executor()); + bool completed = false; + resolver.async_resolve("tcp://" + host + ":8443" + options, [&](const boost::system::error_code& error, const auto& results) { + assert(!error); + assert(!results.empty()); + assert(results.front().url().host() == host); + assert(results.front().url().port() == "8443"); + completed = true; + }); + io_context.run_for(std::chrono::seconds(5)); + assert(completed); + } + } +} + static void check_async_resolve_owns_uri_components() { boost::asio::io_context io_context; @@ -358,7 +378,7 @@ static void check_tcp_accept_connect_and_transfer() assert(std::string(server_buffer.data(), server_buffer.size()) == "ping"); server_read = true; }); - boost::asio::async_write(client, boost::asio::buffer(std::string("ping")), [&](const boost::system::error_code& error, std::size_t size) { + boost::asio::async_write(client, boost::asio::buffer("ping", 4), [&](const boost::system::error_code& error, std::size_t size) { assert(!error); assert(size == 4); client_sent = true; @@ -377,7 +397,7 @@ static void check_tcp_accept_connect_and_transfer() assert(std::string(client_buffer.data(), client_buffer.size()) == "pong"); client_read = true; }); - boost::asio::async_write(server_peer, boost::asio::buffer(std::string("pong")), [&](const boost::system::error_code& error, std::size_t size) { + boost::asio::async_write(server_peer, boost::asio::buffer("pong", 4), [&](const boost::system::error_code& error, std::size_t size) { assert(!error); assert(size == 4); server_sent = true; @@ -753,6 +773,7 @@ int main(int argc, char** argv) { (void)argc; (void)argv; + check_numeric_resolver_hosts(); check_async_resolve_owns_uri_components(); check_uninitialized_socket_operations_fail(); check_socket_move_preserves_moved_from_invariants(); diff --git a/test/io/common/test_io_common_stream_unix_serial.cpp b/test/io/common/test_io_common_stream_unix_serial.cpp index 7a3e663..4c6a175 100644 --- a/test/io/common/test_io_common_stream_unix_serial.cpp +++ b/test/io/common/test_io_common_stream_unix_serial.cpp @@ -132,7 +132,7 @@ static void check_unix_accept_connect_and_transfer() assert(std::string(server_buffer.data(), server_buffer.size()) == "ping"); server_read = true; }); - boost::asio::async_write(client, boost::asio::buffer(std::string("ping")), [&](const boost::system::error_code& error, std::size_t size) { + boost::asio::async_write(client, boost::asio::buffer("ping", 4), [&](const boost::system::error_code& error, std::size_t size) { assert(!error); assert(size == 4); client_sent = true; @@ -151,7 +151,7 @@ static void check_unix_accept_connect_and_transfer() assert(std::string(client_buffer.data(), client_buffer.size()) == "pong"); client_read = true; }); - boost::asio::async_write(server_peer, boost::asio::buffer(std::string("pong")), [&](const boost::system::error_code& error, std::size_t size) { + boost::asio::async_write(server_peer, boost::asio::buffer("pong", 4), [&](const boost::system::error_code& error, std::size_t size) { assert(!error); assert(size == 4); server_sent = true; @@ -211,7 +211,7 @@ static void check_serial_pty_connect_and_transfer() check(connected, "serial connect did not complete"); bool sent = false; - boost::asio::async_write(serial, boost::asio::buffer(std::string("ping")), [&](const boost::system::error_code& error, std::size_t size) { + boost::asio::async_write(serial, boost::asio::buffer("ping", 4), [&](const boost::system::error_code& error, std::size_t size) { assert(!error); assert(size == 4); sent = true; diff --git a/test/io/http/test_io_http_upgrade.cpp b/test/io/http/test_io_http_upgrade.cpp index 6363e3a..7e4227f 100644 --- a/test/io/http/test_io_http_upgrade.cpp +++ b/test/io/http/test_io_http_upgrade.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include #include #include diff --git a/test/io/io-rstrm/test_io_rstrm_control_channel.cpp b/test/io/io-rstrm/test_io_rstrm_control_channel.cpp index 4e79635..7c20439 100644 --- a/test/io/io-rstrm/test_io_rstrm_control_channel.cpp +++ b/test/io/io-rstrm/test_io_rstrm_control_channel.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,9 @@ #include #include +#include + +#include #include #include #include @@ -60,6 +64,25 @@ static void check(bool condition, const std::string& message) } } +class control_payload_sink : public spdlog::sinks::base_sink { + public: + std::atomic_bool m_exposed = false; + std::atomic_uint m_messages = 0; + + private: + void sink_it_(const spdlog::details::log_msg& message) override + { + const std::string_view payload(message.payload.data(), message.payload.size()); + if (payload.find("control-channel-private-marker") != std::string_view::npos) { + m_exposed.store(true); + } + if (payload.find("message_type=") != std::string_view::npos) { + m_messages.fetch_add(1); + } + } + void flush_() override {} +}; + static tcp::socket accept_connection(tcp::acceptor& acceptor) { const auto was_non_blocking = acceptor.non_blocking(); @@ -414,6 +437,8 @@ static void check_client_snapshots_configuration_at_construction() static void check_client_can_create_and_close_tunnel() { + auto log_sink = std::make_shared(); + rstream::core::log::subscribe(log_sink); fake_engine engine; engine.start([](tcp::socket& socket) { auto open_request = read_message(socket); @@ -482,6 +507,7 @@ static void check_client_can_create_and_close_tunnel() properties.m_name = "api"; properties.m_type = "bytestream"; properties.m_publish = true; + properties.m_labels["private-marker"] = "control-channel-private-marker"; auto create_operation = client.async_create_tunnel(properties, boost::asio::deferred); std::move(create_operation)([&](const boost::system::error_code& create_error, rstream::io_rstrm::tunnel tunnel) { assert(!create_error); @@ -510,6 +536,11 @@ static void check_client_can_create_and_close_tunnel() assert(saw_tunnel); assert(saw_disconnected); assert(saw_server_status); + check(!log_sink->m_exposed.load(), "control-channel trace exposed a protocol payload"); +#ifdef DEBUG_BUILD + check(log_sink->m_messages.load() >= 8, "control-channel trace omitted bounded message types"); +#endif + log_sink->set_level(spdlog::level::off); } static void check_client_rejects_operations_before_connection() @@ -1936,6 +1967,8 @@ static void check_acceptor_honors_pending_accept_cancellation() static void check_generated_stable_domain() { + assert(!rstream::io_rstrm::detail::generate_stable_domain(rstream::io::make_address("tcp://127.0.0.1:443"))); + assert(!rstream::io_rstrm::detail::generate_stable_domain(rstream::io::make_address("tcp://[::1]:443"))); const auto hostname = rstream::io_rstrm::detail::generate_stable_domain( rstream::io::make_address("tcp://project.cluster.example:443")); assert(hostname); diff --git a/test/io/metrics/test_io_metrics_exposer.cpp b/test/io/metrics/test_io_metrics_exposer.cpp index f781aa8..1d22b38 100644 --- a/test/io/metrics/test_io_metrics_exposer.cpp +++ b/test/io/metrics/test_io_metrics_exposer.cpp @@ -1,5 +1,13 @@ // See LICENSE file in the project root for license information. +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + #include #include #include diff --git a/test/test_conan_windows_asan.py b/test/test_conan_windows_asan.py new file mode 100644 index 0000000..91f0a36 --- /dev/null +++ b/test/test_conan_windows_asan.py @@ -0,0 +1,132 @@ +import ast +import importlib.util +import os +import re +import shlex +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from conan.errors import ConanInvalidConfiguration +from conan.tools.cmake.utils import parse_extra_variable + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("rstream_recipe", ROOT / "conanfile.py") +RECIPE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECIPE) +TEST_NAME = "rstream-test-core-windows-blocking-handle-asan" + + +class WindowsAsanRequirementTest(unittest.TestCase): + def test_cached_sdk_cannot_skip_qualification(self): + workflow = (ROOT / ".github/workflows/conan.yml").read_text(encoding="utf-8") + commands = re.findall(r"conan create[^\n]+", workflow) + self.assertEqual(len(commands), 3) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + cache = path / "cache" + (cache / "profiles").mkdir(parents=True) + os_name = {"win32": "Windows", "darwin": "Macos"}.get(sys.platform, "Linux") + (cache / "profiles/default").write_text( + "[settings]\nos=" + os_name + "\n", encoding="utf-8" + ) + marker = path / "executions.txt" + (path / "conanfile.py").write_text( + "from conan import ConanFile\n" + "import os\n" + "class Probe(ConanFile):\n" + " name = 'rstream'\n" + " version = '0.0.0'\n" + " def build(self):\n" + " with open(os.environ['RSTREAM_TEST_EXECUTIONS'], 'a') as output:\n" + " output.write('executed\\n')\n", + encoding="utf-8", + ) + environment = dict( + os.environ, CONAN_HOME=str(cache), RSTREAM_TEST_EXECUTIONS=str(marker) + ) + + def create(flags): + result = subprocess.run( + [sys.executable, "-c", + "import sys; from conan.cli.cli import main; main(sys.argv[1:])", + "create", str(path), "--no-remote", *flags], + env=environment, + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return marker.read_text(encoding="utf-8").splitlines() + + self.assertEqual(len(create(["--build=missing"])), 1) + self.assertEqual(len(create(["--build=missing"])), 1) + for expected, command in enumerate(commands, 2): + build_flags = [ + shlex.split(flag)[0] for flag in re.findall(r"--build=\S+", command) + ] + with self.subTest(command=command): + self.assertEqual(len(create(build_flags)), expected) + + def test_workflow_configuration_survives_cmake_option_defaults(self): + workflow = (ROOT / ".github/workflows/conan.yml").read_text(encoding="utf-8") + value = ast.literal_eval( + re.search(r"extra_variables=(\{[^\n]+\})\"", workflow).group(1) + )["RSTREAM_TEST_WINDOWS_PIPE_ASAN"] + variable = parse_extra_variable( + "tools.cmake.cmaketoolchain:extra_variables", + "RSTREAM_TEST_WINDOWS_PIPE_ASAN", + value, + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "toolchain.cmake").write_text( + "set(RSTREAM_TEST_WINDOWS_PIPE_ASAN " + str(variable) + ")\n", + encoding="utf-8", + ) + tests = (ROOT / "cmake/tests.cmake").as_posix() + (path / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.10)\n" + "project(test_requirement NONE)\n" + f'include("{tests}")\n' + "if(NOT RSTREAM_TEST_WINDOWS_PIPE_ASAN)\n" + ' message(FATAL_ERROR "Requested AddressSanitizer test was disabled")\n' + "endif()\n", + encoding="utf-8", + ) + result = subprocess.run( + [ + "cmake", + "-S", str(path), + "-B", str(path / "build"), + "-DCMAKE_TOOLCHAIN_FILE=" + str(path / "toolchain.cmake"), + ], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def verify_report(self, cases): + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "result.xml" + report.write_text("" + cases + "", encoding="utf-8") + RECIPE.ConanPackage.verify_windows_pipe_asan_result(report) + + def test_requires_actual_successful_execution(self): + self.verify_report(f'') + for cases in ( + '', + f'', + f'', + f'', + f'', + ): + with self.subTest(cases=cases), self.assertRaises(ConanInvalidConfiguration): + self.verify_report(cases) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/tunnel/test_tunnel_proxy.cpp b/test/tunnel/test_tunnel_proxy.cpp index abe490d..cf75055 100644 --- a/test/tunnel/test_tunnel_proxy.cpp +++ b/test/tunnel/test_tunnel_proxy.cpp @@ -305,6 +305,13 @@ class fake_engine { std::exception_ptr m_exception; }; +static void drain_cancelled_operations(boost::asio::io_context& io_context) +{ + io_context.restart(); + io_context.run_for(rstream::test::timeout(std::chrono::seconds(5))); + check(io_context.stopped(), "proxy retained asynchronous work after cancellation"); +} + static void check_proxy_forwards_engine_stream_to_upstream_and_back() { std::atomic_bool upstream_served = false; @@ -369,6 +376,7 @@ static void check_proxy_forwards_engine_stream_to_upstream_and_back() run_until(io_context, [&] { return stream_exchanged.load(); }); proxy.cancel(); run_until(io_context, [&] { return proxy_stopped; }); + drain_cancelled_operations(io_context); engine.join(); upstream.join(); @@ -427,6 +435,7 @@ static void check_proxy_rejects_second_run_while_active() run_until(io_context, [&] { return rejected_second_run; }); proxy.cancel(); run_until(io_context, [&] { return proxy_stopped; }); + drain_cancelled_operations(io_context); engine.join(); upstream.join(); @@ -489,6 +498,7 @@ static void check_proxy_default_tunnel_request_leaves_public_policy_to_server() }); run_until(io_context, [&] { return proxy_stopped; }); + drain_cancelled_operations(io_context); engine.join(); check(saw_default_request.load(), "fake engine did not observe the default tunnel request"); diff --git a/test/webtty/CMakeLists.txt b/test/webtty/CMakeLists.txt index 81ffd4f..7558262 100644 --- a/test/webtty/CMakeLists.txt +++ b/test/webtty/CMakeLists.txt @@ -4,6 +4,10 @@ set(WEBTTY_GENERATED_SOURCE_DIR "${PROJECT_BINARY_DIR}/bin/webtty/lib/rstream/webtty/generated-cpp-sources") +add_test_target(${PROJECT_NAME}-test-webtty-discovery test_webtty_discovery.cpp ${PROJECT_NAME}::${PROJECT_NAME} ${PROJECT_NAME}::io-rstrm nlohmann_json::nlohmann_json) +target_include_directories(${PROJECT_NAME}-test-webtty-discovery PRIVATE "${PROJECT_SOURCE_DIR}/bin/webtty/bin/common") +rstream_enable_runtime_plugins(${PROJECT_NAME}-test-webtty-discovery ${PROJECT_NAME}-plugin-io-generic) + add_test_target(${PROJECT_NAME}-test-webtty-protocol test_webtty_protocol.cpp ${PROJECT_NAME}::webtty ${PROJECT_NAME}::io-rstrm Boost::url protobuf::libprotobuf) target_include_directories(${PROJECT_NAME}-test-webtty-protocol SYSTEM PRIVATE ${WEBTTY_GENERATED_SOURCE_DIR}) @@ -18,6 +22,10 @@ add_test_target(${PROJECT_NAME}-test-webtty-cli-config test_webtty_cli_config.cp add_test_target(${PROJECT_NAME}-test-webtty-terminal-stream test_webtty_terminal_stream.cpp ${PROJECT_NAME}::webtty) +if(APPLE) + add_test_target(${PROJECT_NAME}-test-webtty-fifo-reader test_webtty_fifo_reader.cpp ${PROJECT_NAME}::webtty) +endif() + if(UNIX) add_test_target(${PROJECT_NAME}-test-webtty-plain-server-runtime test_webtty_plain_server_runtime.cpp ${PROJECT_NAME}::webtty ${PROJECT_NAME}::${PROJECT_NAME} protobuf::libprotobuf) target_include_directories(${PROJECT_NAME}-test-webtty-plain-server-runtime SYSTEM PRIVATE ${WEBTTY_GENERATED_SOURCE_DIR}) diff --git a/test/webtty/fixtures/workspace-approved-client.json b/test/webtty/fixtures/workspace-approved-client.json new file mode 100644 index 0000000..30bc408 --- /dev/null +++ b/test/webtty/fixtures/workspace-approved-client.json @@ -0,0 +1,49 @@ +{ + "description": "Public test vector from an isolated Chrome workspace device approval and Go credential signer. Contains no private keys or authentication tokens.", + "enrollment": { + "serverId": "cmtorml5b001zf4s70sfh5zuh", + "workspaceId": "cmtork5qx0001dis78xp33uh3", + "projectId": "cmtork5ra0005dis7w0m3omzc", + "workspaceTrustKeysetId": "cmtorkj0w0001f4s7ibymzn7r", + "workspaceTrustKeysetFingerprint": "sha256:rhasPtv4mM7bGHoMHnkvZ3aLsGMjpLPYFz9cwmoyqNE", + "workspaceTrustPublicSigningKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqnyUNNjrBJlT3IeiR5_dFnTqaURvuKr2l4uPxHv2NzV3hwriJEbNFRMPlwfRIvAls3J4hYTEdLGLHldtCwddKg" + }, + "credential": { + "payload": { + "client_signing_key_id": "Dc-2FleBB8UtwXwol4lnrn4UG7XHbDIlXt4f9R3Mv54", + "client_signing_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfyoJ8mkp7UIh88xxmuULgqDD11diVcieAgk0kBUoVHq6NR1p0eq5YUuWg7G4A_7OpEDa1qA6_sj9uNBeN43CnQ", + "device_fingerprint": "sha256:iASw-c5tVOVkDrqoiYTgJlbWZVSD7ttp2GsyS2Q03nQ", + "device_key_id": "cmtorkkon0006f4s7b7k18f5k", + "device_kind": "cli", + "device_public_encryption_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtLfGX25FV2Rxvc52BlCsIJ_6eDzGmMbjtH6PRw2gFf22lJVTQyresVtx6SPBOa8IrUPrwKfNUSEIuEznez2H9g", + "device_public_signing_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfyoJ8mkp7UIh88xxmuULgqDD11diVcieAgk0kBUoVHq6NR1p0eq5YUuWg7G4A_7OpEDa1qA6_sj9uNBeN43CnQ", + "project_id": "cmtork5ra0005dis7w0m3omzc", + "server_id": "cmtorml5b001zf4s70sfh5zuh", + "signed_at": "2026-09-05T19:18:09.755Z", + "trust_actor_signature": "kD3aCcj1pXvf_7IqadVJgrSUU0hkDxOhPonEzWfgTyic3K9B4r1yxo9YddpBNfi3v1Wcp2TTPFKvs4DfhQWo7A", + "trust_keyset_id": "cmtorkj0w0001f4s7ibymzn7r", + "trust_keyset_signature": "XdspZHqxzF0E7cVIHvrS7XFBr3GGpsQ95AGrRYSyulF-LfKnqy5cWy-qo1I09vqvXqdmzf7nReqlyciMdq-2OQ", + "trust_payload": { + "approver_device_key_id": "cmtorkj0t0000f4s7fqmc5t52", + "envelope_hash": "M3EEK-w5UrBeL0XyopWCaWCIeRnQGNHZon8dMmJGFcU", + "keyset_id": "cmtorkj0w0001f4s7ibymzn7r", + "target_device_key_id": "cmtorkkon0006f4s7b7k18f5k", + "target_fingerprint": "sha256:iASw-c5tVOVkDrqoiYTgJlbWZVSD7ttp2GsyS2Q03nQ", + "type": "workspace.device.approve", + "v": 1, + "workspace_id": "cmtork5qx0001dis78xp33uh3" + }, + "trust_payload_hash": "CYYs2UQRcdLHY-vme9NNJulXWBNEkr2VPbx0bOHvfio", + "trust_signed_at": "2026-09-05T19:16:38.690Z", + "trust_source": "approval", + "type": "workspace.webtty.client.credential", + "v": 1, + "webtty_key_algorithm": "webtty-x25519-hpke-v1", + "webtty_key_id": "vHsIiWoQQC0T1lyDK6nUtg", + "webtty_public_key": "E12cH3L9VECKlhJbzoblEL0jJMHUxWPNDBp8-cmjCgQ", + "workspace_id": "cmtork5qx0001dis78xp33uh3" + }, + "signature": "MEUCIAxeyWmc14GcMSnN_IuPJItY-vUDjkzF-IGLsVZ9b5eZAiEAmtSJGt3zojMy_t5bTYi-68FucYv0gbfW1Ar1C7Noq_I", + "v": 1 + } +} diff --git a/test/webtty/test_webtty_cli_config.cpp b/test/webtty/test_webtty_cli_config.cpp index 4605438..d50766b 100644 --- a/test/webtty/test_webtty_cli_config.cpp +++ b/test/webtty/test_webtty_cli_config.cpp @@ -10,6 +10,7 @@ #include #include "../../bin/webtty/bin/common/webtty_cli.hpp" +#include "../../bin/webtty/bin/common/webtty_workspace_trust.hpp" namespace cli = rstream::webtty::cli; @@ -297,6 +298,7 @@ static void check_enrollment_validation() "version: 1\n" "serverId: prod-shell\n" "workspaceId: workspace-1\n" + "serverName: Production shell\n" "projectId: project-1\n" "apiUrl: https://app.example.test\n" "identityFile: ~/.rstream/webtty/identities/prod-shell.identity.json\n" @@ -317,6 +319,7 @@ static void check_enrollment_validation() "enrollmentStatus: active\n"); auto enrollment = cli::load_server_enrollment(path.string()); assert(enrollment.m_server_id == "prod-shell"); + assert(enrollment.m_server_name == "Production shell"); assert(cli::enrollment_requires_e2e(enrollment)); require_no_runtime_error("explicit enrollment identity validation", [&]() { cli::validate_identity_matches_enrollment(identity, enrollment); }); auto admission_enrollment = enrollment; @@ -325,9 +328,11 @@ static void check_enrollment_validation() uri_options.m_managed = true; uri_options.m_publish = true; uri_options.m_server_id = admission_enrollment.m_server_id; + uri_options.m_server_name = admission_enrollment.m_server_name; uri_options.m_encryption_policy = admission_enrollment.m_encryption_policy; uri_options.m_labels = {{"env", "prod"}}; auto admission_labels = rstream::webtty::build_webtty_labels(uri_options); + assert(admission_labels.at("rstream.webtty.server_name") == "Production shell"); auto admission_label = cli::create_server_admission_label(admission_enrollment, identity, admission_labels); auto admission_raw = cli::base64url_decode(admission_label, 0, "server admission label"); auto admission_json = nlohmann::json::parse(std::string(admission_raw.begin(), admission_raw.end())); @@ -430,6 +435,60 @@ static void check_enrollment_validation() assert(throws_runtime_error([&tampered_path]() { cli::load_server_enrollment(tampered_path.string()); })); } +static void check_workspace_approved_client_credential() +{ + auto path = std::filesystem::path(__FILE__).parent_path() / "fixtures" / "workspace-approved-client.json"; + std::ifstream file(path); + assert(file.is_open()); + auto fixture = nlohmann::json::parse(file); + const auto& pins = fixture.at("enrollment"); + cli::server_enrollment enrollment; + enrollment.m_server_id = pins.at("serverId").get(); + enrollment.m_workspace_id = pins.at("workspaceId").get(); + enrollment.m_project_id = pins.at("projectId").get(); + enrollment.m_workspace_trust_keyset_id = pins.at("workspaceTrustKeysetId").get(); + enrollment.m_workspace_trust_keyset_fingerprint = pins.at("workspaceTrustKeysetFingerprint").get(); + enrollment.m_workspace_trust_public_signing_key = pins.at("workspaceTrustPublicSigningKey").get(); + const auto& credential = fixture.at("credential"); + const auto& payload = credential.at("payload"); + auto key_id = cli::base64url_decode(payload.at("client_signing_key_id").get(), 0, "key id"); + auto public_key = cli::base64url_decode(payload.at("client_signing_public_key").get(), 0, "public key"); + auto verify = [&](const nlohmann::json& value) { + auto serialized = value.dump(); + return cli::verify_workspace_client_credential(enrollment, key_id, public_key, rstream::webtty::byte_vector(serialized.begin(), serialized.end())); + }; + assert(verify(credential) == public_key); + assert(!cli::verify_workspace_client_credential(enrollment, key_id, public_key, {})); + for (const auto& name : {"workspace_id", "project_id", "server_id", "trust_keyset_id", "client_signing_key_id", "client_signing_public_key", "device_public_signing_key", "device_public_encryption_key", "device_fingerprint", "trust_payload_hash", "trust_keyset_signature", "type"}) { + auto tampered = credential; + tampered["payload"][name] = "invalid"; + assert(throws_runtime_error([&]() { verify(tampered); })); + } + auto tampered = credential; + tampered["signature"] = "AA"; + assert(throws_runtime_error([&]() { verify(tampered); })); + tampered = credential; + tampered["v"] = 2; + assert(throws_runtime_error([&]() { verify(tampered); })); + tampered = credential; + tampered["payload"]["trust_payload"]["target_device_key_id"] = "other-device"; + tampered["payload"]["trust_payload_hash"] = cli::workspace_sha256_base64url(tampered["payload"]["trust_payload"]); + assert(throws_runtime_error([&]() { verify(tampered); })); + for (auto size : {0, 63, 64, 65, 72}) { + tampered = credential; + tampered["payload"]["trust_keyset_signature"] = cli::base64url_encode(rstream::webtty::byte_vector(size)); + assert(throws_runtime_error([&]() { verify(tampered); })); + } + auto signature = cli::base64url_decode(payload.at("trust_keyset_signature").get(), 0, "signature"); + assert(signature.size() == 64); + signature[0] ^= 1; + tampered = credential; + tampered["payload"]["trust_keyset_signature"] = cli::base64url_encode(signature); + assert(throws_runtime_error([&]() { verify(tampered); })); + public_key[0] ^= 1; + assert(throws_runtime_error([&]() { verify(credential); })); +} + int main() { check_argv_has(); @@ -441,5 +500,6 @@ int main() check_known_server_entries_file(); check_runtime_config_validation(); check_enrollment_validation(); + check_workspace_approved_client_credential(); return 0; } diff --git a/test/webtty/test_webtty_discovery.cpp b/test/webtty/test_webtty_discovery.cpp new file mode 100644 index 0000000..57cefa2 --- /dev/null +++ b/test/webtty/test_webtty_discovery.cpp @@ -0,0 +1,147 @@ +// See LICENSE file in the project root for license information. + +#ifdef _MSC_VER +// MSVC can flag Asio's buffer conversion as unreachable after inlining. +#pragma warning(push) +#pragma warning(disable : 4702) +#include +#pragma warning(pop) +#endif + +#include +#include +#include +#include + +#include +#include + +#include + +namespace cli = rstream::webtty::cli; + +template +void expect_error(F&& call, const std::string& message) +{ + try { + call(); + } + catch (const std::runtime_error& error) { + assert(std::string(error.what()).find(message) != std::string::npos); + return; + } + throw std::runtime_error("expected discovery error: " + message); +} + +void check_engine_discovery_io(const std::string& mode) +{ + boost::asio::io_context context; + boost::asio::signal_set signals(context, SIGINT, SIGTERM); + boost::asio::ip::tcp::acceptor acceptor(context, {boost::asio::ip::make_address("127.0.0.1"), 0}); + boost::asio::ip::tcp::socket peer(context); + boost::beast::flat_buffer buffer(16 * 1024); + boost::beast::http::request request; + const std::string body = R"([{"id":"shell","status":"online","protocol":"webtty","labels":{"rstream.webtty.transport":"plain"}}])"; + const std::string response = mode == "oversized" ? "HTTP/1.1 200 OK\r\nContent-Length: 2097152\r\n\r\n" : "HTTP/1.1 " + std::string(mode == "denied" ? "403 Forbidden" : "200 OK") + "\r\nContent-Length: " + std::to_string(mode == "malformed" ? 1 : body.size()) + "\r\nConnection: close\r\n\r\n" + (mode == "malformed" ? "{" : body); + bool observed = false; + acceptor.async_accept(peer, [&](const boost::system::error_code& error) { + assert(!error); + boost::beast::http::async_read(peer, buffer, request, [&](const boost::system::error_code& error, std::size_t) { + assert(!error); + assert(request.target() == "/api/tunnels"); + assert(request[boost::beast::http::field::authorization].empty()); + observed = true; + if (mode == "cancel") { + std::raise(SIGINT); + return; + } + if (mode == "timeout") { + return; + } + boost::asio::async_write(peer, boost::asio::buffer(response), [&](const boost::system::error_code&, std::size_t) { + boost::system::error_code ignored; + peer.close(ignored); + }); + }); + }); + boost::urls::url target("rstrm://shell"); + target.params().append({"server", "tcp://127.0.0.1:" + std::to_string(acceptor.local_endpoint().port())}); + target.params().append({"rstream.no_token", "true"}); + const auto start = std::chrono::steady_clock::now(); + const auto run = [&]() { return cli::discover_webtty_server(context, signals, rstream::io::address(target), "", std::chrono::milliseconds(200)); }; + if (mode == "ok") { + const auto server = run(); + assert(server.m_transport == "plain"); + assert(server.m_target == "shell"); + } + else { + expect_error(run, mode == "denied" ? "rejected" : mode == "malformed" ? "invalid WebTTY engine inventory" + : "discovery failed"); + } + assert(observed); + assert(std::chrono::steady_clock::now() - start < std::chrono::seconds(2)); + assert(context.poll() == 0); +} + +void check_explicit_engine_requires_explicit_auth_before_io() +{ + boost::asio::io_context context; + boost::asio::signal_set signals(context, SIGINT, SIGTERM); + boost::asio::ip::tcp::acceptor acceptor(context, {boost::asio::ip::make_address("127.0.0.1"), 0}); + boost::urls::url target("rstrm://shell"); + target.params().append({"server", "tcp://127.0.0.1:" + std::to_string(acceptor.local_endpoint().port())}); + expect_error([&] { cli::discover_webtty_server(context, signals, rstream::io::address(target), ""); }, "explicit engine URI requires an explicit token"); + assert(context.poll() == 0); + acceptor.non_blocking(true); + boost::asio::ip::tcp::socket peer(context); + boost::system::error_code error; + acceptor.accept(peer, error); + assert(error == boost::asio::error::would_block || error == boost::asio::error::try_again); +} + +int main() +{ + check_explicit_engine_requires_explicit_auth_before_io(); + for (const auto& mode : {"ok", "denied", "malformed", "oversized", "cancel", "timeout"}) { + check_engine_discovery_io(mode); + } + for (const auto& transport : {"plain", "websocket", "webtransport"}) { + for (const bool managed : {false, true}) { + for (const bool publish : {false, true}) { + const nlohmann::json server = { + {"id", "tunnel"}, + {"name", "shell"}, + {"status", "online"}, + {"publish", publish}, + {"protocol", managed ? "webtty" : ""}, + {"type", std::string(transport) == "webtransport" ? "datagram" : "bytestream"}, + {"labels", {{"application-protocol", "rstream.webtty"}, {"rstream.webtty.transport", transport}, {"rstream.webtty.exec.path", "/terminal"}}}, + }; + const auto inventory = nlohmann::json::array({server}); + if (std::string(transport) == "webtransport") { + expect_error([&]() { cli::select_discovered_server(inventory, "shell", ""); }, "not implemented"); + continue; + } + const auto selected = cli::select_discovered_server(inventory, "shell", ""); + assert(selected.m_transport == transport); + assert(selected.m_target == "tunnel"); + assert(selected.m_exec_path == "/terminal"); + assert(!selected.m_requires_known_server); + expect_error([&]() { cli::select_discovered_server(inventory, "shell", std::string(transport) == "plain" ? "websocket" : "plain"); }, "conflicts"); + expect_error([&]() { cli::select_discovered_server(nlohmann::json::array({server, server}), "shell", ""); }, "multiple"); + } + } + } + nlohmann::json legacy = {{"id", "shell"}, {"status", "online"}, {"protocol", "webtty"}}; + assert(cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", "").m_transport == "websocket"); + assert(cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", "plain").m_transport == "plain"); + legacy["type"] = "datagram"; + expect_error([&]() { cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", ""); }, "not implemented"); + legacy["labels"] = {{"rstream.webtty.transport", "future"}}; + expect_error([&]() { cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", ""); }, "invalid WebTTY transport"); + legacy["labels"] = {{"rstream.webtty.transport", "websocket"}}; + expect_error([&]() { cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", ""); }, "conflicts"); + legacy["type"] = "bytestream"; + legacy["labels"]["rstream.webtty.e2e"] = "required"; + assert(cli::select_discovered_server(nlohmann::json::array({legacy}), "shell", "").m_requires_known_server); +} diff --git a/test/webtty/test_webtty_fifo_reader.cpp b/test/webtty/test_webtty_fifo_reader.cpp new file mode 100644 index 0000000..4eddfa5 --- /dev/null +++ b/test/webtty/test_webtty_fifo_reader.cpp @@ -0,0 +1,289 @@ +// See LICENSE file in the project root for license information. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +using reader_type = rstream::webtty::detail::fifo_reader; + +struct named_pipe { + explicit named_pipe(boost::asio::io_context& context) + : input(context), + output(context) + { + std::array directory{}; + std::string("/tmp/rstream-webtty-fifo-XXXXXX").copy(directory.data(), directory.size() - 1); + assert(::mkdtemp(directory.data()) != nullptr); + const auto path = std::string(directory.data()) + "/input"; + assert(::mkfifo(path.c_str(), 0600) == 0); + input.assign(::open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC)); + output.assign(::open(path.c_str(), O_WRONLY | O_NONBLOCK | O_CLOEXEC)); + assert(::unlink(path.c_str()) == 0); + assert(::rmdir(directory.data()) == 0); + } + + boost::asio::posix::stream_descriptor input; + boost::asio::posix::stream_descriptor output; +}; + +static void check_transfer_with_open_writer(std::size_t buffer_size) +{ + boost::asio::io_context context; + auto strand = boost::asio::make_strand(context); + named_pipe pipe(context); + reader_type reader(strand, pipe.input.native_handle()); + std::string expected(1024 * 1024, '\0'); + for (std::size_t i = 0; i < expected.size(); ++i) { + expected[i] = static_cast(i % 251); + } + std::vector buffer(buffer_size); + std::string received; + bool done = false; + bool timed_out = false; + std::atomic stop_writer = false; + std::atomic writer_failed = false; + boost::asio::steady_timer deadline(strand, rstream::test::timeout(std::chrono::seconds(5))); + deadline.async_wait([&](auto error) { + if (!error) { + timed_out = true; + reader.close(); + } + }); + std::function read; + read = [&] { + reader.async_read_some(boost::asio::buffer(buffer), boost::asio::bind_executor(strand, [&](auto error, auto size) { + assert(strand.running_in_this_thread()); + if (error) { + assert(timed_out); + return; + } + received.append(buffer.data(), size); + if (received.size() == expected.size()) { + done = true; + deadline.cancel(); + reader.close(); + } + else { + read(); + } + })); + }; + read(); + std::thread writer([&] { + std::size_t offset = 0; + while (offset < expected.size() && !stop_writer) { + pollfd ready{pipe.output.native_handle(), POLLOUT, 0}; + const auto result = ::poll(&ready, 1, 100); + if (result == -1 && errno == EINTR) { + continue; + } + if (result < 0 || (ready.revents & (POLLERR | POLLHUP | POLLNVAL))) { + writer_failed = true; + break; + } + if (result == 0) { + continue; + } + const auto size = ::write(pipe.output.native_handle(), expected.data() + offset, expected.size() - offset); + if (size < 0 && (errno == EINTR || errno == EAGAIN)) { + continue; + } + if (size <= 0) { + writer_failed = true; + break; + } + offset += static_cast(size); + } + }); + std::vector workers; + for (int i = 0; i < 3; ++i) { + workers.emplace_back([&] { context.run(); }); + } + context.run(); + for (auto& worker : workers) { + worker.join(); + } + stop_writer = true; + writer.join(); + assert(!writer_failed); + assert(!timed_out); + assert(done); + assert(received == expected); + assert(pipe.output.is_open()); +} + +static void check_cancellation_and_destruction() +{ + boost::asio::io_context context; + auto strand = boost::asio::make_strand(context); + named_pipe pipe(context); + std::array buffer; + int cancelled = 0; + int rejected = 0; + { + reader_type reader(strand, pipe.input.native_handle()); + reader.async_read_some(boost::asio::buffer(buffer), boost::asio::bind_executor(strand, [&](auto error, auto size) { + assert(strand.running_in_this_thread()); + assert(error == boost::asio::error::operation_aborted); + assert(size == 0); + ++cancelled; + })); + reader.async_read_some(boost::asio::buffer(buffer), [&](auto error, auto size) { + assert(error == boost::asio::error::already_started); + assert(size == 0); + ++rejected; + }); + } + assert(cancelled == 0); + assert(rejected == 0); + context.run(); + assert(cancelled == 1); + assert(rejected == 1); +} + +static void check_eof_and_repeated_close() +{ + boost::asio::io_context context; + named_pipe pipe(context); + reader_type reader(context.get_executor(), pipe.input.native_handle()); + std::array buffer; + pipe.output.close(); + bool eof = false; + reader.async_read_some(boost::asio::buffer(buffer), [&](auto error, auto size) { + assert(error == boost::asio::error::eof); + assert(size == 0); + eof = true; + reader.close(); + reader.close(); + }); + context.run(); + assert(eof); + context.restart(); + bool cancelled = false; + reader.async_read_some(boost::asio::buffer(buffer), [&](auto error, auto size) { + assert(error == boost::asio::error::operation_aborted); + assert(size == 0); + cancelled = true; + }); + context.run(); + assert(cancelled); +} + +static void check_pending_eof_and_empty_buffer() +{ + boost::asio::io_context context; + auto strand = boost::asio::make_strand(context); + named_pipe pipe(context); + reader_type reader(strand, pipe.input.native_handle()); + std::array buffer; + int completed = 0; + reader.async_read_some(boost::asio::mutable_buffer(), [&](auto error, auto size) { + assert(!error); + assert(size == 0); + ++completed; + reader.async_read_some(boost::asio::buffer(buffer), [&](auto read_error, auto read_size) { + assert(read_error == boost::asio::error::eof); + assert(read_size == 0); + ++completed; + reader.close(); + }); + boost::asio::post(strand, [&] { pipe.output.close(); }); + }); + context.run(); + assert(completed == 2); +} + +struct suppress_fifo_readiness { + boost::asio::any_io_executor executor; + int descriptor; + boost::asio::posix::stream_descriptor* writer; + bool* entered; + + int operator()(int count, fd_set* descriptors, timeval* timeout) + { + if (!*entered) { + *entered = true; + boost::asio::post(executor, [output = writer] { output->close(); }); + } + // Model a lost FIFO notification while retaining real cancellation readiness. + FD_CLR(descriptor, descriptors); + return ::select(count, descriptors, nullptr, nullptr, timeout); + } +}; + +static void check_eof_without_readiness_notification() +{ + boost::asio::io_context context; + auto strand = boost::asio::make_strand(context); + named_pipe pipe(context); + bool entered = false; + using fault_reader = rstream::webtty::detail::basic_fifo_reader; + fault_reader reader(strand, pipe.input.native_handle(), {strand, pipe.input.native_handle(), &pipe.output, &entered}); + std::array buffer; + bool timed_out = false; + bool eof = false; + boost::asio::steady_timer deadline(strand, rstream::test::timeout(std::chrono::seconds(5))); + deadline.async_wait([&](auto error) { + if (!error) { + timed_out = true; + reader.close(); + } + }); + reader.async_read_some(boost::asio::buffer(buffer), boost::asio::bind_executor(strand, [&](auto error, auto size) { + eof = error == boost::asio::error::eof; + assert(size == 0); + deadline.cancel(); + reader.close(); + })); + context.run(); + assert(entered); + assert(!timed_out); + assert(eof); + assert(!pipe.output.is_open()); +} + +static void check_descriptor_limit_is_rejected() +{ + boost::asio::io_context context; + for (int descriptor : {-1, FD_SETSIZE}) { + bool rejected = false; + try { + reader_type reader(context.get_executor(), descriptor); + } + catch (const boost::system::system_error& error) { + rejected = error.code() == boost::asio::error::fd_set_failure; + } + assert(rejected); + } +} + +int main() +{ + for (int i = 0; i < 16; ++i) { + check_transfer_with_open_writer(i % 2 == 0 ? 4096 : 800 * 1024); + check_cancellation_and_destruction(); + check_pending_eof_and_empty_buffer(); + } + check_eof_and_repeated_close(); + check_eof_without_readiness_notification(); + check_descriptor_limit_is_rejected(); +} diff --git a/test/webtty/test_webtty_plain_client_runtime.cpp b/test/webtty/test_webtty_plain_client_runtime.cpp index 608c47b..ef41cfd 100644 --- a/test/webtty/test_webtty_plain_client_runtime.cpp +++ b/test/webtty/test_webtty_plain_client_runtime.cpp @@ -516,6 +516,14 @@ class fake_early_exit_plain_server { protobuf::Message close; close.mutable_close()->set_return_code(0); write_message(socket, close); + // Closing with an unread stdin EOS sends a TCP reset and can discard + // the successful close frame. Half-close and drain until the client exits. + socket.shutdown(tcp::socket::shutdown_send); + char trailing[1024]; + boost::system::error_code error_code; + while (socket.read_some(boost::asio::buffer(trailing), error_code) != 0) { + } + assert(error_code == boost::asio::error::eof); } catch (...) { m_exception = std::current_exception(); diff --git a/test/webtty/test_webtty_protocol.cpp b/test/webtty/test_webtty_protocol.cpp index 84a05d9..1ba4bdd 100644 --- a/test/webtty/test_webtty_protocol.cpp +++ b/test/webtty/test_webtty_protocol.cpp @@ -476,6 +476,7 @@ static void check_webtty_uri_is_publishable_and_labelled() } } assert(labels.count("application-protocol=rstream.webtty") == 1); + assert(labels.count("rstream.webtty.transport=websocket") == 1); assert(labels.count("rstream.webtty.capabilities=exec") == 1); assert(labels.count("rstream.webtty.execution.mode=spawn") == 1); assert(labels.count("rstream.webtty.exec.path=/") == 1); @@ -491,11 +492,40 @@ static void check_webtty_uri_is_publishable_and_labelled() assert(has_os_family); } +static void check_webtty_uri_transport_contract() +{ + for (const auto transport : {protocol::type::plain, protocol::type::websocket}) { + for (const bool managed : {false, true}) { + for (const bool publish : {false, true}) { + rstream::webtty::webtty_uri_options options; + options.m_transport = transport; + options.m_managed = managed; + options.m_publish = publish; + const auto labels = rstream::webtty::build_webtty_labels(options); + assert(labels.at("rstream.webtty.transport") == (transport == protocol::type::plain ? "plain" : "websocket")); + const auto url = parse_url(rstream::webtty::build_webtty_uri(options)); + const auto params = url.params(); + assert((*params.find("rstrm.type")).value == "bytestream"); + if (transport == protocol::type::plain && !managed) { + assert((*params.find("rstrm.publish")).value == "false"); + assert(params.find("rstrm.protocol") == params.end()); + assert(params.find("rstrm.token_auth") == params.end()); + } + else { + assert((*params.find("rstrm.publish")).value == (publish ? "true" : "false")); + assert((*params.find("rstrm.protocol")).value == (managed ? "webtty" : "http")); + } + } + } + } +} + static void check_managed_webtty_uri_is_publishable_and_labelled() { rstream::webtty::webtty_uri_options options; options.m_managed = true; options.m_server_id = "prod-shell"; + options.m_server_name = "Production shell"; options.m_host_key_id = "host-key-id"; options.m_encryption_policy = "explicit_key"; options.m_labels["env"] = "production"; @@ -515,6 +545,7 @@ static void check_managed_webtty_uri_is_publishable_and_labelled() } assert(labels.count("application-protocol=rstream.webtty") == 1); assert(labels.count("rstream.webtty.server_id=prod-shell") == 1); + assert(labels.count("rstream.webtty.server_name=Production shell") == 1); assert(labels.count("rstream.webtty.host_key_id=host-key-id") == 1); assert(labels.count("rstream.webtty.e2e=required") == 1); assert(labels.count("rstream.webtty.client_proof=required") == 1); @@ -693,6 +724,7 @@ int main(int argc, char** argv) check_windows_login_user_is_restricted_to_server_account(); #endif check_webtty_uri_is_publishable_and_labelled(); + check_webtty_uri_transport_contract(); check_managed_webtty_uri_is_publishable_and_labelled(); check_managed_webtty_admission_label_reaches_tunnel_properties(); check_private_managed_webtty_uri_omits_token_auth(); diff --git a/test/webtty/test_webtty_terminal_stream.cpp b/test/webtty/test_webtty_terminal_stream.cpp index 802a12f..5516591 100644 --- a/test/webtty/test_webtty_terminal_stream.cpp +++ b/test/webtty/test_webtty_terminal_stream.cpp @@ -5,11 +5,16 @@ #include #include #include +#include +#include +#include #include +#include #include #include #include +#include #include #if __has_include() #include @@ -39,6 +44,114 @@ namespace stream = rstream::webtty::stream; #ifdef _WIN32 +static int windows_pty_console_child() +{ + for (const auto channel : {STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE}) { + DWORD mode = 0; + if (!::GetConsoleMode(::GetStdHandle(channel), &mode)) { + return 10; + } + } + DWORD count = 0; + const char ready[] = "CONPTY_READY\n"; + if (!::WriteFile(::GetStdHandle(STD_OUTPUT_HANDLE), ready, sizeof(ready) - 1, &count, nullptr)) { + return 11; + } + char input[128] = {}; + if (!::ReadFile(::GetStdHandle(STD_INPUT_HANDLE), input, sizeof(input), &count, nullptr) + || std::string(input, count) != "conpty-input\r\n") { + return 12; + } + const char output[] = "CONPTY_STDIN_OK\n"; + const char error[] = "CONPTY_STDERR_OK\n"; + if (!::WriteFile(::GetStdHandle(STD_OUTPUT_HANDLE), output, sizeof(output) - 1, &count, nullptr) + || !::WriteFile(::GetStdHandle(STD_ERROR_HANDLE), error, sizeof(error) - 1, &count, nullptr)) { + return 13; + } + return 0; +} + +static void check_windows_pty_console_io(const char* executable) +{ + boost::asio::io_context io_context; + auto work = boost::asio::make_work_guard(io_context); + auto stream_ptr = stream::make_stream(io_context.get_executor(), stream::backend::tty); + auto child = rstream::webtty::detail::process::make_child( + stream_ptr, + boost::process::exe(executable), + boost::process::args(std::vector{"--conpty-console-child"})); + char buffer[4096] = {}; + const char input[] = "conpty-input\r"; + std::string output; + std::promise output_complete; + auto output_ready = output_complete.get_future(); + bool output_reported = false; + bool input_sent = false; + bool input_written = false; + std::function read; + read = [&] { + stream::base::async_read_some_completion_handler handler = + [&](const std::error_code& error_code, std::size_t count) { + if (error_code || output.size() + count > 16384) { + return; + } + output.append(buffer, count); + if (!input_sent && output.find("CONPTY_READY") != std::string::npos) { + input_sent = true; + stream::base::async_write_completion_handler write_handler = + [&](const std::error_code& write_error, std::size_t written) { + input_written = !write_error && written == sizeof(input) - 1; + }; + stream_ptr->async_write(boost::asio::buffer(input, sizeof(input) - 1), stream::type::std_in, std::move(write_handler)); + } + if (!output_reported && output.find("CONPTY_STDIN_OK") != std::string::npos + && output.find("CONPTY_STDERR_OK") != std::string::npos) { + output_reported = true; + output_complete.set_value(); + } + read(); + }; + stream_ptr->async_read_some(boost::asio::buffer(buffer), stream::type::std_out, std::move(handler)); + }; + read(); + std::thread runner([&] { io_context.run(); }); + const auto exited = ::WaitForSingleObject(child->native_handle(), 10000) == WAIT_OBJECT_0; + boost::system::error_code ignored; + if (!exited) { + child->terminate(ignored); + } + child->wait(ignored); + const auto drained = exited && child->exit_code() == 0 + && output_ready.wait_for(std::chrono::seconds(5)) == std::future_status::ready; + stream_ptr->close(); + work.reset(); + runner.join(); + assert(exited); + assert(child->exit_code() == 0); + assert(drained); + assert(input_written); + assert(output.find("CONPTY_STDIN_OK") != std::string::npos); + assert(output.find("CONPTY_STDERR_OK") != std::string::npos); +} + +static void check_windows_pty_with_redirected_parent(const char* executable) +{ + boost::process::child parent( + boost::process::exe(executable), + boost::process::args(std::vector{"--conpty-redirected-parent"}), + boost::process::std_in + boost::process::null); + const auto exited = ::WaitForSingleObject(parent.native_handle(), 20000) == WAIT_OBJECT_0; + boost::system::error_code ignored; + if (!exited) { + parent.terminate(ignored); + } + parent.wait(ignored); + assert(exited); + assert(parent.exit_code() == 0); +} + static void check_windows_pty_rejects_overlapping_writes() { boost::asio::io_context io_context; @@ -221,13 +334,21 @@ static void check_pty_stream_lifecycle_and_window_size() pty->set_window_size({.m_row = 40, .m_col = 120, .m_xpixel = 0, .m_ypixel = 0}, error_code); assert(!error_code); - auto pty_posix = std::dynamic_pointer_cast(stream_ptr); - assert(pty_posix); - pty_posix->on_success(error_code); + stream_ptr->close(); + auto child = rstream::webtty::detail::process::make_child( + stream_ptr, + boost::process::exe("/bin/sleep"), + boost::process::args(std::vector{"30"})); + pty->set_window_size({.m_row = 50, .m_col = 150, .m_xpixel = 0, .m_ypixel = 0}, error_code); assert(!error_code); + child->terminate(); + child->wait(); stream_ptr->close(); stream_ptr->close(); + pty->set_window_size({.m_row = 24, .m_col = 80, .m_xpixel = 0, .m_ypixel = 0}, error_code); + assert(error_code); + error_code.clear(); pty->allocate(error_code); assert(!error_code); stream_ptr->close(); @@ -247,8 +368,22 @@ static void check_pipe_stream_lifecycle() int main(int argc, char** argv) { +#ifdef _WIN32 + if (argc == 2 && std::strcmp(argv[1], "--conpty-console-child") == 0) { + return windows_pty_console_child(); + } + if (argc == 2 && std::strcmp(argv[1], "--conpty-redirected-parent") == 0) { + DWORD mode = 0; + assert(!::GetConsoleMode(::GetStdHandle(STD_INPUT_HANDLE), &mode)); + assert(!::GetConsoleMode(::GetStdHandle(STD_OUTPUT_HANDLE), &mode)); + check_windows_pty_console_io(argv[0]); + return 0; + } + check_windows_pty_with_redirected_parent(argv[0]); +#else (void)argc; (void)argv; +#endif check_pipe_stream_lifecycle(); #ifdef _WIN32 check_windows_pty_rejects_overlapping_writes(); diff --git a/test/webtty/test_webtty_websocket_client_runtime.cpp b/test/webtty/test_webtty_websocket_client_runtime.cpp index a0cbf43..c1cb6eb 100644 --- a/test/webtty/test_webtty_websocket_client_runtime.cpp +++ b/test/webtty/test_webtty_websocket_client_runtime.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include @@ -980,10 +981,40 @@ static void check_websocket_client_sends_terminal_size_when_tty_allocated() assert(return_code == 22); } +static void check_websocket_handshake_obeys_open_deadline() +{ + boost::asio::io_context io_context; + tcp::acceptor acceptor(io_context, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0)); + tcp::socket peer(io_context); + acceptor.async_accept(peer, [](const boost::system::error_code& error) { assert(!error); }); + rstream::webtty::client::config config{}; + config.m_address = rstream::io::address("127.0.0.1:" + std::to_string(acceptor.local_endpoint().port())); + config.m_protocol_config.m_protocol_type = rstream::webtty::protocol::type::websocket; + rstream::webtty::settings_client settings{}; + settings.m_common.m_mtu = 1024 * 1024; + settings.m_common.m_timeouts_ms.m_open = rstream::test::timeout_ms(100); + settings.m_common.m_timeouts_ms.m_close = rstream::test::timeout_ms(100); + settings.m_std_in_buffer_size = 1024; + rstream::webtty::client client(io_context.get_executor(), config, settings); + unsigned completions = 0; + client.async_run([&](const std::error_code& error, int code) { + assert(error == rstream::webtty::error::code::operation_timeout); + assert(code == -1); + ++completions; + boost::system::error_code ignored; + peer.close(ignored); + acceptor.close(ignored); + }); + io_context.run_for(rstream::test::timeout(std::chrono::seconds(2))); + assert(completions == 1); + assert(io_context.stopped()); +} + int main(int argc, char** argv) { (void)argc; (void)argv; + check_websocket_handshake_obeys_open_deadline(); check_websocket_client_sends_open_stdin_eos_and_heartbeat(); check_websocket_client_e2e_sends_encrypted_stdin(); check_websocket_client_accepts_remote_exit_during_stdin_shutdown(true); diff --git a/test/webtty/test_webtty_websocket_server_runtime.cpp b/test/webtty/test_webtty_websocket_server_runtime.cpp index 918d481..97bc262 100644 --- a/test/webtty/test_webtty_websocket_server_runtime.cpp +++ b/test/webtty/test_webtty_websocket_server_runtime.cpp @@ -363,25 +363,31 @@ class websocket_webtty_server { m_result = error_code; m_done = true; }); - m_thread = std::thread([this] { - try { - m_io_context.run(); - } - catch (...) { - m_exception = std::current_exception(); - } - }); + for (std::size_t i = 0; i < m_threads.size(); ++i) { + m_threads[i] = std::thread([this, i] { + try { + m_io_context.run(); + } + catch (...) { + m_exceptions[i] = std::current_exception(); + } + }); + } } void stop() { - if (!m_thread.joinable()) { + if (!m_threads.front().joinable()) { return; } m_server->cancel(); - m_thread.join(); - if (m_exception) { - std::rethrow_exception(m_exception); + for (auto& thread : m_threads) { + thread.join(); + } + for (const auto& exception : m_exceptions) { + if (exception) { + std::rethrow_exception(exception); + } } assert(m_done); assert(!m_result); @@ -393,8 +399,8 @@ class websocket_webtty_server { rstream::webtty::settings_server m_settings; boost::asio::io_context m_io_context; std::shared_ptr m_server; - std::thread m_thread; - std::exception_ptr m_exception; + std::array m_threads; + std::array m_exceptions; bool m_done = false; std::error_code m_result; };