Skip to content

Commit

Permalink
merge bitcoin#20477: Add unit testing of node eviction logic
Browse files Browse the repository at this point in the history
  • Loading branch information
kwvg committed Mar 25, 2024
1 parent 2d838a6 commit 9f1a3e5
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 74 deletions.
140 changes: 66 additions & 74 deletions src/net.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -931,21 +931,6 @@ size_t CConnman::SocketSendData(CNode& node)
return nSentSize;
}

struct NodeEvictionCandidate
{
NodeId id;
int64_t nTimeConnected;
int64_t nMinPingUsecTime;
int64_t nLastBlockTime;
int64_t nLastTXTime;
bool fRelevantServices;
bool fRelayTxes;
bool fBloomFilter;
uint64_t nKeyedNetGroup;
bool prefer_evict;
bool m_is_local;
};

static bool ReverseCompareNodeMinPingTime(const NodeEvictionCandidate& a, const NodeEvictionCandidate& b)
{
return a.nMinPingUsecTime > b.nMinPingUsecTime;
Expand Down Expand Up @@ -1001,63 +986,8 @@ static void EraseLastKElements(std::vector<T> &elements, Comparator comparator,
elements.erase(elements.end() - eraseSize, elements.end());
}

/** Try to find a connection to evict when the node is full.
* Extreme care must be taken to avoid opening the node to attacker
* triggered network partitioning.
* The strategy used here is to protect a small number of peers
* for each of several distinct characteristics which are difficult
* to forge. In order to partition a node the attacker must be
* simultaneously better at all of them than honest peers.
*/
bool CConnman::AttemptToEvictConnection()
[[nodiscard]] std::optional<NodeId> SelectNodeToEvict(std::vector<NodeEvictionCandidate>&& vEvictionCandidates)
{
std::vector<NodeEvictionCandidate> vEvictionCandidates;
{
LOCK(cs_vNodes);

for (const CNode* node : vNodes) {
if (node->HasPermission(PF_NOBAN))
continue;
if (!node->IsInboundConn())
continue;
if (node->fDisconnect)
continue;

if (fMasternodeMode) {
// This handles eviction protected nodes. Nodes are always protected for a short time after the connection
// was accepted. This short time is meant for the VERSION/VERACK exchange and the possible MNAUTH that might
// follow when the incoming connection is from another masternode. When a message other than MNAUTH
// is received after VERSION/VERACK, the protection is lifted immediately.
bool isProtected = GetSystemTimeInSeconds() - node->nTimeConnected < INBOUND_EVICTION_PROTECTION_TIME;
if (node->nTimeFirstMessageReceived != 0 && !node->fFirstMessageIsMNAUTH) {
isProtected = false;
}
// if MNAUTH was valid, the node is always protected (and at the same time not accounted when
// checking incoming connection limits)
if (!node->GetVerifiedProRegTxHash().IsNull()) {
isProtected = true;
}
if (isProtected) {
continue;
}
}

bool peer_relay_txes = false;
bool peer_filter_not_null = false;
if (node->RelayAddrsWithConn()) {
LOCK(node->m_tx_relay->cs_filter);
peer_relay_txes = node->m_tx_relay->fRelayTxes;
peer_filter_not_null = node->m_tx_relay->pfilter != nullptr;
}
NodeEvictionCandidate candidate = {node->GetId(), node->nTimeConnected, node->nMinPingUsecTime,
node->nLastBlockTime, node->nLastTXTime,
HasAllDesirableServiceFlags(node->nServices),
peer_relay_txes, peer_filter_not_null, node->nKeyedNetGroup,
node->m_prefer_evict, node->addr.IsLocal()};
vEvictionCandidates.push_back(candidate);
}
}

// Protect connections with certain characteristics

// Deterministically select 4 peers to protect by netgroup.
Expand Down Expand Up @@ -1095,7 +1025,7 @@ bool CConnman::AttemptToEvictConnection()
total_protect_size -= initial_size - vEvictionCandidates.size();
EraseLastKElements(vEvictionCandidates, ReverseCompareNodeTimeConnected, total_protect_size);

if (vEvictionCandidates.empty()) return false;
if (vEvictionCandidates.empty()) return std::nullopt;

// If any remaining peers are preferred for eviction consider only them.
// This happens after the other preferences since if a peer is really the best by other criteria (esp relaying blocks)
Expand Down Expand Up @@ -1127,10 +1057,72 @@ bool CConnman::AttemptToEvictConnection()
vEvictionCandidates = std::move(mapNetGroupNodes[naMostConnections]);

// Disconnect from the network group with the most connections
NodeId evicted = vEvictionCandidates.front().id;
return vEvictionCandidates.front().id;
}

/** Try to find a connection to evict when the node is full.
* Extreme care must be taken to avoid opening the node to attacker
* triggered network partitioning.
* The strategy used here is to protect a small number of peers
* for each of several distinct characteristics which are difficult
* to forge. In order to partition a node the attacker must be
* simultaneously better at all of them than honest peers.
*/
bool CConnman::AttemptToEvictConnection()
{
std::vector<NodeEvictionCandidate> vEvictionCandidates;
{
LOCK(cs_vNodes);

for (const CNode* node : vNodes) {
if (node->HasPermission(PF_NOBAN))
continue;
if (!node->IsInboundConn())
continue;
if (node->fDisconnect)
continue;

if (fMasternodeMode) {
// This handles eviction protected nodes. Nodes are always protected for a short time after the connection
// was accepted. This short time is meant for the VERSION/VERACK exchange and the possible MNAUTH that might
// follow when the incoming connection is from another masternode. When a message other than MNAUTH
// is received after VERSION/VERACK, the protection is lifted immediately.
bool isProtected = GetSystemTimeInSeconds() - node->nTimeConnected < INBOUND_EVICTION_PROTECTION_TIME;
if (node->nTimeFirstMessageReceived != 0 && !node->fFirstMessageIsMNAUTH) {
isProtected = false;
}
// if MNAUTH was valid, the node is always protected (and at the same time not accounted when
// checking incoming connection limits)
if (!node->GetVerifiedProRegTxHash().IsNull()) {
isProtected = true;
}
if (isProtected) {
continue;
}
}

bool peer_relay_txes = false;
bool peer_filter_not_null = false;
if (node->RelayAddrsWithConn()) {
LOCK(node->m_tx_relay->cs_filter);
peer_relay_txes = node->m_tx_relay->fRelayTxes;
peer_filter_not_null = node->m_tx_relay->pfilter != nullptr;
}
NodeEvictionCandidate candidate = {node->GetId(), node->nTimeConnected, node->nMinPingUsecTime,
node->nLastBlockTime, node->nLastTXTime,
HasAllDesirableServiceFlags(node->nServices),
peer_relay_txes, peer_filter_not_null, node->nKeyedNetGroup,
node->m_prefer_evict, node->addr.IsLocal()};
vEvictionCandidates.push_back(candidate);
}
}
const std::optional<NodeId> node_id_to_evict = SelectNodeToEvict(std::move(vEvictionCandidates));
if (!node_id_to_evict) {
return false;
}
LOCK(cs_vNodes);
for (CNode* pnode : vNodes) {
if (pnode->GetId() == evicted) {
if (pnode->GetId() == *node_id_to_evict) {
pnode->fDisconnect = true;
return true;
}
Expand Down
18 changes: 18 additions & 0 deletions src/net.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
#include <thread>
#include <optional>
#include <queue>
#include <vector>

#ifndef WIN32
#define USE_WAKEUP_PIPE
Expand Down Expand Up @@ -1592,6 +1593,23 @@ inline std::chrono::microseconds PoissonNextSend(std::chrono::microseconds now,
/** Dump binary message to file, with timestamp */
void CaptureMessage(const CAddress& addr, const std::string& msg_type, const Span<const unsigned char>& data, bool is_incoming);

struct NodeEvictionCandidate
{
NodeId id;
int64_t nTimeConnected;
int64_t nMinPingUsecTime;
int64_t nLastBlockTime;
int64_t nLastTXTime;
bool fRelevantServices;
bool fRelayTxes;
bool fBloomFilter;
uint64_t nKeyedNetGroup;
bool prefer_evict;
bool m_is_local;
};

[[nodiscard]] std::optional<NodeId> SelectNodeToEvict(std::vector<NodeEvictionCandidate>&& vEvictionCandidates);

class CExplicitNetCleanup
{
public:
Expand Down
145 changes: 145 additions & 0 deletions src/test/net_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@

#include <boost/test/unit_test.hpp>

#include <algorithm>
#include <cstdint>
#include <ios>
#include <memory>
#include <optional>
#include <string>

using namespace std::literals;
Expand Down Expand Up @@ -845,4 +847,147 @@ BOOST_AUTO_TEST_CASE(LocalAddress_BasicLifecycle)
}


std::vector<NodeEvictionCandidate> GetRandomNodeEvictionCandidates(const int n_candidates, FastRandomContext& random_context)
{
std::vector<NodeEvictionCandidate> candidates;
for (int id = 0; id < n_candidates; ++id) {
candidates.push_back({
/* id */ id,
/* nTimeConnected */ static_cast<int64_t>(random_context.randrange(100)),
/* nMinPingUsecTime */ static_cast<int64_t>(random_context.randrange(100)),
/* nLastBlockTime */ static_cast<int64_t>(random_context.randrange(100)),
/* nLastTXTime */ static_cast<int64_t>(random_context.randrange(100)),
/* fRelevantServices */ random_context.randbool(),
/* fRelayTxes */ random_context.randbool(),
/* fBloomFilter */ random_context.randbool(),
/* nKeyedNetGroup */ random_context.randrange(100),
/* prefer_evict */ random_context.randbool(),
/* m_is_local */ random_context.randbool(),
});
}
return candidates;
}

// Returns true if any of the node ids in node_ids are selected for eviction.
bool IsEvicted(std::vector<NodeEvictionCandidate> candidates, const std::vector<NodeId>& node_ids, FastRandomContext& random_context)
{
Shuffle(candidates.begin(), candidates.end(), random_context);
const std::optional<NodeId> evicted_node_id = SelectNodeToEvict(std::move(candidates));
if (!evicted_node_id) {
return false;
}
return std::find(node_ids.begin(), node_ids.end(), *evicted_node_id) != node_ids.end();
}

// Create number_of_nodes random nodes, apply setup function candidate_setup_fn,
// apply eviction logic and then return true if any of the node ids in node_ids
// are selected for eviction.
bool IsEvicted(const int number_of_nodes, std::function<void(NodeEvictionCandidate&)> candidate_setup_fn, const std::vector<NodeId>& node_ids, FastRandomContext& random_context)
{
std::vector<NodeEvictionCandidate> candidates = GetRandomNodeEvictionCandidates(number_of_nodes, random_context);
for (NodeEvictionCandidate& candidate : candidates) {
candidate_setup_fn(candidate);
}
return IsEvicted(candidates, node_ids, random_context);
}

namespace {
constexpr int NODE_EVICTION_TEST_ROUNDS{10};
constexpr int NODE_EVICTION_TEST_UP_TO_N_NODES{200};
} // namespace

BOOST_AUTO_TEST_CASE(node_eviction_test)
{
FastRandomContext random_context{true};

for (int i = 0; i < NODE_EVICTION_TEST_ROUNDS; ++i) {
for (int number_of_nodes = 0; number_of_nodes < NODE_EVICTION_TEST_UP_TO_N_NODES; ++number_of_nodes) {
// Four nodes with the highest keyed netgroup values should be
// protected from eviction.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nKeyedNetGroup = number_of_nodes - candidate.id;
},
{0, 1, 2, 3}, random_context));

// Eight nodes with the lowest minimum ping time should be protected
// from eviction.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [](NodeEvictionCandidate& candidate) {
candidate.nMinPingUsecTime = candidate.id;
},
{0, 1, 2, 3, 4, 5, 6, 7}, random_context));

// Four nodes that most recently sent us novel transactions accepted
// into our mempool should be protected from eviction.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nLastTXTime = number_of_nodes - candidate.id;
},
{0, 1, 2, 3}, random_context));

