mirror of
https://github.com/lux-cpp/node.git
synced 2026-10-30 14:48:15 +00:00
Mesh::listen_bind bound INADDR_LOOPBACK, so a validator in a pod accepted no link from another pod and a five-seat network reported NO QUORUM REACHABLE. --mesh-host H (default 127.0.0.1, like --rpc-host) goes through HostConfig to listen_bind(host, port); a host that is not an IPv4 address is refused before anything starts, and the daemon prints the address it bound. A pod runs with --mesh-host 0.0.0.0: its own --peers entry is the Service address the others dial, which the pod does not hold. mesh_formation_test [F]: two hosts bound to 0.0.0.0 meet over the machine's outward address (10.42.4.74 on the build pod), a host bound to 127.0.0.1 is not there, and a bad host is refused. run_test: --mesh-host pod.local exits 2. Co-authored-by: Hanzo Dev <[email protected]>
359 lines
16 KiB
C++
359 lines
16 KiB
C++
// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
//
|
|
// mesh_formation_test.cpp — forming the mesh is bounded, and a missing validator
|
|
// is not a dead cluster.
|
|
//
|
|
// Two properties, both about what a node does when the set it was configured with
|
|
// is not the set that showed up:
|
|
//
|
|
// BOUNDED. One deadline covers the whole phase. It used to cover dialing only:
|
|
// accept() blocked forever waiting for a validator that was never started, and
|
|
// a peer that connected and then said nothing held the acceptor for as long as
|
|
// it liked. A node whose start-up can be held open by a stranger is a node an
|
|
// unauthenticated peer can keep out of its own cluster.
|
|
//
|
|
// PARTIAL. connect_mesh reports how many peers it reached and lets the caller
|
|
// read that against the finality rule. Requiring every peer contradicted the
|
|
// quorum rule the whole engine is built on: four validators holding 80 of 100
|
|
// stake — comfortably over the ⅔ floor of 66 — all refused to start because the
|
|
// fifth was down.
|
|
|
|
#include "lux/node/node_host.hpp"
|
|
#include "names.hpp"
|
|
|
|
#include <span>
|
|
#include "bls_signature.hpp"
|
|
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include <arpa/inet.h>
|
|
#include <ifaddrs.h>
|
|
#include <net/if.h>
|
|
#include <netinet/in.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
|
|
using namespace lux::node;
|
|
using namespace lux::consensus;
|
|
|
|
namespace {
|
|
|
|
constexpr std::uint32_t kN = 3;
|
|
constexpr std::uint64_t kStake = 20;
|
|
constexpr int kDeadlineMs = 1500;
|
|
// What is being proven is BOUNDED, not fast: before the fix these calls did not
|
|
// return at all. The bound allows the deadline plus a peer-I/O window per
|
|
// connection attempt, generously, so a loaded machine cannot turn a proof about
|
|
// termination into a proof about scheduling.
|
|
constexpr int kBoundMs = kDeadlineMs + 4 * kPeerIoTimeoutMs;
|
|
|
|
int g_fail = 0;
|
|
void check(bool ok, const std::string& what) {
|
|
if (!ok) { std::printf(" ASSERT FAILED: %s\n", what.c_str()); ++g_fail; }
|
|
}
|
|
|
|
struct Key { std::array<std::uint8_t, 32> sk{}; PubKey pk{}; };
|
|
Key make_key(std::uint8_t tag) {
|
|
std::array<std::uint8_t, 32> seed{};
|
|
seed[0] = tag;
|
|
for (int i = 1; i < 32; ++i) seed[i] = std::uint8_t(0xA5 ^ (tag + i));
|
|
Key k;
|
|
if (cevm::crypto::bls::keygen(seed.data(), k.sk.data()) != 0) { std::puts("keygen"); std::exit(2); }
|
|
if (cevm::crypto::bls::sk_to_pk(k.sk.data(), k.pk.data()) != 0) { std::puts("sk_to_pk"); std::exit(2); }
|
|
return k;
|
|
}
|
|
|
|
std::vector<Key> g_keys;
|
|
std::vector<Validator> g_set;
|
|
// The names every host here greets with: a link proves who answered, so a
|
|
// host without one could not form a mesh at all. Made once in main.
|
|
std::unique_ptr<test::Names> g_names;
|
|
|
|
std::unique_ptr<Node2Host> make_host(std::uint32_t index, const std::string& bind = "127.0.0.1") {
|
|
HostConfig cfg;
|
|
cfg.index = index;
|
|
cfg.host = bind;
|
|
cfg.port = 0;
|
|
cfg.sk = g_keys[index].sk;
|
|
cfg.pk = g_keys[index].pk;
|
|
cfg.validators = g_set;
|
|
cfg.wave = WaveConfig{kN, 3, 4};
|
|
cfg.link = g_names->link(index);
|
|
return std::make_unique<Node2Host>(std::move(cfg));
|
|
}
|
|
|
|
// A loopback port that is BOUND but never listening: connects to it are refused
|
|
// immediately and deterministically, and no other process can take it while this
|
|
// object lives. That is a validator that is configured and not running.
|
|
struct DownPeer {
|
|
int fd = -1;
|
|
std::uint16_t port = 0;
|
|
DownPeer() {
|
|
fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = 0;
|
|
if (::bind(fd, reinterpret_cast<sockaddr*>(&a), sizeof a) != 0) { std::puts("bind"); std::exit(2); }
|
|
socklen_t len = sizeof a;
|
|
::getsockname(fd, reinterpret_cast<sockaddr*>(&a), &len);
|
|
port = ntohs(a.sin_port); // bound, not listening → ECONNREFUSED
|
|
}
|
|
~DownPeer() { if (fd >= 0) ::close(fd); }
|
|
};
|
|
|
|
// This machine's first IPv4 address that is not loopback, or nothing.
|
|
std::string outward() {
|
|
ifaddrs* all = nullptr;
|
|
if (::getifaddrs(&all) != 0) return {};
|
|
std::string found;
|
|
for (ifaddrs* i = all; i != nullptr && found.empty(); i = i->ifa_next) {
|
|
if (i->ifa_addr == nullptr || i->ifa_addr->sa_family != AF_INET) continue;
|
|
if ((i->ifa_flags & IFF_LOOPBACK) || !(i->ifa_flags & IFF_UP)) continue;
|
|
char text[INET_ADDRSTRLEN] = {};
|
|
const auto* a = reinterpret_cast<const sockaddr_in*>(i->ifa_addr);
|
|
if (::inet_ntop(AF_INET, &a->sin_addr, text, sizeof text)) found = text;
|
|
}
|
|
::freeifaddrs(all);
|
|
return found;
|
|
}
|
|
|
|
long elapsed_ms(std::chrono::steady_clock::time_point t0) {
|
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - t0).count();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
std::printf("============ node — mesh formation is bounded, and partial ============\n");
|
|
std::printf("a validator that never starts costs a deadline, not the cluster\n\n");
|
|
|
|
for (std::uint32_t i = 0; i < kN; ++i) g_keys.push_back(make_key(std::uint8_t(0xC0 + i)));
|
|
for (const auto& k : g_keys) g_set.push_back({k.pk, kStake});
|
|
g_names = std::make_unique<test::Names>(kN);
|
|
|
|
// [A] the inbound half of the phase is bounded. Host 1 waits on an inbound
|
|
// connection from host 0, which never runs.
|
|
{
|
|
auto h = make_host(1);
|
|
const std::uint16_t port = h->listen_bind();
|
|
DownPeer absent;
|
|
std::map<std::uint32_t, PeerAddr> peers{{0, {"127.0.0.1", absent.port}}};
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
const std::size_t reached = h->connect_mesh(peers, kDeadlineMs);
|
|
const long took = elapsed_ms(t0);
|
|
check(reached == 0, "[A] no peer reached (the only configured peer is down)");
|
|
check(took < kBoundMs,
|
|
"[A] accept returned by the deadline, not never (took " + std::to_string(took) + " ms)");
|
|
std::printf(" [A] host 1 on :%u waited %ld ms for an absent host 0 (deadline %d ms)\n",
|
|
port, took, kDeadlineMs);
|
|
}
|
|
|
|
// [B] a stranger that connects and then says nothing does not hold the node.
|
|
{
|
|
auto h = make_host(1);
|
|
const std::uint16_t port = h->listen_bind();
|
|
DownPeer absent;
|
|
|
|
// Connect to the host's listener and send no handshake, ever.
|
|
const int mute = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = htons(port);
|
|
check(::connect(mute, reinterpret_cast<sockaddr*>(&a), sizeof a) == 0,
|
|
"[B] the mute stranger connected");
|
|
|
|
std::map<std::uint32_t, PeerAddr> peers{{0, {"127.0.0.1", absent.port}}};
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
const std::size_t reached = h->connect_mesh(peers, kDeadlineMs);
|
|
const long took = elapsed_ms(t0);
|
|
::close(mute);
|
|
check(reached == 0, "[B] a connection with no handshake never becomes a peer");
|
|
check(took < kBoundMs,
|
|
"[B] the mute stranger did not hold the acceptor (took " + std::to_string(took) + " ms)");
|
|
std::printf(" [B] a socket that connected and stayed silent cost %ld ms, then was dropped\n", took);
|
|
}
|
|
|
|
// [C] two of three validators run: each reaches the other and says so.
|
|
{
|
|
auto h0 = make_host(0);
|
|
auto h2 = make_host(2);
|
|
const std::uint16_t p0 = h0->listen_bind();
|
|
const std::uint16_t p2 = h2->listen_bind();
|
|
DownPeer absent; // validator 1 is configured but not running
|
|
|
|
std::map<std::uint32_t, PeerAddr> configured{
|
|
{0, {"127.0.0.1", p0}},
|
|
{1, {"127.0.0.1", absent.port}},
|
|
{2, {"127.0.0.1", p2}},
|
|
};
|
|
|
|
std::size_t r0 = 0, r2 = 0;
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
{
|
|
std::thread a([&] { r0 = h0->connect_mesh(configured, kDeadlineMs); });
|
|
std::thread b([&] { r2 = h2->connect_mesh(configured, kDeadlineMs); });
|
|
a.join();
|
|
b.join();
|
|
}
|
|
const long took = elapsed_ms(t0);
|
|
check(r0 == 1, "[C] host 0 reached exactly the one running peer");
|
|
check(r2 == 1, "[C] host 2 reached exactly the one running peer");
|
|
check(h0->peer_count() == 1 && h2->peer_count() == 1, "[C] both hold one peer");
|
|
check(took < kBoundMs,
|
|
"[C] the absent validator cost a deadline, not a hang (took " + std::to_string(took) + " ms)");
|
|
std::printf(" [C] 2 of 3 up: host 0 reached %zu, host 2 reached %zu, in %ld ms\n", r0, r2, took);
|
|
}
|
|
|
|
// [E] a connection fills at most the slot it names. The handshake index is a
|
|
// claim, not a proof — node has no peer authentication — but checking it
|
|
// against the slots actually being waited on means one socket cannot take
|
|
// two, and a claim on a validator that is not expected is refused rather
|
|
// than counted. Without the check, two connections from a retrying dialer
|
|
// left the mesh believing it was complete while a validator was missing.
|
|
{
|
|
auto h = make_host(2); // awaits inbound from validators 0 and 1
|
|
const std::uint16_t port = h->listen_bind();
|
|
DownPeer d0, d1;
|
|
std::map<std::uint32_t, PeerAddr> peers{
|
|
{0, {"127.0.0.1", d0.port}},
|
|
{1, {"127.0.0.1", d1.port}},
|
|
};
|
|
|
|
std::size_t reached = 0;
|
|
std::thread forming([&] { reached = h->connect_mesh(peers, kDeadlineMs); });
|
|
|
|
// A SEAT IS PROVED, NOT CLAIMED. The first two connections are the old
|
|
// greeting — four bytes naming a validator index — and the third is a
|
|
// real handshake by a real identity that is in no committee. None of
|
|
// them can sign for a seat, so none of them gets one; the first two used
|
|
// to be enough to take one.
|
|
std::vector<int> conns;
|
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
|
const int c = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = htons(port);
|
|
if (::connect(c, reinterpret_cast<sockaddr*>(&a), sizeof a) != 0) {
|
|
::close(c);
|
|
continue;
|
|
}
|
|
if (attempt < 2) {
|
|
const std::uint8_t claim[4] = {0, 0, 0, 0}; // "I am validator 0"
|
|
(void)::send(c, claim, sizeof claim, 0);
|
|
} else {
|
|
const Link outsider = g_names->stranger();
|
|
const auto out = pq::run_initiator(
|
|
outsider.me,
|
|
[c](std::span<const std::uint8_t> b) {
|
|
const auto f = pq::frame(b);
|
|
(void)::send(c, f.data(), f.size(), 0);
|
|
},
|
|
[c]() -> std::vector<std::uint8_t> {
|
|
std::uint8_t head[4];
|
|
if (::recv(c, head, sizeof head, MSG_WAITALL) != ssize_t(sizeof head)) return {};
|
|
std::array<std::uint8_t, 4> h{head[0], head[1], head[2], head[3]};
|
|
std::vector<std::uint8_t> body(pq::body_size(h));
|
|
if (::recv(c, body.data(), body.size(), MSG_WAITALL) != ssize_t(body.size()))
|
|
return {};
|
|
return body;
|
|
},
|
|
outsider.chain);
|
|
// The handshake itself may well complete — a stranger has a
|
|
// perfectly good key. What it does not have is a seat.
|
|
std::printf(" [E] a validator nobody seated: handshake %s\n",
|
|
out.ok ? "completed, and it was still refused" : out.error.c_str());
|
|
}
|
|
conns.push_back(c);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(60));
|
|
}
|
|
forming.join();
|
|
for (int c : conns) ::close(c);
|
|
|
|
check(reached == 0, "[E] two stale index claims and one unseated validator take no slot");
|
|
std::printf(" [E] against slots {0,1}: %zu peer(s) admitted\n", reached);
|
|
}
|
|
|
|
// [D] regression: a complete set still forms a complete mesh, fast.
|
|
{
|
|
std::vector<std::unique_ptr<Node2Host>> hosts;
|
|
std::map<std::uint32_t, PeerAddr> configured;
|
|
for (std::uint32_t i = 0; i < kN; ++i) {
|
|
hosts.push_back(make_host(i));
|
|
configured[i] = PeerAddr{"127.0.0.1", hosts.back()->listen_bind()};
|
|
}
|
|
std::vector<std::size_t> reached(kN, 0);
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
{
|
|
std::vector<std::thread> ts;
|
|
for (std::uint32_t i = 0; i < kN; ++i)
|
|
ts.emplace_back([&, i] { reached[i] = hosts[i]->connect_mesh(configured, kDeadlineMs); });
|
|
for (auto& t : ts) t.join();
|
|
}
|
|
const long took = elapsed_ms(t0);
|
|
for (std::uint32_t i = 0; i < kN; ++i)
|
|
check(reached[i] == kN - 1, "[D] host " + std::to_string(i) + " reached every peer");
|
|
check(took < kDeadlineMs, "[D] a complete set forms without waiting out the deadline");
|
|
std::printf(" [D] full %u-node mesh formed in %ld ms\n", kN, took);
|
|
}
|
|
|
|
// ── [F] a validator in a pod binds every interface ────────────────────────
|
|
// Its peers are other machines, so the mesh listens where they can reach
|
|
// it: two hosts bound to 0.0.0.0 meet over this machine's own outward
|
|
// address when it has one, and over 127.0.0.1 through the same bind when it
|
|
// does not. The control is the old bind: a host on 127.0.0.1 is not there
|
|
// at the outward address.
|
|
{
|
|
const std::string far = outward();
|
|
const std::string dial = far.empty() ? "127.0.0.1" : far;
|
|
auto h0 = make_host(0, "0.0.0.0");
|
|
auto h1 = make_host(1, "0.0.0.0");
|
|
const std::uint16_t p0 = h0->listen_bind();
|
|
const std::uint16_t p1 = h1->listen_bind();
|
|
std::map<std::uint32_t, PeerAddr> configured{{0, {dial, p0}}, {1, {dial, p1}}};
|
|
std::size_t r0 = 0, r1 = 0;
|
|
{
|
|
std::thread a([&] { r0 = h0->connect_mesh(configured, kDeadlineMs); });
|
|
std::thread b([&] { r1 = h1->connect_mesh(configured, kDeadlineMs); });
|
|
a.join();
|
|
b.join();
|
|
}
|
|
check(r0 == 1 && r1 == 1, "[F] two hosts bound to 0.0.0.0 meet at " + dial);
|
|
std::printf(" [F] bound 0.0.0.0, met over %s (%s)\n", dial.c_str(),
|
|
far.empty() ? "no outward address here" : "this machine's outward address");
|
|
if (!far.empty()) {
|
|
auto lo = make_host(1); // bound 127.0.0.1, as every validator was
|
|
auto out = make_host(0, "0.0.0.0");
|
|
const std::uint16_t pl = lo->listen_bind();
|
|
(void)out->listen_bind();
|
|
std::map<std::uint32_t, PeerAddr> to{{1, {far, pl}}};
|
|
check(out->connect_mesh(to, 600) == 0,
|
|
"[F] control: a host bound to 127.0.0.1 is not there at " + far);
|
|
}
|
|
bool refused = false;
|
|
try {
|
|
(void)make_host(0, "not-an-address")->listen_bind();
|
|
} catch (const std::runtime_error&) {
|
|
refused = true;
|
|
}
|
|
check(refused, "[F] a mesh host that is not an IPv4 address is refused");
|
|
}
|
|
|
|
std::printf("------------------------------------------------------------------------\n");
|
|
if (g_fail) { std::printf("==== node MESH FORMATION: FAIL (%d) ====\n", g_fail); return 1; }
|
|
std::printf("==== node MESH FORMATION: PASS — bounded, partial, and complete when it can be ====\n");
|
|
return 0;
|
|
}
|