@decentnetwork/peer 0.1.40 → 0.1.42

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.d.ts CHANGED
@@ -147,4 +147,34 @@ export declare class Peer {
147
147
  cookieRequestSentMs: number | null;
148
148
  } | null;
149
149
  waitForFriendRequest(timeoutMs?: number): Promise<FriendRequest>;
150
+ /**
151
+ * After a friend sends us PACKET_ID_ONLINE, push our nickname + status
152
+ * message so their UI replaces "unknown" with our actual display name,
153
+ * and optionally fire off a configured greeting message. Idempotent —
154
+ * tracked per friend so we don't spam every keepalive cycle.
155
+ *
156
+ * Sends are sequenced with small delays so the receiving peer's UI layer
157
+ * doesn't process three messenger packets in the same render frame —
158
+ * iOS Beagle 1.8.6 has a SwiftUI use-after-free in
159
+ * `_UIHostingView.beginTransaction()` when nickname / status / message
160
+ * arrive in rapid succession at session establishment, observed as
161
+ * EXC_BAD_ACCESS in the iOS app's main thread.
162
+ */
163
+ /**
164
+ * Update our own profile (display name / status-message description) at
165
+ * runtime and re-push it to friends. `name` maps to the toxcore nickname
166
+ * (PACKET_ID_NICKNAME) and the Carrier USERINFO `name`; `description` maps to
167
+ * the USERINFO descr / status message. Re-arms the per-friend "profile sent"
168
+ * flag so every friend receives the update, and proactively pushes to anyone
169
+ * currently online. The greeting is NOT re-sent (its flag is left intact).
170
+ */
171
+ setUserInfo(info: {
172
+ name?: string;
173
+ description?: string;
174
+ }): void;
175
+ /** Our current display name + status-message description. */
176
+ userInfo(): {
177
+ name: string;
178
+ description: string;
179
+ };
150
180
  }
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
  /**
@@ -2801,6 +2832,34 @@ export class Peer {
2801
2832
  * arrive in rapid succession at session establishment, observed as
2802
2833
  * EXC_BAD_ACCESS in the iOS app's main thread.
2803
2834
  */
2835
+ /**
2836
+ * Update our own profile (display name / status-message description) at
2837
+ * runtime and re-push it to friends. `name` maps to the toxcore nickname
2838
+ * (PACKET_ID_NICKNAME) and the Carrier USERINFO `name`; `description` maps to
2839
+ * the USERINFO descr / status message. Re-arms the per-friend "profile sent"
2840
+ * flag so every friend receives the update, and proactively pushes to anyone
2841
+ * currently online. The greeting is NOT re-sent (its flag is left intact).
2842
+ */
2843
+ setUserInfo(info) {
2844
+ const opts = this.#opts;
2845
+ if (info.name !== undefined)
2846
+ opts.nickname = info.name;
2847
+ if (info.description !== undefined)
2848
+ opts.statusMessage = info.description;
2849
+ this.#profileSentTo.clear();
2850
+ for (const [friendId, s] of this.#friendSessions.entries()) {
2851
+ if (s.established)
2852
+ this.#sendProfileAndGreeting(friendId);
2853
+ }
2854
+ }
2855
+ /** Our current display name + status-message description. */
2856
+ userInfo() {
2857
+ const opts = this.#opts;
2858
+ return {
2859
+ name: opts.nickname ?? PEER_NICKNAME,
2860
+ description: opts.statusMessage ?? PEER_STATUS_MESSAGE,
2861
+ };
2862
+ }
2804
2863
  #sendProfileAndGreeting(friendId) {
2805
2864
  if (!this.#profileSentTo.has(friendId)) {
2806
2865
  this.#profileSentTo.add(friendId);
@@ -3544,6 +3603,193 @@ export class Peer {
3544
3603
  this.#debugLog(`lan_sweep friend=${friendId} probes=${probes}`);
3545
3604
  }
3546
3605
  }