// Up to eight non-tx-relay peers that most recently sent us novel
// blocks should be protected from eviction.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nLastBlockTime = number_of_nodes - candidate.id;
if (candidate.id <= 7) {
candidate.fRelayTxes = false;
candidate.fRelevantServices = true;
}
},
{0, 1, 2, 3, 4, 5, 6, 7}, random_context));

// Four peers that most recently sent us novel blocks should be
// protected from eviction.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nLastBlockTime = number_of_nodes - candidate.id;
},
{0, 1, 2, 3}, random_context));

// Combination of the previous two tests.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nLastBlockTime = number_of_nodes - candidate.id;
if (candidate.id <= 7) {
candidate.fRelayTxes = false;
candidate.fRelevantServices = true;
}
},
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, random_context));

// Combination of all tests above.
BOOST_CHECK(!IsEvicted(
number_of_nodes, [number_of_nodes](NodeEvictionCandidate& candidate) {
candidate.nKeyedNetGroup = number_of_nodes - candidate.id; // 4 protected
candidate.nMinPingUsecTime = candidate.id; // 8 protected
candidate.nLastTXTime = number_of_nodes - candidate.id; // 4 protected
candidate.nLastBlockTime = number_of_nodes - candidate.id; // 4 protected
},
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, random_context));

// An eviction is expected given >= 29 random eviction candidates. The eviction logic protects at most
// four peers by net group, eight by lowest ping time, four by last time of novel tx, up to eight non-tx-relay
// peers by last novel block time, and four more peers by last novel block time.
if (number_of_nodes >= 29) {
BOOST_CHECK(SelectNodeToEvict(GetRandomNodeEvictionCandidates(number_of_nodes, random_context)));
}

// No eviction is expected given <= 20 random eviction candidates. The eviction logic protects at least
// four peers by net group, eight by lowest ping time, four by last time of novel tx and four peers by last
// novel block time.
if (number_of_nodes <= 20) {
BOOST_CHECK(!SelectNodeToEvict(GetRandomNodeEvictionCandidates(number_of_nodes, random_context)));
}

// Cases left to test:
// * "Protect the half of the remaining nodes which have been connected the longest. [...]"
// * "Pick out up to 1/4 peers that are localhost, sorted by longest uptime. [...]"
// * "If any remaining peers are preferred for eviction consider only them. [...]"
// * "Identify the network group with the most connections and youngest member. [...]"
}
}
}

BOOST_AUTO_TEST_SUITE_END()

0 comments on commit 9f1a3e5

Please sign in to comment.