@decentnetwork/peer 0.1.40 → 0.1.41

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.
@@ -0,0 +1,25 @@
1
+ import type { KeyPair } from "../crypto/keypair.js";
2
+ export declare const NET_PACKET_PING_REQUEST = 0;
3
+ export declare const NET_PACKET_PING_RESPONSE = 1;
4
+ export declare const NET_PACKET_GET_NODES = 2;
5
+ export declare const NET_PACKET_SEND_NODES = 4;
6
+ export type DhtRpcPacket = {
7
+ type: number;
8
+ senderPublicKey: Uint8Array;
9
+ plain: Uint8Array;
10
+ };
11
+ export declare function encodeDhtRpc(type: number, sender: KeyPair, recipientPublicKey: Uint8Array, plain: Uint8Array): Uint8Array;
12
+ export declare function decodeDhtRpc(packet: Uint8Array, ourSecretKey: Uint8Array): DhtRpcPacket | undefined;
13
+ export declare function buildPingPlain(type: number, pingId: Uint8Array): Uint8Array;
14
+ export declare function parsePingPlain(plain: Uint8Array): {
15
+ innerType: number;
16
+ pingId: Uint8Array;
17
+ } | undefined;
18
+ export declare function buildGetNodesPlain(targetPublicKey: Uint8Array, pingId: Uint8Array): Uint8Array;
19
+ export declare function parseGetNodesPlain(plain: Uint8Array): {
20
+ target: Uint8Array;
21
+ pingId: Uint8Array;
22
+ } | undefined;
23
+ export declare function buildSendNodesPlain(packedNodes: Uint8Array, count: number, pingId: Uint8Array): Uint8Array;
24
+ export declare function parseSendNodesNodeBytes(plain: Uint8Array): Uint8Array | undefined;
25
+ export declare function packUdpNodeV4(host: string, port: number, pk: Uint8Array): Uint8Array | undefined;
@@ -0,0 +1,100 @@
1
+ // Classic toxcore DHT request/response packets (DHT.c): ping and get_nodes.
2
+ //
3
+ // These are what make a node *findable* and *discoverable* over UDP — the
4
+ // mechanism native C++/iOS/Android Carrier clients use to locate a friend's
5
+ // live UDP ip:port and net_crypto to it directly (fast), instead of falling
6
+ // back to a TCP relay (slow). Without answering these, a JS peer is invisible
7
+ // in the DHT: a native client's get_nodes search for our key finds nothing,
8
+ // so it can never punch UDP to us. See peer.ts #handleDhtRpc / #doDhtMaintenance.
9
+ //
10
+ // Wire format (dht_create_packet): no recipient key on the wire (unlike the
11
+ // 0x20 CRYPTO_PACKET) — the recipient identifies us by the sender key field.
12
+ // [type(1)] [sender DHT public key(32)] [nonce(24)] [crypto_box(plain)+MAC]
13
+ // Encrypted to the recipient's DHT pk with our DHT sk.
14
+ import nacl from "tweetnacl";
15
+ import { concatBytes, randomBytes } from "../utils/bytes.js";
16
+ export const NET_PACKET_PING_REQUEST = 0x00;
17
+ export const NET_PACKET_PING_RESPONSE = 0x01;
18
+ export const NET_PACKET_GET_NODES = 0x02;
19
+ export const NET_PACKET_SEND_NODES = 0x04;
20
+ const KEY_SIZE = 32;
21
+ const NONCE_SIZE = 24;
22
+ const MAC_SIZE = 16;
23
+ function isDhtRpcType(t) {
24
+ return (t === NET_PACKET_PING_REQUEST ||
25
+ t === NET_PACKET_PING_RESPONSE ||
26
+ t === NET_PACKET_GET_NODES ||
27
+ t === NET_PACKET_SEND_NODES);
28
+ }
29
+ export function encodeDhtRpc(type, sender, recipientPublicKey, plain) {
30
+ const nonce = randomBytes(NONCE_SIZE);
31
+ const encrypted = nacl.box(plain, nonce, recipientPublicKey, sender.secretKey);
32
+ return concatBytes([Uint8Array.of(type), sender.publicKey, nonce, encrypted]);
33
+ }
34
+ export function decodeDhtRpc(packet, ourSecretKey) {
35
+ if (packet.length < 1 + KEY_SIZE + NONCE_SIZE + MAC_SIZE)
36
+ return undefined;
37
+ const type = packet[0];
38
+ if (!isDhtRpcType(type))
39
+ return undefined;
40
+ const senderPublicKey = packet.slice(1, 1 + KEY_SIZE);
41
+ const nonce = packet.slice(1 + KEY_SIZE, 1 + KEY_SIZE + NONCE_SIZE);
42
+ const encrypted = packet.slice(1 + KEY_SIZE + NONCE_SIZE);
43
+ const plain = nacl.box.open(encrypted, nonce, senderPublicKey, ourSecretKey);
44
+ if (!plain)
45
+ return undefined;
46
+ return { type, senderPublicKey, plain };
47
+ }
48
+ // ---- plaintext payload builders/parsers ----
49
+ // ping req/resp plain: [type(1)][ping_id(8)]; the type byte is echoed inside
50
+ // (toxcore uses it as a sanity check). ping_id is opaque, echoed by the peer.
51
+ export function buildPingPlain(type, pingId) {
52
+ return concatBytes([Uint8Array.of(type), pingId.slice(0, 8)]);
53
+ }
54
+ export function parsePingPlain(plain) {
55
+ if (plain.length < 1 + 8)
56
+ return undefined;
57
+ return { innerType: plain[0], pingId: plain.slice(1, 9) };
58
+ }
59
+ // get_nodes plain: [target public key(32)][sendback ping_id(8)].
60
+ export function buildGetNodesPlain(targetPublicKey, pingId) {
61
+ return concatBytes([targetPublicKey.slice(0, 32), pingId.slice(0, 8)]);
62
+ }
63
+ export function parseGetNodesPlain(plain) {
64
+ if (plain.length < 32 + 8)
65
+ return undefined;
66
+ return { target: plain.slice(0, 32), pingId: plain.slice(32, 40) };
67
+ }
68
+ // send_nodes plain: [count(1)][packed nodes...][sendback ping_id(8)].
69
+ // (Nodes are parsed by peer.ts parsePackedNodes; we only split off the count
70
+ // prefix and trailing ping_id here.)
71
+ export function buildSendNodesPlain(packedNodes, count, pingId) {
72
+ return concatBytes([Uint8Array.of(count & 0xff), packedNodes, pingId.slice(0, 8)]);
73
+ }
74
+ export function parseSendNodesNodeBytes(plain) {
75
+ if (plain.length < 1 + 8)
76
+ return undefined;
77
+ const count = plain[0];
78
+ if (count === 0)
79
+ return new Uint8Array(0);
80
+ // node region is everything between the count byte and the trailing 8-byte ping_id
81
+ return plain.slice(1, plain.length - 8);
82
+ }
83
+ // packed UDP IPv4 node (toxcore Node_format): [family=2][ip(4)][port BE(2)][pk(32)] = 39 bytes
84
+ export function packUdpNodeV4(host, port, pk) {
85
+ const parts = host.split(".").map((p) => Number.parseInt(p, 10));
86
+ if (parts.length !== 4 || parts.some((n) => !(n >= 0 && n <= 255)))
87
+ return undefined;
88
+ if (pk.length !== 32 || !(port > 0 && port <= 0xffff))
89
+ return undefined;
90
+ const out = new Uint8Array(1 + 4 + 2 + 32);
91
+ out[0] = 2; // UDP_FAMILY_IPV4
92
+ out[1] = parts[0];
93
+ out[2] = parts[1];
94
+ out[3] = parts[2];
95
+ out[4] = parts[3];
96
+ out[5] = (port >> 8) & 0xff;
97
+ out[6] = port & 0xff;
98
+ out.set(pk, 7);
99
+ return out;
100
+ }
package/dist/peer.js CHANGED
@@ -8,6 +8,7 @@ import { LegacyBootstrapClient } from "./compat/bootstrap.js";
8
8
  import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket } from "./compat/packet.js";