3606
+ // ===================== classic toxcore DHT (ping / get_nodes) ==========
3607
+ // Answering and issuing these makes a JS peer a real DHT node — the thing
3608
+ // native C++/iOS/Android Carrier clients rely on to find a friend's live UDP
3609
+ // ip:port and net_crypto to it directly. Previously peer.js was a DHT *client*
3610
+ // only (bootstrap get_nodes), so it was invisible: native peers couldn't find
3611
+ // our UDP endpoint and every session fell back to the slow TCP relay.
3612
+ #dhtPingId() {
3613
+ return randomBytes(8);
3614
+ }
3615
+ // The known nodes closest (XOR distance) to `target`, UDP-only, with a valid
3616
+ // 32-byte key — the candidates a get_nodes lookup should converge through.
3617
+ #closestKnownNodes(target, limit) {
3618
+ const withPk = [];
3619
+ for (const node of this.#knownNodes) {
3620
+ if (!node.pk || node.isTcp)
3621
+ continue;
3622
+ try {
3623
+ const pk = base58ToBytes(node.pk);
3624
+ if (pk.length === 32)
3625
+ withPk.push({ node, pk });
3626
+ }
3627
+ catch { /* skip malformed */ }
3628
+ }
3629
+ withPk.sort((a, b) => xorCloser(target, a.pk, b.pk));
3630
+ return withPk.slice(0, limit).map((e) => e.node);
3631
+ }
3632
+ #friendByDhtPk(dhtPkBase58) {
3633
+ for (const [friendId, session] of this.#friendSessions.entries()) {
3634
+ const dpk = session.friendDhtPublicKey;
3635
+ if (dpk && carrierIdFromPublicKey(dpk) === dhtPkBase58) {
3636
+ return { friendId, realPk: session.friendRealPublicKey ?? dpk };
3637
+ }
3638
+ }
3639
+ return undefined;
3640
+ }
3641
+ #handleDhtRpc(packet, remote) {
3642
+ if (!this.#keyPair)
3643
+ return;
3644
+ const decoded = decodeDhtRpc(packet, this.#keyPair.secretKey);
3645
+ if (!decoded)
3646
+ return;
3647
+ const senderId = carrierIdFromPublicKey(decoded.senderPublicKey);
3648
+ // Learn the sender's real reachable UDP address: populates our table and
3649
+ // keeps mutual reachability alive across the mesh.
3650
+ if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
3651
+ this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
3652
+ }
3653
+ if (decoded.type === NET_PACKET_PING_REQUEST) {
3654
+ const ping = parsePingPlain(decoded.plain);
3655
+ if (!ping)
3656
+ return;
3657
+ // Echo ping_id so the peer marks us alive and keeps us in its table
3658
+ // (= keeps us findable through it).
3659
+ const plain = buildPingPlain(NET_PACKET_PING_RESPONSE, ping.pingId);
3660
+ const resp = encodeDhtRpc(NET_PACKET_PING_RESPONSE, this.#keyPair, decoded.senderPublicKey, plain);
3661
+ void this.#sendPacket(resp, { host: remote.address, port: remote.port }).catch(() => undefined);
3662
+ return;
3663
+ }
3664
+ if (decoded.type === NET_PACKET_PING_RESPONSE) {
3665
+ return; // liveness already recorded via addKnownNodes above
3666
+ }
3667
+ if (decoded.type === NET_PACKET_GET_NODES) {
3668
+ const gn = parseGetNodesPlain(decoded.plain);
3669
+ if (!gn)
3670
+ return;
3671
+ // Reply with up to 4 known nodes closest to the searched key. When a
3672
+ // native peer searches for OUR key, replying from our socket reveals our
3673
+ // address to them directly, and the hint lets their lookup converge.
3674
+ const packedParts = [];
3675
+ let count = 0;
3676
+ for (const node of this.#closestKnownNodes(gn.target, 4)) {
3677
+ if (!node.pk)
3678
+ continue;
3679
+ let pk;
3680
+ try {
3681
+ pk = base58ToBytes(node.pk);
3682
+ }
3683
+ catch {
3684
+ continue;
3685
+ }
3686
+ const packed = packUdpNodeV4(node.host, node.port, pk);
3687
+ if (packed) {
3688
+ packedParts.push(packed);
3689
+ count++;
3690
+ }
3691
+ }
3692
+ const nodesBytes = packedParts.length ? concatBytes(packedParts) : new Uint8Array(0);
3693
+ const plain = buildSendNodesPlain(nodesBytes, count, gn.pingId);
3694
+ const resp = encodeDhtRpc(NET_PACKET_SEND_NODES, this.#keyPair, decoded.senderPublicKey, plain);
3695
+ void this.#sendPacket(resp, { host: remote.address, port: remote.port }).catch(() => undefined);
3696
+ return;
3697
+ }
3698
+ if (decoded.type === NET_PACKET_SEND_NODES) {
3699
+ const nodeBytes = parseSendNodesNodeBytes(decoded.plain);
3700
+ if (!nodeBytes || nodeBytes.length === 0)
3701
+ return;
3702
+ const nodes = parsePackedNodes(nodeBytes).filter((n) => !n.isTcp);
3703
+ if (nodes.length)
3704
+ this.addKnownNodes(nodes);
3705
+ // If a returned node IS one of our friends' DHT keys, we just learned
3706
+ // their live UDP endpoint — cache it and kick a UDP cookie to punch.
3707
+ for (const node of nodes) {
3708
+ if (!node.pk)
3709
+ continue;
3710
+ const match = this.#friendByDhtPk(node.pk);
3711
+ if (!match)
3712
+ continue;
3713
+ try {
3714
+ this.#cacheFriendRemote(match.friendId, node.host, node.port, match.realPk, base58ToBytes(node.pk));
3715
+ this.#debugLog(`dht_search found UDP endpoint for ${match.friendId} at ${node.host}:${node.port}`);
3716
+ void this.#initiateSession(match.friendId).catch(() => undefined);
3717
+ }
3718
+ catch { /* malformed key */ }
3719
+ }
3720
+ return;
3721
+ }
3722
+ }
3723
+ async #sendDhtGetNodes(node, targetPublicKey) {
3724
+ if (!this.#keyPair || !node.pk || node.isTcp)
3725
+ return;
3726
+ let recipientPk;
3727
+ try {
3728
+ recipientPk = base58ToBytes(node.pk);
3729
+ }
3730
+ catch {
3731
+ return;
3732
+ }
3733
+ if (recipientPk.length !== 32)
3734
+ return;
3735
+ const plain = buildGetNodesPlain(targetPublicKey, this.#dhtPingId());
3736
+ const pkt = encodeDhtRpc(NET_PACKET_GET_NODES, this.#keyPair, recipientPk, plain);
3737
+ await this.#sendPacket(pkt, { host: node.host, port: node.port }).catch(() => undefined);
3738
+ }
3739
+ async #sendDhtPing(node) {
3740
+ if (!this.#keyPair || !node.pk || node.isTcp)
3741
+ return;
3742
+ let recipientPk;
3743
+ try {
3744
+ recipientPk = base58ToBytes(node.pk);
3745
+ }
3746
+ catch {
3747
+ return;
3748
+ }
3749
+ if (recipientPk.length !== 32)
3750
+ return;
3751
+ const plain = buildPingPlain(NET_PACKET_PING_REQUEST, this.#dhtPingId());
3752
+ const pkt = encodeDhtRpc(NET_PACKET_PING_REQUEST, this.#keyPair, recipientPk, plain);
3753
+ await this.#sendPacket(pkt, { host: node.host, port: node.port }).catch(() => undefined);
3754
+ }
3755
+ #ensureDhtMaintenanceLoop() {
3756
+ if (this.#dhtMaintenanceTimer || DHT_MAINTENANCE_INTERVAL_MS <= 0)
3757
+ return;
3758
+ this.#dhtMaintenanceTimer = setInterval(() => {
3759
+ void this.#doDhtMaintenance().catch(() => undefined);
3760
+ }, DHT_MAINTENANCE_INTERVAL_MS);
3761
+ if (typeof this.#dhtMaintenanceTimer.unref === "function")
3762
+ this.#dhtMaintenanceTimer.unref();
3763
+ void this.#doDhtMaintenance().catch(() => undefined); // become findable right after join
3764
+ }
3765
+ async #doDhtMaintenance() {
3766
+ if (!this.#keyPair)
3767
+ return;
3768
+ const selfPk = this.#keyPair.publicKey;
3769
+ // 1. get_nodes toward our OWN key → neighbours store our address → native
3770
+ // peers searching for us learn our UDP endpoint and can punch (findable).
3771
+ for (const node of this.#closestKnownNodes(selfPk, 8)) {
3772
+ void this.#sendDhtGetNodes(node, selfPk);
3773
+ }
3774
+ // 2. For each friend not on a confirmed UDP path, get_nodes toward THEIR
3775
+ // DHT key → learn their UDP endpoint → punch from our side.
3776
+ for (const [, session] of this.#friendSessions.entries()) {
3777
+ const dpk = session.friendDhtPublicKey;
3778
+ if (!dpk)
3779
+ continue;
3780
+ const udpConfirmed = session.lastUdpRecvMs !== undefined && Date.now() - session.lastUdpRecvMs < 8_000;
3781
+ if (udpConfirmed)
3782
+ continue;
3783
+ for (const node of this.#closestKnownNodes(dpk, 6)) {
3784
+ void this.#sendDhtGetNodes(node, dpk);
3785
+ }
3786
+ }
3787
+ // 3. Keep a handful of nodes warm (and ourselves in their tables).
3788
+ for (const node of this.#knownNodes.slice(0, 8)) {
3789
+ if (!node.isTcp)
3790
+ void this.#sendDhtPing(node);
3791
+ }
3792
+ }
3547
3793
  /**
3548
3794
  * Send an onion DHT-PK announcement to a friend so they learn our DHT
3549
3795
  * public key + endpoint hints. This is the bidirectional partner of the
@@ -3652,6 +3898,36 @@ export class Peer {
3652
3898
  packed.push(entry);
3653
3899
  }
3654
3900
  }
3901
+ // Also advertise our PUBLIC reflexive (srflx) UDP endpoint, learned
3902
+ // via STUN. The LAN entries above only help same-LAN peers; a peer
3903
+ // across the internet — e.g. a native iOS/Android Beagle client —
3904
+ // needs our NAT-mapped public ip:port to send a UDP cookie request
3905
+ // and punch. Without it they only ever learn our TCP relays and the
3906
+ // session sits on the slow transpacific relay forever. This is the
3907
+ // "STUN-style reflexive discovery" the LAN-only code deferred; it's
3908
+ // what makes Mac<->phone text go UDP-direct. The receiver already
3909
+ // consumes 0x02 entries (#handleOnionDhtPk -> #cacheFriendRemote).
3910
+ try {
3911
+ const srflx = await this.#gatherOwnSrflx();
3912
+ if (srflx && srflx.port > 0 && !seenIps.has(srflx.host)) {
3913
+ const parts = srflx.host.split(".").map((p) => Number.parseInt(p, 10));
3914
+ if (parts.length === 4 && !parts.some((n) => !(n >= 0 && n <= 255))) {
3915
+ const entry = new Uint8Array(1 + 4 + 2 + 32);
3916
+ entry[0] = 0x02; // UDP_FAMILY_IPV4
3917
+ entry[1] = parts[0];
3918
+ entry[2] = parts[1];
3919
+ entry[3] = parts[2];
3920
+ entry[4] = parts[3];
3921
+ entry[5] = (srflx.port >> 8) & 0xff;
3922
+ entry[6] = srflx.port & 0xff;
3923
+ entry.set(ourDhtPk, 7);
3924
+ packed.push(entry);
3925
+ }
3926
+ }
3927
+ }
3928
+ catch {
3929
+ // STUN gather failed — fall through with LAN + relay extras only.
3930
+ }
3655
3931
  }
3656
3932
  catch {
3657
3933
  // 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.42",
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",