yams/tests/unit/daemon/p2p_transport_catch2_test.cpp
0xkite 731c1f5c0f
fix(build): OpenSSL 4 certificate names and Boost 1.92 timer cancel (#337)
* fix(p2p): own certificate names for OpenSSL 4

Signed-off-by: trvon <git@trevon.dev>

* test(daemon): use supported Asio timer cancellation

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
Co-authored-by: trvon <git@trevon.dev>
2026-10-23 21:16:23 -06:00

964 lines
44 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2026 YAMS Contributors
// pi-lens-ignore: fatal error
#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/p2p/p2p_transport.h>
#include <yams/memory_sync/writer_auth.h>
#include "common/p2p_test_helpers.h"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <array>
#include <atomic>
#include <chrono>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
using yams::daemon::p2p::P2pClientOptions;
using yams::daemon::p2p::P2pConnection;
using yams::daemon::p2p::P2pListener;
using yams::daemon::p2p::TlsIdentity;
namespace {
using namespace yams::p2p_test;
TlsIdentity identity(std::string nodeId, const yams::memory_sync::WriterKeyPair& keyPair) {
auto result = TlsIdentity::fromPrivateKeyPem(std::move(nodeId), keyPair.privateKeyPem);
REQUIRE(result.has_value());
return std::move(result.value());
}
bool waitUntil(std::function<bool()> predicate, std::chrono::milliseconds timeout = 2s) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (!predicate() && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(1ms);
}
return predicate();
}
#if YAMS_DAEMON_TEST_HOOKS_ENABLED
struct AcceptLoopHookGuard {
~AcceptLoopHookGuard() {
P2pListener::testing_setAcceptLoopHook({});
P2pListener::testing_setHandshakeStartedHook({});
P2pListener::testing_setAfterAcceptJoinHook({});
}
};
#endif
} // namespace
TEST_CASE("P2P TLS identity derives a stable SPKI pin from writer key", "[daemon][p2p][tls]") {
auto firstKey = yams::memory_sync::generateWriterKeyPair();
auto secondKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(firstKey.has_value());
REQUIRE(secondKey.has_value());
auto first = identity("node-a", firstKey.value());
auto reloaded = identity("node-a", firstKey.value());
auto other = identity("node-b", secondKey.value());
CHECK(first.nodeId() == "node-a");
CHECK(first.certPem().find("BEGIN CERTIFICATE") != std::string::npos);
CHECK(first.spkiPin().size() == 64);
CHECK(first.spkiPin() == reloaded.spkiPin());
CHECK(first.spkiPin() != other.spkiPin());
}
TEST_CASE("P2P TLS certificate binds matching subject and issuer to the node",
"[daemon][p2p][tls][certificate]") {
auto key = yams::memory_sync::generateWriterKeyPair();
REQUIRE(key.has_value());
const auto tlsIdentity = identity("certificate-node", key.value());
const auto pem = tlsIdentity.certPem();
const auto bio = std::unique_ptr<BIO, decltype(&BIO_free)>(
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
REQUIRE(bio != nullptr);
const auto certificate = std::unique_ptr<X509, decltype(&X509_free)>(
PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free);
REQUIRE(certificate != nullptr);
const auto* subject = X509_get_subject_name(certificate.get());
const auto* issuer = X509_get_issuer_name(certificate.get());
REQUIRE(subject != nullptr);
REQUIRE(issuer != nullptr);
std::array<char, 256> commonName{};
const auto length = X509_NAME_get_text_by_NID(subject, NID_commonName, commonName.data(),
static_cast<int>(commonName.size()));
REQUIRE(length > 0);
CHECK(std::string(commonName.data(), static_cast<std::size_t>(length)) == "certificate-node");
CHECK(X509_NAME_cmp(subject, issuer) == 0);
const auto publicKey = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>(
X509_get_pubkey(certificate.get()), EVP_PKEY_free);
REQUIRE(publicKey != nullptr);
CHECK(X509_verify(certificate.get(), publicKey.get()) == 1);
}
TEST_CASE("P2P transport trust defaults fail closed", "[daemon][p2p][tls]") {
auto clientKey = yams::memory_sync::generateWriterKeyPair();
auto serverKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(clientKey.has_value());
REQUIRE(serverKey.has_value());
auto unpinned = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = 1,
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = {},
.connectTimeout = 100ms,
.handshakeTimeout = 100ms});
REQUIRE_FALSE(unpinned.has_value());
CHECK(unpinned.error().code == yams::ErrorCode::InvalidArgument);
auto invalidDeadline = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = 1,
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = std::string(64, 'a'),
.connectTimeout = 100ms,
.handshakeTimeout = 100ms,
.sessionTimeout = 0ms});
REQUIRE_FALSE(invalidDeadline.has_value());
CHECK(invalidDeadline.error().code == yams::ErrorCode::InvalidArgument);
auto oversizedDeadline = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = 1,
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = std::string(64, 'a'),
.connectTimeout = 100ms,
.handshakeTimeout = 100ms,
.sessionTimeout = yams::daemon::p2p::kP2pMaxSessionTimeout + 1ms});
REQUIRE_FALSE(oversizedDeadline.has_value());
CHECK(oversizedDeadline.error().code == yams::ErrorCode::InvalidArgument);
P2pListener listener(P2pListener::Options{
.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {std::string(64, 'a')},
.handshakeTimeout = 100ms,
.maxConcurrentSessions = 1,
});
auto started = listener.start();
REQUIRE_FALSE(started.has_value());
CHECK(started.error().code == yams::ErrorCode::InvalidState);
P2pListener noHandshakeSlots(P2pListener::Options{
.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 100ms,
.sessionTimeout = 1s,
.maxConcurrentHandshakes = 0,
.maxConcurrentSessions = 1,
});
noHandshakeSlots.setHandler([](P2pConnection) {});
auto noCapacity = noHandshakeSlots.start();
REQUIRE_FALSE(noCapacity.has_value());
CHECK(noCapacity.error().code == yams::ErrorCode::InvalidArgument);
P2pListener excessiveHandshakeSlots(P2pListener::Options{
.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 100ms,
.sessionTimeout = 1s,
.maxConcurrentHandshakes = yams::daemon::p2p::kP2pMaxConcurrentHandshakes + 1,
.maxConcurrentSessions = 1,
});
excessiveHandshakeSlots.setHandler([](P2pConnection) {});
auto excessiveCapacity = excessiveHandshakeSlots.start();
REQUIRE_FALSE(excessiveCapacity.has_value());
CHECK(excessiveCapacity.error().code == yams::ErrorCode::InvalidArgument);
}
TEST_CASE("P2P TLS listener and client mutually authenticate and exchange frames",
"[daemon][p2p][tls][network]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
auto clientIdentity = identity("client-node", clientKey.value());
const std::string serverPin = serverIdentity.spkiPin();
const std::string clientPin = clientIdentity.spkiPin();
P2pListener::Options listenerOptions{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {clientPin},
.handshakeTimeout = 2s,
.maxConcurrentSessions = 2};
P2pListener listener(std::move(listenerOptions));
std::promise<yams::Result<void>> serverDone;
auto serverFuture = serverDone.get_future();
listener.setHandler([&](P2pConnection channel) {
if (channel.peerSpkiPin() != clientPin || channel.peerCertCn() != "client-node") {
serverDone.set_value(
yams::Error{yams::ErrorCode::Unauthorized, "unexpected client identity"});
return;
}
auto request = channel.readFrame(2s);
if (!request) {
serverDone.set_value(request.error());
return;
}
if (text(request.value()) != "ping") {
serverDone.set_value(
yams::Error{yams::ErrorCode::InvalidData, "unexpected request frame"});
return;
}
auto written = channel.writeFrame(bytes("pong"), 2s);
serverDone.set_value(written ? yams::Result<void>{} : yams::Result<void>{written.error()});
});
REQUIRE(listener.start().has_value());
REQUIRE(listener.boundPort() != 0);
auto client =
yams::daemon::p2p::p2pConnect(P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = std::move(clientIdentity),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s});
REQUIRE(client.has_value());
CHECK(client.value().peerSpkiPin() == serverPin);
CHECK(client.value().peerCertCn() == "server-node");
CHECK(client.value().localNodeId() == "client-node");
auto excessiveWrite =
client.value().writeFrame(bytes("ping"), yams::daemon::p2p::kP2pMaxOperationTimeout + 1ms);
REQUIRE_FALSE(excessiveWrite.has_value());
CHECK(excessiveWrite.error().code == yams::ErrorCode::InvalidArgument);
auto excessiveRead = client.value().readFrame(yams::daemon::p2p::kP2pMaxOperationTimeout + 1ms);
REQUIRE_FALSE(excessiveRead.has_value());
CHECK(excessiveRead.error().code == yams::ErrorCode::InvalidArgument);
REQUIRE(client.value().writeFrame(bytes("ping"), 2s).has_value());
auto response = client.value().readFrame(2s);
REQUIRE(response.has_value());
CHECK(text(response.value()) == "pong");
REQUIRE(serverFuture.wait_for(3s) == std::future_status::ready);
CHECK(serverFuture.get().has_value());
CHECK(listener.acceptedCount() == 1);
auto oversized = client.value().writeFrame(bytes("too-large"), 2s, 4);
REQUIRE_FALSE(oversized.has_value());
CHECK(oversized.error().code == yams::ErrorCode::InvalidArgument);
client.value().close();
listener.stop();
CHECK_FALSE(listener.started());
}
TEST_CASE("P2P control frames accept the worst-case authenticated state size",
"[daemon][p2p][tls][network]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
auto clientIdentity = identity("client-node", clientKey.value());
const std::string serverPin = serverIdentity.spkiPin();
const std::string clientPin = clientIdentity.spkiPin();
P2pListener::Options listenerOptions{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {clientPin},
.handshakeTimeout = 2s,
.maxConcurrentSessions = 1};
P2pListener listener(std::move(listenerOptions));
std::promise<std::size_t> serverDone;
auto serverFuture = serverDone.get_future();
listener.setHandler([&](P2pConnection channel) {
auto frame = channel.readFrame(5s);
serverDone.set_value(frame ? frame.value().size() : 0);
});
REQUIRE(listener.start().has_value());
auto client =
yams::daemon::p2p::p2pConnect(P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = std::move(clientIdentity),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s});
REQUIRE(client.has_value());
// Exceeds the previous 64 KiB control-frame cap; must round-trip against the worst-case
// authenticated state frame (256 writers x 256-byte ids ~300 KB).
constexpr std::size_t largeSize = 200 * 1024;
const std::vector<std::byte> largeFrame(largeSize, std::byte{'x'});
REQUIRE(client.value().writeFrame(largeFrame, 5s).has_value());
REQUIRE(serverFuture.wait_for(5s) == std::future_status::ready);
CHECK(serverFuture.get() == largeSize);
client.value().close();
listener.stop();
}
TEST_CASE("P2P TLS read deadline interrupts a stalled peer", "[daemon][p2p][tls][network]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
auto clientIdentity = identity("client-node", clientKey.value());
const std::string serverPin = serverIdentity.spkiPin();
const std::string clientPin = clientIdentity.spkiPin();
std::promise<void> entered;
auto enteredFuture = entered.get_future();
std::promise<void> release;
auto releaseFuture = release.get_future().share();
P2pListener::Options listenerOptions{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {clientPin},
.handshakeTimeout = 2s,
.maxConcurrentSessions = 1};
P2pListener listener(std::move(listenerOptions));
listener.setHandler([&](P2pConnection) {
entered.set_value();
releaseFuture.wait();
});
REQUIRE(listener.start().has_value());
auto client =
yams::daemon::p2p::p2pConnect(P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = std::move(clientIdentity),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s});
REQUIRE(client.has_value());
REQUIRE(enteredFuture.wait_for(2s) == std::future_status::ready);
auto frame = client.value().readFrame(100ms);
REQUIRE_FALSE(frame.has_value());
CHECK(frame.error().code == yams::ErrorCode::Timeout);
release.set_value();
listener.stop();
}
TEST_CASE("P2P TLS listener reaps completed session workers while running",
"[daemon][p2p][tls][network][lifecycle]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const std::string serverPin = serverIdentity.spkiPin();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 2s,
.maxConcurrentSessions = 1});
listener.setHandler([](P2pConnection channel) { channel.close(); });
REQUIRE(listener.start().has_value());
constexpr std::size_t kSessionCount = 20;
for (std::size_t index = 0; index < kSessionCount; ++index) {
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s});
REQUIRE(client.has_value());
auto closed = client.value().readFrame(2s);
REQUIRE_FALSE(closed.has_value());
}
CHECK(listener.acceptedCount() == kSessionCount);
CHECK(listener.retainedSessionCount() <= 2);
listener.stop();
CHECK(listener.retainedSessionCount() == 0);
}
TEST_CASE("P2P TLS listener rejects a mismatched client pin", "[daemon][p2p][tls][network]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const std::string serverPin = serverIdentity.spkiPin();
std::promise<void> handlerCalled;
auto handlerFuture = handlerCalled.get_future();
P2pListener::Options listenerOptions{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {std::string(64, 'f')},
.handshakeTimeout = 2s,
.maxConcurrentSessions = 1};
P2pListener listener(std::move(listenerOptions));
listener.setHandler([&](P2pConnection) { handlerCalled.set_value(); });
REQUIRE(listener.start().has_value());
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s});
if (client) {
auto closed = client.value().readFrame(2s);
REQUIRE_FALSE(closed.has_value());
}
CHECK(listener.acceptedCount() == 1);
CHECK(handlerFuture.wait_for(0s) == std::future_status::timeout);
listener.stop();
}
TEST_CASE("P2P listener keeps unauthenticated handshakes out of authenticated capacity",
"[daemon][p2p][tls][network][capacity]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
auto clientIdentity = identity("client-node", clientKey.value());
const std::string serverPin = serverIdentity.spkiPin();
const std::string clientPin = clientIdentity.spkiPin();
std::promise<void> authenticated;
auto authenticatedFuture = authenticated.get_future();
std::promise<void> release;
auto releaseFuture = release.get_future().share();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {clientPin},
.handshakeTimeout = 2s,
.sessionTimeout = 3s,
.maxConcurrentHandshakes = 2,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) {
authenticated.set_value();
releaseFuture.wait();
});
REQUIRE(listener.start().has_value());
boost::asio::io_context rawIo;
boost::asio::ip::tcp::socket stalled(rawIo);
stalled.connect({boost::asio::ip::make_address("127.0.0.1"), listener.boundPort()});
REQUIRE(waitUntil([&] { return listener.preAuthSessionCount() == 1; }));
CHECK(listener.authenticatedSessionCount() == 0);
auto client =
yams::daemon::p2p::p2pConnect(P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = std::move(clientIdentity),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s,
.sessionTimeout = 3s});
REQUIRE(client.has_value());
REQUIRE(authenticatedFuture.wait_for(2s) == std::future_status::ready);
CHECK(listener.preAuthSessionCount() == 1);
CHECK(listener.authenticatedSessionCount() == 1);
release.set_value();
client.value().close();
boost::system::error_code ignored;
stalled.close(ignored);
listener.stop();
}
TEST_CASE("P2P listener bounds and reuses unauthenticated handshake capacity",
"[daemon][p2p][tls][network][capacity]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
P2pListener listener(
P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 2s,
.sessionTimeout = 3s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([](P2pConnection) {});
REQUIRE(listener.start().has_value());
boost::asio::io_context rawIo;
boost::asio::ip::tcp::socket first(rawIo);
boost::asio::ip::tcp::socket queued(rawIo);
const boost::asio::ip::tcp::endpoint endpoint{boost::asio::ip::make_address("127.0.0.1"),
listener.boundPort()};
first.connect(endpoint);
REQUIRE(waitUntil([&] { return listener.preAuthSessionCount() == 1; }));
queued.connect(endpoint);
std::this_thread::sleep_for(50ms);
CHECK(listener.acceptedCount() == 1);
CHECK(listener.preAuthSessionCount() == 1);
boost::system::error_code ignored;
first.close(ignored);
REQUIRE(waitUntil([&] { return listener.acceptedCount() == 2; }));
CHECK(listener.preAuthSessionCount() == 1);
queued.close(ignored);
listener.stop();
CHECK(listener.preAuthSessionCount() == 0);
}
TEST_CASE("P2P listener bounds and reuses authenticated session capacity",
"[daemon][p2p][tls][network][capacity]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const auto serverPin = serverIdentity.spkiPin();
std::atomic<unsigned> invocations{0};
std::promise<void> firstEntered;
auto firstEnteredFuture = firstEntered.get_future();
std::promise<void> releaseFirst;
auto releaseFirstFuture = releaseFirst.get_future().share();
std::promise<void> reused;
auto reusedFuture = reused.get_future();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 2s,
.sessionTimeout = 3s,
.maxConcurrentHandshakes = 2,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) {
if (invocations.fetch_add(1, std::memory_order_relaxed) == 0) {
firstEntered.set_value();
releaseFirstFuture.wait();
return;
}
reused.set_value();
});
REQUIRE(listener.start().has_value());
auto connectClient = [&] {
return yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s,
.sessionTimeout = 3s});
};
auto first = connectClient();
REQUIRE(first.has_value());
REQUIRE(firstEnteredFuture.wait_for(1s) == std::future_status::ready);
CHECK(listener.authenticatedSessionCount() == 1);
auto rejected = connectClient();
REQUIRE(rejected.has_value());
REQUIRE_FALSE(rejected.value().readFrame(1s).has_value());
CHECK(invocations.load(std::memory_order_relaxed) == 1);
CHECK(listener.authenticatedSessionCount() == 1);
releaseFirst.set_value();
first.value().close();
REQUIRE(waitUntil([&] { return listener.authenticatedSessionCount() == 0; }));
auto third = connectClient();
REQUIRE(third.has_value());
REQUIRE(reusedFuture.wait_for(1s) == std::future_status::ready);
CHECK(invocations.load(std::memory_order_relaxed) == 2);
listener.stop();
}
TEST_CASE("P2P connection enforces one aggregate session deadline",
"[daemon][p2p][tls][network][deadline]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
auto clientIdentity = identity("client-node", clientKey.value());
const std::string serverPin = serverIdentity.spkiPin();
const std::string clientPin = clientIdentity.spkiPin();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {clientPin},
.handshakeTimeout = 2s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 2,
.maxConcurrentSessions = 1});
listener.setHandler([](P2pConnection channel) {
for (int exchange = 0; exchange < 2; ++exchange) {
auto request = channel.readFrame(2s);
if (!request || !channel.writeFrame(request.value(), 2s)) {
return;
}
}
// Keep the peer alive while the client tests its shorter session deadline.
// Returning after the first late frame instead tests a premature peer close.
while (channel.readFrame(2s).has_value()) {
}
});
REQUIRE(listener.start().has_value());
auto client =
yams::daemon::p2p::p2pConnect(P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = std::move(clientIdentity),
.expectedPeerPin = serverPin,
.connectTimeout = 2s,
.handshakeTimeout = 2s,
.sessionTimeout = 500ms});
REQUIRE(client.has_value());
for (int exchange = 0; exchange < 2; ++exchange) {
REQUIRE(client.value().writeFrame(bytes("ping"), 2s).has_value());
auto response = client.value().readFrame(2s);
REQUIRE(response.has_value());
CHECK(text(response.value()) == "ping");
}
// The session deadline is absolute (not idle-based), so a write only starts failing
// once 500ms of wall time have passed. Poll until it does instead of sleeping a fixed
// 600ms, which is timing-sensitive under load. Require a client-side Timeout:
// a peer close at the server's longer deadline does not prove this contract.
const auto expiryDeadline = std::chrono::steady_clock::now() + 3s;
bool observedExpiry = false;
yams::ErrorCode expiryCode = yams::ErrorCode::InvalidState;
while (std::chrono::steady_clock::now() < expiryDeadline) {
auto attempt = client.value().writeFrame(bytes("late"), 2s);
if (!attempt) {
observedExpiry = true;
expiryCode = attempt.error().code;
break;
}
std::this_thread::sleep_for(10ms);
}
REQUIRE(observedExpiry);
CHECK(expiryCode == yams::ErrorCode::Timeout);
listener.stop();
}
TEST_CASE("P2P listener stop interrupts an in-flight unauthenticated handshake",
"[daemon][p2p][tls][network][lifecycle]") {
#if YAMS_DAEMON_TEST_HOOKS_ENABLED
AcceptLoopHookGuard guard;
std::promise<void> handshakeStarted;
auto handshakeStartedFuture = handshakeStarted.get_future();
P2pListener::testing_setHandshakeStartedHook([&] { handshakeStarted.set_value(); });
#endif
auto serverKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
P2pListener listener(
P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 5s,
.sessionTimeout = 6s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([](P2pConnection) {});
REQUIRE(listener.start().has_value());
boost::asio::io_context rawIo;
boost::asio::ip::tcp::socket stalled(rawIo);
stalled.connect({boost::asio::ip::make_address("127.0.0.1"), listener.boundPort()});
#if YAMS_DAEMON_TEST_HOOKS_ENABLED
REQUIRE(handshakeStartedFuture.wait_for(2s) == std::future_status::ready);
#else
REQUIRE(waitUntil([&] { return listener.preAuthSessionCount() == 1; }));
#endif
CHECK(listener.preAuthSessionCount() == 1);
const auto started = std::chrono::steady_clock::now();
listener.stop();
CHECK(std::chrono::steady_clock::now() - started < 1s);
CHECK(listener.preAuthSessionCount() == 0);
}
TEST_CASE("P2P listener contains handler exceptions and reuses authenticated capacity",
"[daemon][p2p][network][lifecycle]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const auto serverPin = serverIdentity.spkiPin();
std::atomic<unsigned> invocations{0};
std::promise<void> recovered;
auto recoveredFuture = recovered.get_future();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 1s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 2,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) {
if (invocations.fetch_add(1, std::memory_order_relaxed) == 0) {
throw std::runtime_error("injected handler failure");
}
recovered.set_value();
});
REQUIRE(listener.start().has_value());
auto connectClient = [&] {
return yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 1s,
.handshakeTimeout = 1s,
.sessionTimeout = 2s});
};
auto first = connectClient();
REQUIRE(first.has_value());
REQUIRE_FALSE(first.value().readFrame(1s).has_value());
REQUIRE(waitUntil([&] { return listener.authenticatedSessionCount() == 0; }));
auto second = connectClient();
REQUIRE(second.has_value());
REQUIRE(recoveredFuture.wait_for(1s) == std::future_status::ready);
CHECK(invocations.load(std::memory_order_relaxed) == 2);
listener.stop();
}
TEST_CASE("P2P listener permits a session handler to request stop",
"[daemon][p2p][network][lifecycle]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const auto serverPin = serverIdentity.spkiPin();
std::promise<void> handlerReturnedFromStop;
auto handlerReturnedFromStopFuture = handlerReturnedFromStop.get_future();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 1s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) {
listener.stop();
handlerReturnedFromStop.set_value();
});
REQUIRE(listener.start().has_value());
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 1s,
.handshakeTimeout = 1s,
.sessionTimeout = 2s});
REQUIRE(client.has_value());
REQUIRE(handlerReturnedFromStopFuture.wait_for(1s) == std::future_status::ready);
CHECK_FALSE(listener.started());
// External cleanup joins the worker that requested cancellation.
listener.stop();
CHECK(listener.retainedSessionCount() == 0);
}
#if YAMS_DAEMON_TEST_HOOKS_ENABLED
TEST_CASE("P2P listener contains accept-thread exceptions", "[daemon][p2p][lifecycle]") {
AcceptLoopHookGuard guard;
P2pListener::testing_setAcceptLoopHook(
[] { throw std::runtime_error("injected accept-loop failure"); });
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const auto serverPin = serverIdentity.spkiPin();
std::promise<void> connectedAfterRestart;
auto connectedAfterRestartFuture = connectedAfterRestart.get_future();
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 1s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) { connectedAfterRestart.set_value(); });
REQUIRE(listener.start().has_value());
REQUIRE(waitUntil([&] { return !listener.started(); }));
auto unsafeRestart = listener.start();
REQUIRE_FALSE(unsafeRestart.has_value());
CHECK(unsafeRestart.error().code == yams::ErrorCode::InvalidState);
P2pListener::testing_setAcceptLoopHook({});
listener.stop();
REQUIRE(listener.start().has_value());
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 1s,
.handshakeTimeout = 1s,
.sessionTimeout = 2s});
REQUIRE(client.has_value());
CHECK(connectedAfterRestartFuture.wait_for(1s) == std::future_status::ready);
listener.stop();
}
TEST_CASE("P2P listener serializes restart with external teardown", "[daemon][p2p][lifecycle]") {
AcceptLoopHookGuard guard;
std::promise<void> acceptJoined;
auto acceptJoinedFuture = acceptJoined.get_future();
std::promise<void> releaseTeardown;
auto releaseTeardownFuture = releaseTeardown.get_future().share();
P2pListener::testing_setAfterAcceptJoinHook([&] {
acceptJoined.set_value();
releaseTeardownFuture.wait();
});
auto serverKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
P2pListener listener(
P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 1s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([](P2pConnection) {});
REQUIRE(listener.start().has_value());
auto stopping = std::async(std::launch::async, [&] { listener.stop(); });
REQUIRE(acceptJoinedFuture.wait_for(1s) == std::future_status::ready);
auto restarting = std::async(std::launch::async, [&] { return listener.start(); });
CHECK(restarting.wait_for(50ms) == std::future_status::timeout);
P2pListener::testing_setAfterAcceptJoinHook({});
releaseTeardown.set_value();
stopping.get();
auto restarted = restarting.get();
REQUIRE(restarted.has_value());
CHECK(listener.started());
listener.stop();
}
TEST_CASE("P2P listener contains overlapping worker and external stop requests",
"[daemon][p2p][network][lifecycle]") {
AcceptLoopHookGuard guard;
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
auto serverIdentity = identity("server-node", serverKey.value());
const auto serverPin = serverIdentity.spkiPin();
std::promise<void> handlerEntered;
auto handlerEnteredFuture = handlerEntered.get_future();
std::promise<void> requestWorkerStop;
auto requestWorkerStopFuture = requestWorkerStop.get_future().share();
std::promise<void> acceptJoined;
auto acceptJoinedFuture = acceptJoined.get_future();
P2pListener::testing_setAfterAcceptJoinHook([&] { acceptJoined.set_value(); });
P2pListener listener(P2pListener::Options{.host = "127.0.0.1",
.port = 0,
.identity = std::move(serverIdentity),
.allowedPeerPins = {},
.allowUnpinnedPeers = true,
.handshakeTimeout = 1s,
.sessionTimeout = 2s,
.maxConcurrentHandshakes = 1,
.maxConcurrentSessions = 1});
listener.setHandler([&](P2pConnection) {
handlerEntered.set_value();
requestWorkerStopFuture.wait();
listener.stop();
});
REQUIRE(listener.start().has_value());
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = serverPin,
.connectTimeout = 1s,
.handshakeTimeout = 1s,
.sessionTimeout = 2s});
REQUIRE(client.has_value());
REQUIRE(handlerEnteredFuture.wait_for(1s) == std::future_status::ready);
auto externalStop = std::async(std::launch::async, [&] { listener.stop(); });
REQUIRE(acceptJoinedFuture.wait_for(1s) == std::future_status::ready);
requestWorkerStop.set_value();
externalStop.get();
CHECK_FALSE(listener.started());
CHECK(listener.retainedSessionCount() == 0);
}
#endif
TEST_CASE("P2P TLS client rejects a mismatched server pin", "[daemon][p2p][tls][network]") {
auto serverKey = yams::memory_sync::generateWriterKeyPair();
auto clientKey = yams::memory_sync::generateWriterKeyPair();
REQUIRE(serverKey.has_value());
REQUIRE(clientKey.has_value());
P2pListener::Options listenerOptions{.host = "127.0.0.1",
.port = 0,
.identity = identity("server-node", serverKey.value()),
.allowedPeerPins = {},
.handshakeTimeout = 2s,
.maxConcurrentSessions = 1};
P2pListener listener(std::move(listenerOptions));
listener.setHandler([](P2pConnection channel) { channel.close(); });
REQUIRE(listener.start().has_value());
auto client = yams::daemon::p2p::p2pConnect(
P2pClientOptions{.host = "127.0.0.1",
.port = listener.boundPort(),
.identity = identity("client-node", clientKey.value()),
.expectedPeerPin = std::string(64, '0'),
.connectTimeout = 2s,
.handshakeTimeout = 2s});
REQUIRE_FALSE(client.has_value());
CHECK(client.error().code == yams::ErrorCode::Unauthorized);
listener.stop();
}