librats 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +9 -4
  2. package/lib/index.d.ts +9 -22
  3. package/native-src/CMakeLists.txt +132 -50
  4. package/native-src/cmake/ratsConfig.cmake.in +8 -0
  5. package/native-src/src/bt_network.cpp +296 -183
  6. package/native-src/src/bt_network.h +25 -5
  7. package/native-src/src/crypto/sha256.c +1 -1
  8. package/native-src/src/dht.cpp +297 -47
  9. package/native-src/src/dht.h +70 -6
  10. package/native-src/src/file_transfer.cpp +1185 -1578
  11. package/native-src/src/file_transfer.h +240 -521
  12. package/native-src/src/io_poller.cpp +917 -0
  13. package/native-src/src/io_poller.h +138 -0
  14. package/native-src/src/krpc.cpp +161 -96
  15. package/native-src/src/krpc.h +18 -4
  16. package/native-src/src/librats.cpp +812 -1236
  17. package/native-src/src/librats.h +207 -208
  18. package/native-src/src/librats_bittorrent.cpp +1 -5
  19. package/native-src/src/librats_c.cpp +22 -39
  20. package/native-src/src/librats_discovery.cpp +377 -0
  21. package/native-src/src/librats_encryption.cpp +130 -283
  22. package/native-src/src/librats_file_transfer.cpp +27 -109
  23. package/native-src/src/librats_gossipsub.cpp +1 -5
  24. package/native-src/src/librats_ice.cpp +5 -1
  25. package/native-src/src/librats_log_macros.h +36 -0
  26. package/native-src/src/librats_logging.cpp +1 -7
  27. package/native-src/src/librats_mdns.cpp +1 -11
  28. package/native-src/src/librats_persistence.cpp +1 -11
  29. package/native-src/src/librats_reconnection.cpp +2 -13
  30. package/native-src/src/librats_statistic.cpp +105 -0
  31. package/native-src/src/socket.cpp +15 -3
  32. package/package.json +1 -1
  33. package/scripts/build-librats.js +3 -0
  34. package/scripts/prepare-package.js +10 -0
  35. package/src/librats_node.cpp +53 -64
@@ -5,12 +5,14 @@
5
5
  * @brief Async network layer for BitTorrent peer connections
6
6
  *
7
7
  * Provides efficient multiplexed I/O for managing multiple peer connections
8
- * with non-blocking sockets. Buffers are owned by BtPeerConnection (single
9
- * source of truth) - this class only handles socket I/O.
8
+ * using platform-optimal I/O polling (epoll/kqueue/WSAPoll).
9
+ * Buffers are owned by BtPeerConnection (single source of truth) -
10
+ * this class only handles socket I/O.
10
11
  */
11
12
 
12
13
  #include "bt_types.h"
13
14
  #include "bt_peer_connection.h"
15
+ #include "io_poller.h"
14
16
  #include "socket.h"
15
17
 
16
18
  #include <vector>