9
9
  import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
10
10
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
11
+ import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
11
12
  import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
12
13
  import { LegacyExpressClient } from "./compat/express.js";
13
14
  import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
@@ -44,6 +45,10 @@ const NODE_BLACKLIST_MAX_TTL_MS = readEnvInt("DECENT_NODE_BLACKLIST_MAX_TTL_MS",
44
45
  const FRIEND_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ANNOUNCE_ATTEMPTS", 1);
45
46
  const JOIN_ANNOUNCE_TIMEOUT_MS = readEnvInt("DECENT_JOIN_ANNOUNCE_TIMEOUT_MS", 12000);
46
47
  const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS", 20000);
48
+ // Classic-DHT maintenance cadence. Every tick we get_nodes toward our own key
49
+ // (so neighbours store our address and native peers can find our UDP endpoint)
50
+ // and toward each not-yet-UDP friend's key (so we find theirs and punch).
51
+ const DHT_MAINTENANCE_INTERVAL_MS = readEnvInt("DECENT_DHT_MAINTENANCE_INTERVAL_MS", 15000);
47
52
  const MAX_SELF_ANNOUNCE_TARGETS = readEnvInt("DECENT_SELF_ANNOUNCE_TARGETS", 16);
48
53
  const SELF_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_SELF_ANNOUNCE_ATTEMPTS", 3);
49
54
  // Self-announce batch size — number of step1 / step2 requests to fan out
@@ -194,6 +199,7 @@ export class Peer {
194
199
  #selfAnnounceTimer;
195
200
  #friendConnectionTimer;
196
201
  #lanDiscoveryTimer;
202
+ #dhtMaintenanceTimer;
197
203
  // Per-friend last DHT-PK send time, keyed by friendId, used even when no
198
204
  // session entry exists yet so the connection loop does not flood DHT-PK
199
205
  // requests when route discovery keeps failing.
@@ -437,6 +443,10 @@ export class Peer {
437
443
  clearInterval(this.#selfAnnounceTimer);
438
444
  this.#selfAnnounceTimer = undefined;
439
445
  }
446
+ if (this.#dhtMaintenanceTimer) {
447
+ clearInterval(this.#dhtMaintenanceTimer);
448
+ this.#dhtMaintenanceTimer = undefined;
449
+ }
440
450
  if (this.#friendConnectionTimer) {
441
451
  clearInterval(this.#friendConnectionTimer);
442
452
  this.#friendConnectionTimer = undefined;
@@ -488,6 +498,7 @@ export class Peer {
488
498
  this.#knownNodes = dedupeNodes([result.respondingNode, ...discovered, ...this.#opts.bootstrapNodes]);
489
499
  await this.#runSelfAnnounce(false, Date.now() + JOIN_ANNOUNCE_TIMEOUT_MS);
490
500
  this.#ensureSelfAnnounceLoop();
501
+ this.#ensureDhtMaintenanceLoop();
491
502
  this.#ensureFriendConnectionLoop();
492
503
  this.#ensureExpressPullLoop();
493
504
  // Kick off an immediate friend connection cycle so persisted friends with
@@ -1066,6 +1077,19 @@ export class Peer {
1066
1077
  catch { /* best-effort */ }
1067
1078
  }).catch(() => { });
1068
1079
  }
1080
+ // Classic toxcore DHT RPC (ping / get_nodes / send_nodes). Answering these
1081
+ // is what makes us *findable* over UDP: a native iOS/Android peer searches
1082
+ // the DHT for our key, a neighbour that holds our address returns it, then
1083
+ // the native peer pings/get_nodes us directly and net_cryptos over UDP.
1084
+ // DHT is UDP-only, so ignore anything arriving via a TCP relay.
1085
+ if ((packet[0] === NET_PACKET_PING_REQUEST ||
1086
+ packet[0] === NET_PACKET_PING_RESPONSE ||
1087
+ packet[0] === NET_PACKET_GET_NODES ||
1088
+ packet[0] === NET_PACKET_SEND_NODES) &&
1089
+ !this.#remoteIsTcp(remote)) {
1090
+ this.#handleDhtRpc(packet, remote);
1091
+ return;
1092
+ }
1069
1093
  if (packet[0] === NET_PACKET_CRYPTO) {
1070
1094
  const opened = openToxDhtCryptoRequest(packet, this.#keyPair);
1071
1095
  if (!opened) {
@@ -2700,8 +2724,15 @@ export class Peer {
2700
2724
  // id (decentlan's IP channel = 163) via bulkDataPacketId so its
2701
2725
  // high-volume traffic gets the same single-transport routing instead of
2702
2726
  // fanning out over UDP + relay + TCP relay (which delivers 3-4 duplicates).
2703
- const isBulkData = kind === PACKET_ID_MESSAGE ||
2704
- (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId);
2727
+ // Chat (PACKET_ID_MESSAGE = 64) is LOW-volume and must be RELIABLE, so it
2728
+ // is NOT bulk: it dual-sends over UDP + relay and net_crypto retransmits,
2729
+ // so a reply still lands when the peer's UDP path is briefly stale (iOS
2730
+ // app suspend / NAT remap) instead of being fired into a dead endpoint and
2731
+ // silently lost. Only the app-declared high-volume channel (bulkDataPacketId,
2732
+ // e.g. decentlan IP=163) stays bulk / single-transport to avoid the relay
2733
+ // backlog + duplicate fan-out that high packet rates cause. Chat is sparse
2734
+ // and net_crypto dedups by packet number, so dual-send is safe here.
2735
+ const isBulkData = this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId;
2705
2736
  await this.#sendToFriend(friendId, encrypted, session, isBulkData);
2706
2737
  }
2707
2738
  /**
@@ -3544,6 +3575,193 @@ export class Peer {
3544
3575
  this.#debugLog(`lan_sweep friend=${friendId} probes=${probes}`);
3545
3576
  }
3546
3577
  }
3578
+ // ===================== classic toxcore DHT (ping / get_nodes) ==========
3579
+ // Answering and issuing these makes a JS peer a real DHT node — the thing
3580
+ // native C++/iOS/Android Carrier clients rely on to find a friend's live UDP
3581
+ // ip:port and net_crypto to it directly. Previously peer.js was a DHT *client*
3582
+ // only (bootstrap get_nodes), so it was invisible: native peers couldn't find
3583
+ // our UDP endpoint and every session fell back to the slow TCP relay.
3584
+ #dhtPingId() {
3585
+ return randomBytes(8);
3586
+ }
3587
+ // The known nodes closest (XOR distance) to `target`, UDP-only, with a valid
3588
+ // 32-byte key — the candidates a get_nodes lookup should converge through.
3589
+ #closestKnownNodes(target, limit) {
3590
+ const withPk = [];
3591
+ for (const node of this.#knownNodes) {
3592
+ if (!node.pk || node.isTcp)
3593
+ continue;
3594
+ try {
3595
+ const pk = base58ToBytes(node.pk);
3596
+ if (pk.length === 32)
3597
+ withPk.push({ node, pk });
3598
+ }
3599
+ catch { /* skip malformed */ }
3600
+ }
3601
+ withPk.sort((a, b) => xorCloser(target, a.pk, b.pk));
3602
+ return withPk.slice(0, limit).map((e) => e.node);
3603
+ }
3604
+ #friendByDhtPk(dhtPkBase58) {
3605
+ for (const [friendId, session] of this.#friendSessions.entries()) {
3606
+ const dpk = session.friendDhtPublicKey;
3607
+ if (dpk && carrierIdFromPublicKey(dpk) === dhtPkBase58) {
3608
+ return { friendId, realPk: session.friendRealPublicKey ?? dpk };
3609
+ }
3610
+ }
3611
+ return undefined;
3612
+ }
3613
+ #handleDhtRpc(packet, remote) {
3614
+ if (!this.#keyPair)
3615
+ return;
3616
+ const decoded = decodeDhtRpc(packet, this.#keyPair.secretKey);
3617
+ if (!decoded)
3618
+ return;
3619
+ const senderId = carrierIdFromPublicKey(decoded.senderPublicKey);
3620
+ // Learn the sender's real reachable UDP address: populates our table and
3621
+ // keeps mutual reachability alive across the mesh.
3622
+ if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
3623
+ this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
3624
+ }
3625
+ if (decoded.type === NET_PACKET_PING_REQUEST) {
3626
+ const ping = parsePingPlain(decoded.plain);
3627
+ if (!ping)
3628
+ return;
3629
+ // Echo ping_id so the peer marks us alive and keeps us in its table
3630
+ // (= keeps us findable through it).
3631
+ const plain = buildPingPlain(NET_PACKET_PING_RESPONSE, ping.pingId);
3632
+ const resp = encodeDhtRpc(NET_PACKET_PING_RESPONSE, this.#keyPair, decoded.senderPublicKey, plain);
3633
+ void this.#sendPacket(resp, { host: remote.address, port: remote.port }).catch(() => undefined);
3634
+ return;
3635
+ }
3636
+ if (decoded.type === NET_PACKET_PING_RESPONSE) {
3637
+ return; // liveness already recorded via addKnownNodes above
3638
+ }
3639
+ if (decoded.type === NET_PACKET_GET_NODES) {
3640
+ const gn = parseGetNodesPlain(decoded.plain);
3641
+ if (!gn)
3642
+ return;
3643
+ // Reply with up to 4 known nodes closest to the searched key. When a
3644
+ // native peer searches for OUR key, replying from our socket reveals our
3645
+ // address to them directly, and the hint lets their lookup converge.
3646
+ const packedParts = [];
3647
+ let count = 0;
3648
+ for (const node of this.#closestKnownNodes(gn.target, 4)) {
3649
+ if (!node.pk)
3650
+ continue;
3651
+ let pk;
3652
+ try {
3653
+ pk = base58ToBytes(node.pk);
3654
+ }
3655
+ catch {
3656
+ continue;
3657
+ }
3658
+ const packed = packUdpNodeV4(node.host, node.port, pk);
3659
+ if (packed) {
3660
+ packedParts.push(packed);
3661
+ count++;
3662
+ }
3663
+ }
3664
+ const nodesBytes = packedParts.length ? concatBytes(packedParts) : new Uint8Array(0);
3665
+ const plain = buildSendNodesPlain(nodesBytes, count, gn.pingId);
3666
+ const resp = encodeDhtRpc(NET_PACKET_SEND_NODES, this.#keyPair, decoded.senderPublicKey, plain);
3667
+ void this.#sendPacket(resp, { host: remote.address, port: remote.port }).catch(() => undefined);
3668
+ return;
3669
+ }
3670
+ if (decoded.type === NET_PACKET_SEND_NODES) {
3671
+ const nodeBytes = parseSendNodesNodeBytes(decoded.plain);
3672
+ if (!nodeBytes || nodeBytes.length === 0)
3673
+ return;
3674
+ const nodes = parsePackedNodes(nodeBytes).filter((n) => !n.isTcp);
3675
+ if (nodes.length)
3676
+ this.addKnownNodes(nodes);
3677
+ // If a returned node IS one of our friends' DHT keys, we just learned
3678
+ // their live UDP endpoint — cache it and kick a UDP cookie to punch.
3679
+ for (const node of nodes) {
3680
+ if (!node.pk)
3681
+ continue;
3682
+ const match = this.#friendByDhtPk(node.pk);
3683
+ if (!match)
3684
+ continue;
3685
+ try {
3686
+ this.#cacheFriendRemote(match.friendId, node.host, node.port, match.realPk, base58ToBytes(node.pk));
3687
+ this.#debugLog(`dht_search found UDP endpoint for ${match.friendId} at ${node.host}:${node.port}`);
3688
+ void this.#initiateSession(match.friendId).catch(() => undefined);
3689
+ }
3690
+ catch { /* malformed key */ }
3691
+ }
3692
+ return;
3693
+ }
3694
+ }
3695
+ async #sendDhtGetNodes(node, targetPublicKey) {
3696
+ if (!this.#keyPair || !node.pk || node.isTcp)
3697
+ return;
3698
+ let recipientPk;
3699
+ try {
3700
+ recipientPk = base58ToBytes(node.pk);
3701
+ }
3702
+ catch {
3703
+ return;
3704
+ }
3705
+ if (recipientPk.length !== 32)
3706
+ return;
3707
+ const plain = buildGetNodesPlain(targetPublicKey, this.#dhtPingId());
3708
+ const pkt = encodeDhtRpc(NET_PACKET_GET_NODES, this.#keyPair, recipientPk, plain);
3709
+ await this.#sendPacket(pkt, { host: node.host, port: node.port }).catch(() => undefined);
3710
+ }
3711
+ async #sendDhtPing(node) {
3712
+ if (!this.#keyPair || !node.pk || node.isTcp)
3713
+ return;
3714
+ let recipientPk;
3715
+ try {
3716
+ recipientPk = base58ToBytes(node.pk);
3717
+ }
3718
+ catch {
3719
+ return;
3720
+ }
3721
+ if (recipientPk.length !== 32)
3722
+ return;
3723
+ const plain = buildPingPlain(NET_PACKET_PING_REQUEST, this.#dhtPingId());
3724
+ const pkt = encodeDhtRpc(NET_PACKET_PING_REQUEST, this.#keyPair, recipientPk, plain);
3725
+ await this.#sendPacket(pkt, { host: node.host, port: node.port }).catch(() => undefined);
3726
+ }
3727
+ #ensureDhtMaintenanceLoop() {
3728
+ if (this.#dhtMaintenanceTimer || DHT_MAINTENANCE_INTERVAL_MS <= 0)
3729
+ return;
3730
+ this.#dhtMaintenanceTimer = setInterval(() => {
3731
+ void this.#doDhtMaintenance().catch(() => undefined);
3732
+ }, DHT_MAINTENANCE_INTERVAL_MS);
3733
+ if (typeof this.#dhtMaintenanceTimer.unref === "function")
3734
+ this.#dhtMaintenanceTimer.unref();
3735
+ void this.#doDhtMaintenance().catch(() => undefined); // become findable right after join
3736
+ }
3737
+ async #doDhtMaintenance() {
3738
+ if (!this.#keyPair)
3739
+ return;
3740
+ const selfPk = this.#keyPair.publicKey;
3741
+ // 1. get_nodes toward our OWN key → neighbours store our address → native
3742
+ // peers searching for us learn our UDP endpoint and can punch (findable).
3743
+ for (const node of this.#closestKnownNodes(selfPk, 8)) {
3744
+ void this.#sendDhtGetNodes(node, selfPk);
3745
+ }
3746
+ // 2. For each friend not on a confirmed UDP path, get_nodes toward THEIR
3747
+ // DHT key → learn their UDP endpoint → punch from our side.
3748
+ for (const [, session] of this.#friendSessions.entries()) {
3749
+ const dpk = session.friendDhtPublicKey;
3750
+ if (!dpk)
3751
+ continue;
3752
+ const udpConfirmed = session.lastUdpRecvMs !== undefined && Date.now() - session.lastUdpRecvMs < 8_000;
3753
+ if (udpConfirmed)
3754
+ continue;
3755
+ for (const node of this.#closestKnownNodes(dpk, 6)) {
3756
+ void this.#sendDhtGetNodes(node, dpk);
3757
+ }
3758
+ }
3759
+ // 3. Keep a handful of nodes warm (and ourselves in their tables).
3760
+ for (const node of this.#knownNodes.slice(0, 8)) {
3761
+ if (!node.isTcp)
3762
+ void this.#sendDhtPing(node);
3763
+ }
3764
+ }
3547
3765
  /**
3548
3766
  * Send an onion DHT-PK announcement to a friend so they learn our DHT
3549
3767
  * public key + endpoint hints. This is the bidirectional partner of the
@@ -3652,6 +3870,36 @@ export class Peer {
3652
3870
  packed.push(entry);
3653
3871
  }
3654
3872
  }
3873
+ // Also advertise our PUBLIC reflexive (srflx) UDP endpoint, learned
3874
+ // via STUN. The LAN entries above only help same-LAN peers; a peer
3875
+ // across the internet — e.g. a native iOS/Android Beagle client —
3876
+ // needs our NAT-mapped public ip:port to send a UDP cookie request
3877
+ // and punch. Without it they only ever learn our TCP relays and the
3878
+ // session sits on the slow transpacific relay forever. This is the
3879
+ // "STUN-style reflexive discovery" the LAN-only code deferred; it's
3880
+ // what makes Mac<->phone text go UDP-direct. The receiver already
3881
+ // consumes 0x02 entries (#handleOnionDhtPk -> #cacheFriendRemote).
3882
+ try {
3883
+ const srflx = await this.#gatherOwnSrflx();
3884
+ if (srflx && srflx.port > 0 && !seenIps.has(srflx.host)) {
3885
+ const parts = srflx.host.split(".").map((p) => Number.parseInt(p, 10));
3886
+ if (parts.length === 4 && !parts.some((n) => !(n >= 0 && n <= 255))) {
3887
+ const entry = new Uint8Array(1 + 4 + 2 + 32);
3888
+ entry[0] = 0x02; // UDP_FAMILY_IPV4
3889
+ entry[1] = parts[0];
3890
+ entry[2] = parts[1];
3891
+ entry[3] = parts[2];
3892
+ entry[4] = parts[3];
3893
+ entry[5] = (srflx.port >> 8) & 0xff;
3894
+ entry[6] = srflx.port & 0xff;
3895
+ entry.set(ourDhtPk, 7);
3896
+ packed.push(entry);
3897
+ }
3898
+ }
3899
+ }
3900
+ catch {
3901
+ // STUN gather failed — fall through with LAN + relay extras only.
3902
+ }
3655
3903
  }
3656
3904
  catch {
3657
3905
  // os import or interface enumeration failed; skip UDP extras
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",