@@ -37,7 +39,7 @@ struct BtNetworkConfig {
37
39
  size_t max_connections; ///< Maximum total connections
38
40
  bool enable_incoming; ///< Accept incoming connections
39
41
  int connect_timeout_ms; ///< Timeout for outgoing connections (ms)
40
- int select_timeout_ms; ///< Timeout for select() call (ms)
42
+ int poll_timeout_ms; ///< Timeout for I/O poll (ms)
41
43
  size_t send_buffer_high_water; ///< High water mark for send buffer
42
44
  PeerID peer_id; ///< Our peer ID (for incoming connections)
43
45
 
@@ -46,7 +48,7 @@ struct BtNetworkConfig {
46
48
  , max_connections(200)
47
49
  , enable_incoming(true)
48
50
  , connect_timeout_ms(30000)
49
- , select_timeout_ms(15)
51
+ , poll_timeout_ms(15)
50
52
  , send_buffer_high_water(1024 * 1024) // 1MB
51
53
  , peer_id{} {}
52
54
  };
@@ -150,10 +152,16 @@ struct DisconnectedEvent {
150
152
  /**
151
153
  * @brief Manages all BitTorrent peer connections
152
154
  *
153
- * Provides async I/O via select() multiplexing:
155
+ * Provides async I/O via platform-optimal multiplexing:
156
+ * - Linux: epoll (O(1) per event)
157
+ * - macOS: kqueue (O(1) per event)
158
+ * - Windows: WSAPoll (no FD_SETSIZE limit)
159
+ *
160
+ * Features:
154
161
  * - Listens for incoming connections
155
162
  * - Manages outgoing connection queue
156
163
  * - Handles non-blocking reads/writes
164
+ * - TCP_NODELAY for low-latency messaging
157
165
  * - Invokes callbacks for events
158
166
  *
159
167
  * Thread-safety: The manager runs its own I/O thread.
@@ -328,6 +336,9 @@ private:
328
336
  /// Main I/O loop (runs in separate thread)
329
337
  void io_loop();
330
338
 
339
+ /// Synchronize poller state with current connections (under mutex)
340
+ void sync_poller();
341
+
331
342
  /// Process the queue of pending connections
332
343
  void process_pending_connects();
333
344
 
@@ -360,6 +371,9 @@ private:
360
371
  /// Handle info_hash discovered from incoming connection handshake
361
372
  void on_incoming_info_hash(BtPeerConnection* conn, const BtInfoHash& info_hash);
362
373
 
374
+ /// Set TCP_NODELAY on a socket (disable Nagle's algorithm)
375
+ static bool set_tcp_nodelay(socket_t sock);
376
+
363
377
  //=========================================================================
364
378
  // Data Members
365
379
  //=========================================================================
@@ -373,6 +387,12 @@ private:
373
387
 
374
388
  mutable std::mutex mutex_;
375
389
 
390
+ /// Platform-optimal I/O poller (epoll/kqueue/WSAPoll)
391
+ std::unique_ptr<IOPoller> poller_;
392
+
393
+ /// Tracks current poller event mask per socket (to avoid redundant modify calls)
394
+ std::unordered_map<socket_t, uint32_t> poller_state_;
395
+
376
396
  /// Active connections by socket
377
397
  std::unordered_map<socket_t, SocketContext> connections_;
378
398
 
@@ -28,7 +28,7 @@ void sha256_reset(sha256_context_t *context)
28
28
  context->h[0] = 0x6a09e667;
29
29
  context->h[1] = 0xbb67ae85;
30
30
  context->h[2] = 0x3c6ef372;
31
- context->h[3] = 0xa54ff53a,
31
+ context->h[3] = 0xa54ff53a;
32
32
  context->h[4] = 0x510e527f;
33
33
  context->h[5] = 0x9b05688c;
34
34
  context->h[6] = 0x1f83d9ab;
@@ -28,17 +28,19 @@
28
28
  namespace librats {
29
29
 
30
30
 
31
- DhtClient::DhtClient(int port, const std::string& bind_address, const std::string& data_directory)
32
- : port_(port), bind_address_(bind_address), data_directory_(data_directory),
33
- socket_(INVALID_SOCKET_VALUE), running_(false) {
31
+ DhtClient::DhtClient(int port, const std::string& bind_address, const std::string& data_directory,
32
+ AddressFamily address_family)
33
+ : port_(port), bind_address_(bind_address), data_directory_(data_directory),
34
+ address_family_(address_family), socket_(INVALID_SOCKET_VALUE), running_(false) {
34
35
  node_id_ = generate_node_id();
35
36
  routing_table_.resize(NODE_ID_SIZE * 8); // 160 buckets for 160-bit node IDs
36
-
37
+
37
38
  if (data_directory_.empty()) {
38
39
  data_directory_ = ".";
39
40
  }
40
-
41
+
41
42
  LOG_DHT_INFO("DHT client created with node ID: " << node_id_to_hex(node_id_) <<
43
+ " family: " << (is_ipv6() ? "IPv6" : "IPv4") <<
42
44
  (bind_address_.empty() ? "" : " bind address: " + bind_address_) <<
43
45
  " data directory: " << data_directory_);
44
46
  }
@@ -61,20 +63,22 @@ bool DhtClient::start() {
61
63
  return false;
62
64
  }
63
65
 
64
- socket_ = create_udp_socket(port_, bind_address_);
66
+ // Each family gets its own single-family socket. IPv6 uses V6ONLY so the two
67
+ // sockets can share the same port and never cross-deliver mapped addresses.
68
+ socket_ = create_udp_socket(port_, bind_address_, address_family_);
65
69
  // Fallback to free port
66
70
  if (!is_valid_socket(socket_) && port_ > 0) {
67
71
  // Requested port is not available, try ephemeral port as fallback
68
72
  int original_port = port_;
69
73
  LOG_DHT_WARN("UDP port " << original_port << " is not available, falling back to ephemeral port");
70
- socket_ = create_udp_socket(0, bind_address_);
74
+ socket_ = create_udp_socket(0, bind_address_, address_family_);
71
75
  if (is_valid_socket(socket_)) {
72
76
  port_ = get_bound_port(socket_);
73
77
  LOG_DHT_INFO("Fell back from port " << original_port << " to ephemeral port " << port_);
74
78
  }
75
79
  }
76
80
  if (!is_valid_socket(socket_)) {
77
- LOG_DHT_ERROR("Failed to create dual-stack UDP socket on port " << port_);
81
+ LOG_DHT_ERROR("Failed to create " << (is_ipv6() ? "IPv6" : "IPv4") << " UDP socket on port " << port_);
78
82
  return false;
79
83
  }
80
84
 
@@ -378,10 +382,14 @@ size_t DhtClient::get_active_announces_count() const {
378
382
  }
379
383
 
380
384
  std::vector<Peer> DhtClient::get_default_bootstrap_nodes() {
385
+ // Hostnames are resolved to the instance's family at send time (A for IPv4, AAAA for
386
+ // IPv6). dht.libtorrent.org publishes an AAAA record, giving the IPv6 network a
387
+ // reliable bootstrap entry point.
381
388
  return {
382
389
  {"router.bittorrent.com", 6881},
383
390
  {"dht.transmissionbt.com", 6881},
384
391
  {"router.utorrent.com", 6881},
392
+ {"dht.libtorrent.org", 25401},
385
393
  {"dht.aelitis.com", 6881}
386
394
  };
387
395
  }
@@ -509,6 +517,14 @@ void DhtClient::handle_message(const std::vector<uint8_t>& data, const Peer& sen
509
517
  }
510
518
 
511
519
  void DhtClient::add_node(const DhtNode& node, bool confirmed, bool no_verify) {
520
+ // IPv4 and IPv6 are separate Kademlia networks (BEP 32). Reject any node whose
521
+ // address family does not match this instance to keep the routing table clean.
522
+ if (network_utils::is_valid_ipv6(node.peer.ip) != is_ipv6()) {
523
+ LOG_DHT_DEBUG("Ignoring " << (is_ipv6() ? "non-IPv6" : "non-IPv4") << " node "
524
+ << node.peer.ip << ":" << node.peer.port << " (wrong family)");
525
+ return;
526
+ }
527
+
512
528
  std::lock_guard<std::mutex> ping_lock(pending_pings_mutex_);
513
529
  std::lock_guard<std::mutex> lock(routing_table_mutex_);
514
530
 
@@ -980,7 +996,14 @@ void DhtClient::handle_krpc_announce_peer(const KrpcMessage& message, const Peer
980
996
 
981
997
  void DhtClient::handle_krpc_response(const KrpcMessage& message, const Peer& sender) {
982
998
  LOG_DHT_DEBUG("Handling KRPC response from " << sender.ip << ":" << sender.port);
983
-
999
+
1000
+ // BEP 42: nodes echo our external address back in the top-level "ip" field. Responses
1001
+ // here are always replies to queries we sent, so the source is a node we chose to contact;
1002
+ // we still require a consensus of distinct responders before regenerating our node ID.
1003
+ if (!message.external_ip.empty()) {
1004
+ maybe_update_external_ip(message.external_ip, sender);
1005
+ }
1006
+
984
1007
  // Check if this is a ping verification response before normal processing
985
1008
  handle_ping_verification_response(message.transaction_id, message.response_id, sender);
986
1009
 
@@ -1077,14 +1100,25 @@ void DhtClient::handle_krpc_error(const KrpcMessage& message, const Peer& sender
1077
1100
 
1078
1101
  // KRPC sending functions
1079
1102
  bool DhtClient::send_krpc_message(const KrpcMessage& message, const Peer& peer) {
1080
- auto data = KrpcProtocol::encode_message(message);
1103
+ // BEP 42: stamp the requester's external address into every response we send so the
1104
+ // remote node can derive an IP-based node ID. Only copy the message when needed.
1105
+ const KrpcMessage* out = &message;
1106
+ KrpcMessage stamped;
1107
+ if (message.type == KrpcMessageType::Response && message.external_ip.empty()) {
1108
+ stamped = message;
1109
+ stamped.external_ip = peer.ip;
1110
+ stamped.external_port = peer.port;
1111
+ out = &stamped;
1112
+ }
1113
+
1114
+ auto data = KrpcProtocol::encode_message(*out);
1081
1115
  if (data.empty()) {
1082
1116
  LOG_DHT_ERROR("Failed to encode KRPC message");
1083
1117
  return false;
1084
1118
  }
1085
1119
 
1086
1120
  LOG_DHT_DEBUG("Sending KRPC message (" << data.size() << " bytes) to " << peer.ip << ":" << peer.port);
1087
- int result = send_udp_data(socket_, data, peer.ip, peer.port);
1121
+ int result = send_udp_data(socket_, data, peer.ip, peer.port, address_family_);
1088
1122
 
1089
1123
  if (result > 0) {
1090
1124
  LOG_DHT_DEBUG("Successfully sent KRPC message to " << peer.ip << ":" << peer.port);
@@ -1104,12 +1138,16 @@ void DhtClient::send_krpc_ping(const Peer& peer) {
1104
1138
  void DhtClient::send_krpc_find_node(const Peer& peer, const NodeId& target) {
1105
1139
  std::string transaction_id = KrpcProtocol::generate_transaction_id();
1106
1140
  auto message = KrpcProtocol::create_find_node_query(transaction_id, node_id_, target);
1141
+ // BEP 32: ask only for nodes of our own family (separate Kademlia networks)
1142
+ message.want.push_back(is_ipv6() ? "n6" : "n4");
1107
1143
  send_krpc_message(message, peer);
1108
1144
  }
1109
1145
 
1110
1146
  void DhtClient::send_krpc_get_peers(const Peer& peer, const InfoHash& info_hash) {
1111
1147
  std::string transaction_id = KrpcProtocol::generate_transaction_id();
1112
1148
  auto message = KrpcProtocol::create_get_peers_query(transaction_id, node_id_, info_hash);
1149
+ // BEP 32: ask only for nodes of our own family (separate Kademlia networks)
1150
+ message.want.push_back(is_ipv6() ? "n6" : "n4");
1113
1151
  send_krpc_message(message, peer);
1114
1152
  }
1115
1153
 
@@ -1152,14 +1190,218 @@ NodeId DhtClient::generate_node_id() {
1152
1190
  std::random_device rd;
1153
1191
  std::mt19937 gen(rd());
1154
1192
  std::uniform_int_distribution<> dis(0, 255);
1155
-
1193
+
1156
1194
  for (size_t i = 0; i < NODE_ID_SIZE; ++i) {
1157
1195
  id[i] = dis(gen);
1158
1196
  }
1159
-
1197
+
1160
1198
  return id;
1161
1199
  }
1162
1200
 
1201
+ // ============================================================================
1202
+ // BEP 42: deriving the node ID from the external IP address
1203
+ // ============================================================================
1204
+ namespace {
1205
+
1206
+ // CRC-32C (Castagnoli) — the polynomial mandated by BEP 42. Standard parameters:
1207
+ // reflected, init 0xFFFFFFFF, final XOR 0xFFFFFFFF. Bytes are processed in array order,
1208
+ // matching the BEP 42 reference implementation and its published test vectors.
1209
+ uint32_t crc32c(const uint8_t* data, size_t len) {
1210
+ uint32_t crc = 0xFFFFFFFFu;
1211
+ for (size_t i = 0; i < len; ++i) {
1212
+ crc ^= data[i];
1213
+ for (int k = 0; k < 8; ++k) {
1214
+ crc = (crc & 1u) ? (crc >> 1) ^ 0x82F63B78u : (crc >> 1);
1215
+ }
1216
+ }
1217
+ return crc ^ 0xFFFFFFFFu;
1218
+ }
1219
+
1220
+ // Mask the leading octets of an IP per BEP 42 (4 octets for IPv4, 8 for IPv6).
1221
+ // Returns the number of octets written to `out`, or 0 on a parse failure.
1222
+ int masked_octets(const std::string& ip, uint8_t out[8]) {
1223
+ static const uint8_t v4_mask[4] = { 0x03, 0x0f, 0x3f, 0xff };
1224
+ static const uint8_t v6_mask[8] = { 0x01, 0x03, 0x07, 0x0f, 0x1f, 0x3f, 0x7f, 0xff };
1225
+
1226
+ if (network_utils::is_valid_ipv6(ip)) {
1227
+ struct in6_addr addr6;
1228
+ if (inet_pton(AF_INET6, ip.c_str(), &addr6) != 1) return 0;
1229
+ for (int i = 0; i < 8; ++i) out[i] = addr6.s6_addr[i] & v6_mask[i];
1230
+ return 8;
1231
+ }
1232
+
1233
+ struct in_addr addr;
1234
+ if (inet_pton(AF_INET, ip.c_str(), &addr) != 1) return 0;
1235
+ uint32_t h = ntohl(addr.s_addr);
1236
+ uint8_t ipb[4] = {
1237
+ static_cast<uint8_t>((h >> 24) & 0xff), static_cast<uint8_t>((h >> 16) & 0xff),
1238
+ static_cast<uint8_t>((h >> 8) & 0xff), static_cast<uint8_t>(h & 0xff)
1239
+ };
1240
+ for (int i = 0; i < 4; ++i) out[i] = ipb[i] & v4_mask[i];
1241
+ return 4;
1242
+ }
1243
+
1244
+ // Compute the 3 deterministic prefix bytes of a BEP 42 node ID for (ip, seed).
1245
+ // Only the high 5 bits of prefix[2] are deterministic (the low 3 bits are random in a real ID).
1246
+ bool bep42_prefix(const std::string& ip, uint8_t seed, uint8_t prefix[3]) {
1247
+ uint8_t octets[8];
1248
+ int num = masked_octets(ip, octets);
1249
+ if (num == 0) return false;
1250
+ octets[0] |= static_cast<uint8_t>((seed & 0x7) << 5);
1251
+ uint32_t c = crc32c(octets, static_cast<size_t>(num));
1252
+ prefix[0] = static_cast<uint8_t>((c >> 24) & 0xff);
1253
+ prefix[1] = static_cast<uint8_t>((c >> 16) & 0xff);
1254
+ prefix[2] = static_cast<uint8_t>((c >> 8) & 0xf8);
1255
+ return true;
1256
+ }
1257
+
1258
+ } // namespace
1259
+
1260
+ bool DhtClient::is_public_address(const std::string& ip) {
1261
+ if (ip.empty()) return false;
1262
+
1263
+ if (network_utils::is_valid_ipv6(ip)) {
1264
+ struct in6_addr a;
1265
+ if (inet_pton(AF_INET6, ip.c_str(), &a) != 1) return false;
1266
+ const uint8_t* b = a.s6_addr;
1267
+ bool all_zero = true;
1268
+ for (int i = 0; i < 16; ++i) { if (b[i]) { all_zero = false; break; } }
1269
+ if (all_zero) return false; // :: (unspecified)
1270
+ bool loopback = (b[15] == 1);
1271
+ for (int i = 0; i < 15; ++i) { if (b[i]) { loopback = false; break; } }
1272
+ if (loopback) return false; // ::1
1273
+ if ((b[0] & 0xfe) == 0xfc) return false; // fc00::/7 unique local
1274
+ if (b[0] == 0xfe && (b[1] & 0xc0) == 0x80) return false; // fe80::/10 link-local
1275
+ if (b[0] == 0xff) return false; // ff00::/8 multicast
1276
+ return true;
1277
+ }
1278
+
1279
+ struct in_addr a;
1280
+ if (inet_pton(AF_INET, ip.c_str(), &a) != 1) return false;
1281
+ uint32_t h = ntohl(a.s_addr);
1282
+ uint8_t o1 = static_cast<uint8_t>((h >> 24) & 0xff);
1283
+ uint8_t o2 = static_cast<uint8_t>((h >> 16) & 0xff);
1284
+ if (o1 == 0) return false; // 0.0.0.0/8
1285
+ if (o1 == 127) return false; // loopback
1286
+ if (o1 == 10) return false; // 10.0.0.0/8
1287
+ if (o1 == 172 && o2 >= 16 && o2 <= 31) return false; // 172.16.0.0/12
1288
+ if (o1 == 192 && o2 == 168) return false; // 192.168.0.0/16
1289
+ if (o1 == 169 && o2 == 254) return false; // 169.254.0.0/16 link-local
1290
+ if (o1 == 100 && o2 >= 64 && o2 <= 127) return false; // 100.64.0.0/10 CGNAT
1291
+ if (o1 >= 224) return false; // multicast / reserved
1292
+ return true;
1293
+ }
1294
+
1295
+ bool DhtClient::generate_node_id_from_ip(const std::string& ip, NodeId& out, std::mt19937& gen) {
1296
+ uint8_t prefix[3];
1297
+ std::uniform_int_distribution<int> dis(0, 255);
1298
+ uint8_t seed = static_cast<uint8_t>(dis(gen));
1299
+ if (!bep42_prefix(ip, seed, prefix)) return false;
1300
+
1301
+ out[0] = prefix[0];
1302
+ out[1] = prefix[1];
1303
+ out[2] = static_cast<uint8_t>(prefix[2] | (dis(gen) & 0x7)); // low 3 bits are random
1304
+ for (int i = 3; i < 19; ++i) out[i] = static_cast<uint8_t>(dis(gen));
1305
+ out[19] = seed;
1306
+ return true;
1307
+ }
1308
+
1309
+ bool DhtClient::verify_node_id_for_ip(const NodeId& id, const std::string& ip) {
1310
+ // Local/private addresses cannot be verified (their IDs would be "wrong" anyway).
1311
+ if (!is_public_address(ip)) return true;
1312
+ uint8_t prefix[3];
1313
+ if (!bep42_prefix(ip, id[19], prefix)) return true; // unparseable -> don't reject
1314
+ return id[0] == prefix[0]
1315
+ && id[1] == prefix[1]
1316
+ && (id[2] & 0xf8) == prefix[2];
1317
+ }
1318
+
1319
+ void DhtClient::rebuild_routing_table_unlocked() {
1320
+ // node_id_ changed; every node's bucket index is now stale. Re-bucket all of them.
1321
+ std::vector<DhtNode> all;
1322
+ for (auto& bucket : routing_table_) {
1323
+ for (auto& node : bucket) all.push_back(std::move(node));
1324
+ bucket.clear();
1325
+ }
1326
+ for (auto& node : all) {
1327
+ int bucket_index = get_bucket_index(node.id);
1328
+ if (bucket_index < 0 || bucket_index >= static_cast<int>(routing_table_.size())) continue;
1329
+ auto& bucket = routing_table_[bucket_index];
1330
+ if (bucket.size() < K_BUCKET_SIZE) bucket.push_back(std::move(node));
1331
+ }
1332
+ }
1333
+
1334
+ void DhtClient::set_external_ip(const std::string& ip) {
1335
+ // Only public addresses of this instance's family can derive a valid node ID.
1336
+ if (!is_public_address(ip)) return;
1337
+ if (network_utils::is_valid_ipv6(ip) != is_ipv6()) return;
1338
+
1339
+ {
1340
+ std::lock_guard<std::mutex> ext_lock(external_ip_mutex_);
1341
+ if (ip == external_address_) return; // already using this address
1342
+ external_address_ = ip;
1343
+ }
1344
+
1345
+ std::lock_guard<std::mutex> lock(routing_table_mutex_);
1346
+ // If our current ID already matches this IP, keep it (avoids needless churn).
1347
+ if (verify_node_id_for_ip(node_id_, ip)) {
1348
+ LOG_DHT_DEBUG("External IP " << ip << " already matches current node ID (BEP 42)");
1349
+ return;
1350
+ }
1351
+
1352
+ std::random_device rd;
1353
+ std::mt19937 gen(rd());
1354
+ NodeId new_id;
1355
+ if (!generate_node_id_from_ip(ip, new_id, gen)) return;
1356
+
1357
+ NodeId old_id = node_id_;
1358
+ node_id_ = new_id;
1359
+ rebuild_routing_table_unlocked();
1360
+ LOG_DHT_INFO("Regenerated DHT node ID from external IP " << ip << " (BEP 42): "
1361
+ << node_id_to_hex(old_id) << " -> " << node_id_to_hex(new_id));
1362
+ }
1363
+
1364
+ std::string DhtClient::get_external_address() const {
1365
+ std::lock_guard<std::mutex> lock(external_ip_mutex_);
1366
+ return external_address_;
1367
+ }
1368
+
1369
+ void DhtClient::maybe_update_external_ip(const std::string& reported_ip, const Peer& responder) {
1370
+ // Only consider public addresses of our own family.
1371
+ if (!is_public_address(reported_ip)) return;
1372
+ if (network_utils::is_valid_ipv6(reported_ip) != is_ipv6()) return;
1373
+
1374
+ std::string winner;
1375
+ {
1376
+ std::lock_guard<std::mutex> lock(external_ip_mutex_);
1377
+ if (reported_ip == external_address_) return; // already adopted
1378
+
1379
+ // One vote per distinct responder so a single peer cannot force a change.
1380
+ if (external_ip_voters_.size() >= MAX_EXTERNAL_IP_VOTERS) {
1381
+ external_ip_voters_.clear();
1382
+ external_ip_votes_.clear();
1383
+ }
1384
+ if (!external_ip_voters_.insert(responder.ip).second) return; // this responder already voted
1385
+
1386
+ int votes = ++external_ip_votes_[reported_ip];
1387
+ // A vote for an address other than our current one is genuinely interesting
1388
+ // (startup ramp-up, or a possible address change), so surface it at INFO.
1389
+ LOG_DHT_INFO("External IP vote from " << responder.ip << " -> " << reported_ip
1390
+ << " (" << votes << "/" << EXTERNAL_IP_VOTE_THRESHOLD << ")");
1391
+ if (votes >= EXTERNAL_IP_VOTE_THRESHOLD) {
1392
+ winner = reported_ip;
1393
+ external_ip_votes_.clear();
1394
+ external_ip_voters_.clear();
1395
+ }
1396
+ }
1397
+
1398
+ if (!winner.empty()) {
1399
+ LOG_DHT_INFO("External IP consensus reached: " << winner
1400
+ << " (>= " << EXTERNAL_IP_VOTE_THRESHOLD << " responders agree)");
1401
+ set_external_ip(winner);
1402
+ }
1403
+ }
1404
+
1163
1405
  NodeId DhtClient::xor_distance(const NodeId& a, const NodeId& b) {
1164
1406
  NodeId result;
1165
1407
  for (size_t i = 0; i < NODE_ID_SIZE; ++i) {
@@ -2243,8 +2485,10 @@ bool DhtClient::add_search_requests(PendingSearch& search, DeferredCallbacks& de
2243
2485
  LOG_DHT_DEBUG("Querying node " << node_id_to_hex(node.id) << " at " << node.peer.ip << ":" << node.peer.port);
2244
2486
 
2245
2487
  auto message = KrpcProtocol::create_get_peers_query(transaction_id, node_id_, search.info_hash);
2488
+ // BEP 32: request peers/nodes of our own family only
2489
+ message.want.push_back(is_ipv6() ? "n6" : "n4");
2246
2490
  send_krpc_message(message, node.peer);
2247
-
2491
+
2248
2492
  queries_sent++;
2249
2493
  }
2250
2494
 
@@ -2574,12 +2818,29 @@ std::string node_id_to_hex(const NodeId& id) {
2574
2818
  }
2575
2819
 
2576
2820
  // Routing table persistence implementation
2821
+ std::string DhtClient::routing_table_file_path() const {
2822
+ // IPv6 instance uses a distinct filename suffix so it doesn't collide with the
2823
+ // IPv4 instance bound to the same port.
2824
+ const char* suffix = is_ipv6() ? "_v6" : "";
2825
+ #ifdef TESTING
2826
+ if (port_ == 0) {
2827
+ std::ostringstream oss;
2828
+ oss << "dht_routing_" << this << suffix << ".json";
2829
+ return oss.str();
2830
+ }
2831
+ return "dht_routing_" + std::to_string(port_) + suffix + ".json";
2832
+ #else
2833
+ return data_directory_ + "/dht_routing_" + std::to_string(port_) + suffix + ".json";
2834
+ #endif
2835
+ }
2836
+
2577
2837
  bool DhtClient::save_routing_table() {
2578
2838
  std::lock_guard<std::mutex> lock(routing_table_mutex_);
2579
-
2839
+
2580
2840
  try {
2581
2841
  nlohmann::json routing_data;
2582
2842
  routing_data["version"] = 1;
2843
+ routing_data["family"] = is_ipv6() ? "ipv6" : "ipv4";
2583
2844
  routing_data["node_id"] = node_id_to_hex(node_id_);
2584
2845
  routing_data["saved_at"] = std::chrono::system_clock::now().time_since_epoch().count();
2585
2846
 
@@ -2609,21 +2870,10 @@ bool DhtClient::save_routing_table() {
2609
2870
 
2610
2871
  routing_data["nodes"] = nodes_array;
2611
2872
  routing_data["count"] = saved_count;
2612
-
2873
+
2613
2874
  // Determine file path
2614
- std::string file_path;
2615
- #ifdef TESTING
2616
- if (port_ == 0) {
2617
- std::ostringstream oss;
2618
- oss << "dht_routing_" << this << ".json";
2619
- file_path = oss.str();
2620
- } else {
2621
- file_path = "dht_routing_" + std::to_string(port_) + ".json";
2622
- }
2623
- #else
2624
- file_path = data_directory_ + "/dht_routing_" + std::to_string(port_) + ".json";
2625
- #endif
2626
-
2875
+ std::string file_path = routing_table_file_path();
2876
+
2627
2877
  // Write to file
2628
2878
  std::ofstream file(file_path);
2629
2879
  if (!file.is_open()) {
@@ -2648,19 +2898,8 @@ bool DhtClient::load_routing_table() {
2648
2898
 
2649
2899
  try {
2650
2900
  // Determine file path
2651
- std::string file_path;
2652
- #ifdef TESTING
2653
- if (port_ == 0) {
2654
- std::ostringstream oss;
2655
- oss << "dht_routing_" << this << ".json";
2656
- file_path = oss.str();
2657
- } else {
2658
- file_path = "dht_routing_" + std::to_string(port_) + ".json";
2659
- }
2660
- #else
2661
- file_path = data_directory_ + "/dht_routing_" + std::to_string(port_) + ".json";
2662
- #endif
2663
-
2901
+ std::string file_path = routing_table_file_path();
2902
+
2664
2903
  // Check if file exists
2665
2904
  std::ifstream file(file_path);
2666
2905
  if (!file.is_open()) {
@@ -2694,7 +2933,12 @@ bool DhtClient::load_routing_table() {
2694
2933
  std::string node_id_hex = node_data["id"];
2695
2934
  std::string ip = node_data["ip"];
2696
2935
  int port = node_data["port"];
2697
-
2936
+
2937
+ // Skip nodes that don't belong to this instance's family
2938
+ if (network_utils::is_valid_ipv6(ip) != is_ipv6()) {
2939
+ continue;
2940
+ }
2941
+
2698
2942
  NodeId node_id = hex_to_node_id(node_id_hex);
2699
2943
  Peer peer(ip, port);
2700
2944
  DhtNode node(node_id, peer);
@@ -3049,11 +3293,17 @@ void DhtClient::spider_walk() {
3049
3293
  pool_size = spider_nodes_.size();
3050
3294
  }
3051
3295
 
3052
- // If still empty, bootstrap
3296
+ // If still empty, bootstrap (rate-limited to once per 30 seconds)
3053
3297
  if (pool_size == 0) {
3054
- LOG_DHT_DEBUG("Spider walk: routing table empty too, re-bootstrapping");
3055
- for (const auto& bootstrap : get_default_bootstrap_nodes()) {
3056
- send_krpc_find_node(bootstrap, node_id_);
3298
+ auto now = std::chrono::steady_clock::now();
3299
+ if (now - last_spider_bootstrap_ >= std::chrono::seconds(30)) {
3300
+ last_spider_bootstrap_ = now;
3301
+ LOG_DHT_DEBUG("Spider walk: routing table empty too, re-bootstrapping");
3302
+ for (const auto& bootstrap : get_default_bootstrap_nodes()) {
3303
+ send_krpc_find_node(bootstrap, node_id_);
3304
+ }
3305
+ } else {
3306
+ LOG_DHT_DEBUG("Spider walk: skipping re-bootstrap (rate limited, last was < 30s ago)");
3057
3307
  }
3058
3308
  }
3059
3309
  }