@decentnetwork/peer 0.1.84 → 0.1.86

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.
@@ -17,6 +17,8 @@ export type TcpRelayPoolEvents = {
17
17
  peerData: (friendPublicKey: Uint8Array, payload: Uint8Array) => void;
18
18
  /** Inbound OOB_RECV (used for friend requests via TCP). */
19
19
  oob: (senderPublicKey: Uint8Array, payload: Uint8Array) => void;
20
+ /** Inbound onion response routed back through a relay (announce / onion data). */
21
+ onionResponse: (packet: Uint8Array) => void;
20
22
  /** Number of currently-connected relays changed (for diagnostics). */
21
23
  status: (connected: number, total: number) => void;
22
24
  };
@@ -67,6 +69,15 @@ export declare class TcpRelayPool extends EventEmitter {
67
69
  sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array, droppable?: boolean): number;
68
70
  /** Diagnostics. */
69
71
  connectedCount(): number;
72
+ /**
73
+ * Send an onion request (createOnionRequest0Tcp packet) through up to `fanout`
74
+ * connected relays. Each relay forwards it to node B over UDP and routes the
75
+ * onion response back over TCP (surfaced via the pool's "onionResponse"
76
+ * event). Returns the number of relays the request was handed to (0 = none
77
+ * connected). Fanning out across a few relays covers the case where one relay
78
+ * can't reach node B.
79
+ */
80
+ sendOnionRequest(packet: Uint8Array, fanout?: number): number;
70
81
  /**
71
82
  * Snapshot of currently-connected relays for inclusion in DHT-PK
72
83
  * announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
@@ -211,6 +211,25 @@ export class TcpRelayPool extends EventEmitter {
211
211
  connectedCount() {
212
212
  return this.#relays.filter((r) => r.client?.state() === "connected").length;
213
213
  }
214
+ /**
215
+ * Send an onion request (createOnionRequest0Tcp packet) through up to `fanout`
216
+ * connected relays. Each relay forwards it to node B over UDP and routes the
217
+ * onion response back over TCP (surfaced via the pool's "onionResponse"
218
+ * event). Returns the number of relays the request was handed to (0 = none
219
+ * connected). Fanning out across a few relays covers the case where one relay
220
+ * can't reach node B.
221
+ */
222
+ sendOnionRequest(packet, fanout = 2) {
223
+ let sent = 0;
224
+ for (const r of this.#relays) {
225
+ if (sent >= fanout)
226
+ break;
227
+ if (r.client?.state() === "connected" && r.client.sendOnionRequest(packet)) {
228
+ sent += 1;
229
+ }
230
+ }
231
+ return sent;
232
+ }
214
233
  /**
215
234
  * Snapshot of currently-connected relays for inclusion in DHT-PK
216
235
  * announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
@@ -304,6 +323,9 @@ export class TcpRelayPool extends EventEmitter {
304
323
  client.on("oob", (senderKey, payload) => {
305
324
  this.emit("oob", new Uint8Array(senderKey), payload);
306
325
  });
326
+ client.on("onionResponse", (packet) => {
327
+ this.emit("onionResponse", new Uint8Array(packet));
328
+ });
307
329
  try {
308
330
  await client.connect(10_000);
309
331
  }
@@ -54,6 +54,10 @@ export type TcpRelayEvents = {
54
54
  oob: (senderPublicKey: Uint8Array, payload: Uint8Array) => void;
55
55
  /** Pong (response to our ping). */
56
56
  pong: (pingId: bigint) => void;
57
+ /** Inbound TCP_PACKET_ONION_RESPONSE — the raw onion packet (announce or
58
+ * onion-data response), routed back through this relay. Treat exactly like a
59
+ * UDP onion datagram. */
60
+ onionResponse: (packet: Uint8Array) => void;
57
61
  };
58
62
  export declare class TcpRelayClient extends EventEmitter {
59
63
  #private;
@@ -85,6 +89,15 @@ export declare class TcpRelayClient extends EventEmitter {
85
89
  sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array, droppable?: boolean): boolean;
86
90
  /** Send a PING; relay echoes it as PONG. ping_id is opaque 8 bytes. */
87
91
  sendPing(): boolean;
92
+ /**
93
+ * Send an onion request THROUGH this relay (toxcore TCP_client.c::
94
+ * send_onion_request). `packet` is a create_onion_packet_tcp payload
95
+ * (createOnionRequest0Tcp) — the relay acts as onion node A, forwards it to
96
+ * node B over UDP, and routes the onion response back to us over this TCP
97
+ * connection as TCP_PACKET_ONION_RESPONSE (surfaced via the "onionResponse"
98
+ * event). This is the NAT-resilient announce / friend-lookup path.
99
+ */
100
+ sendOnionRequest(packet: Uint8Array): boolean;
88
101
  /** Send DATA payload to the friend at `connectionId`. */
89
102
  sendData(connectionId: number, payload: Uint8Array, droppable?: boolean): boolean;
90
103
  /** Send OOB_SEND (used for delivering friend requests via TCP relay). */
@@ -379,6 +379,16 @@ export class TcpRelayClient extends EventEmitter {
379
379
  this.emit("oob", sender, payload);
380
380
  return;
381
381
  }
382
+ case TCP_PACKET_ONION_RESPONSE: {
383
+ // The relay forwarded an onion response addressed to us (announce
384
+ // response / onion data response). `body` is the raw onion packet —
385
+ // identical to what would have arrived over UDP — so the Peer feeds it
386
+ // through the same NET_PACKET_ONION_* handling.
387
+ if (body.length === 0)
388
+ return;
389
+ this.emit("onionResponse", new Uint8Array(body));
390
+ return;
391
+ }
382
392
  default: {
383
393
  if (type >= 16) {
384
394
  // DATA forwarded by relay from a connected peer. Resolve
@@ -445,6 +455,19 @@ export class TcpRelayClient extends EventEmitter {
445
455
  const pingId = randomBytes(8);
446
456
  return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_PING), pingId]));
447
457
  }
458
+ /**
459
+ * Send an onion request THROUGH this relay (toxcore TCP_client.c::
460
+ * send_onion_request). `packet` is a create_onion_packet_tcp payload
461
+ * (createOnionRequest0Tcp) — the relay acts as onion node A, forwards it to
462
+ * node B over UDP, and routes the onion response back to us over this TCP
463
+ * connection as TCP_PACKET_ONION_RESPONSE (surfaced via the "onionResponse"
464
+ * event). This is the NAT-resilient announce / friend-lookup path.
465
+ */
466
+ sendOnionRequest(packet) {
467
+ if (packet.length === 0)
468
+ return false;
469
+ return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_ONION_REQUEST), packet]));
470
+ }
448
471
  /** Send DATA payload to the friend at `connectionId`. */
449
472
  sendData(connectionId, payload, droppable = false) {
450
473
  if (connectionId < 16 || connectionId > 0xff)
@@ -49,6 +49,33 @@ export declare function createOnionRequest0(opts: {
49
49
  nodeDPort: number;
50
50
  payloadForNodeD: Uint8Array;
51
51
  }): Uint8Array;
52
+ /**
53
+ * Build an onion request to send OVER A TCP RELAY (toxcore
54
+ * onion.c::create_onion_packet_tcp). When the onion path's first hop is a TCP
55
+ * relay we're already connected to, the relay itself acts as node A — so the
56
+ * packet OMITS node A's layer and is sent as the payload of TCP_PACKET_ONION_
57
+ * REQUEST. The relay forwards it to node B over UDP and routes the response
58
+ * back to us over the same TCP connection (TCP_PACKET_ONION_RESPONSE).
59
+ *
60
+ * This is the NAT-resilient discovery path native Beagle uses as primary:
61
+ * self-announce and friend-route lookups survive a NAT that drops the UDP
62
+ * onion return path, because the TCP relay handles the round-trip.
63
+ *
64
+ * Layout (== create_onion_packet_tcp): the UDP builder's inner `bPart` with a
65
+ * bare nonce prepended (no NET_PACKET_ONION_REQUEST_0 type byte, no node-A box):
66
+ * [nonce 24][ipport(B)][tempPubB 32][box_B([ipport(C)][tempPubC 32][box_C([ipport(D)][payload])])]
67
+ */
68
+ export declare function createOnionRequest0Tcp(opts: {
69
+ nodeBHost: string;
70
+ nodeBPort: number;
71
+ nodeBPublicKey: Uint8Array;
72
+ nodeCHost: string;
73
+ nodeCPort: number;
74
+ nodeCPublicKey: Uint8Array;
75
+ nodeDHost: string;
76
+ nodeDPort: number;
77
+ payloadForNodeD: Uint8Array;
78
+ }): Uint8Array;
52
79
  export declare function openOnionDataResponse(packet: Uint8Array, opts: {
53
80
  dataSecretKey: Uint8Array;
54
81
  }): {
@@ -112,6 +112,44 @@ export function createOnionRequest0(opts) {
112
112
  aEncrypted
113
113
  ]);
114
114
  }
115
+ /**
116
+ * Build an onion request to send OVER A TCP RELAY (toxcore
117
+ * onion.c::create_onion_packet_tcp). When the onion path's first hop is a TCP
118
+ * relay we're already connected to, the relay itself acts as node A — so the
119
+ * packet OMITS node A's layer and is sent as the payload of TCP_PACKET_ONION_
120
+ * REQUEST. The relay forwards it to node B over UDP and routes the response
121
+ * back to us over the same TCP connection (TCP_PACKET_ONION_RESPONSE).
122
+ *
123
+ * This is the NAT-resilient discovery path native Beagle uses as primary:
124
+ * self-announce and friend-route lookups survive a NAT that drops the UDP
125
+ * onion return path, because the TCP relay handles the round-trip.
126
+ *
127
+ * Layout (== create_onion_packet_tcp): the UDP builder's inner `bPart` with a
128
+ * bare nonce prepended (no NET_PACKET_ONION_REQUEST_0 type byte, no node-A box):
129
+ * [nonce 24][ipport(B)][tempPubB 32][box_B([ipport(C)][tempPubC 32][box_C([ipport(D)][payload])])]
130
+ */
131
+ export function createOnionRequest0Tcp(opts) {
132
+ ensureLen(opts.nodeBPublicKey, KEY_SIZE, "node B public key");
133
+ ensureLen(opts.nodeCPublicKey, KEY_SIZE, "node C public key");
134
+ const nonce = randomBytes(NONCE_SIZE);
135
+ const dPart = concatBytes([packIpPort(opts.nodeDHost, opts.nodeDPort), opts.payloadForNodeD]);
136
+ const key2 = nacl.box.keyPair();
137
+ const cEncrypted = nacl.box(dPart, nonce, opts.nodeCPublicKey, key2.secretKey);
138
+ const cPart = concatBytes([
139
+ packIpPort(opts.nodeCHost, opts.nodeCPort),
140
+ key2.publicKey,
141
+ cEncrypted
142
+ ]);
143
+ const key1 = nacl.box.keyPair();
144
+ const bEncrypted = nacl.box(cPart, nonce, opts.nodeBPublicKey, key1.secretKey);
145
+ // No node-A box: the relay is node A. Just [nonce][ipport(B)][tempPubB][box_B].
146
+ return concatBytes([
147
+ nonce,
148
+ packIpPort(opts.nodeBHost, opts.nodeBPort),
149
+ key1.publicKey,
150
+ bEncrypted
151
+ ]);
152
+ }
115
153
  export function openOnionDataResponse(packet, opts) {
116
154
  if (packet.length < 1 + NONCE_SIZE + KEY_SIZE + 16) {
117
155
  return undefined;
package/dist/peer.d.ts CHANGED
@@ -45,6 +45,11 @@ export declare class Peer {
45
45
  selfAnnounceStoredOn: number;
46
46
  udpLocalPort: number | null;
47
47
  tcpRelayConnected: number;
48
+ /** TCP-relay onion diagnostics: requests handed to relays / responses
49
+ * routed back. If sent>0 but recv=0, relays aren't returning onion
50
+ * responses (relay doesn't forward, or wire mismatch). */
51
+ tcpOnionSent: number;
52
+ tcpOnionRecv: number;
48
53
  /** The specific relay endpoints this peer has live connections to.
49
54
  * Lets us compare two peers' relay sets — if there's NO overlap,
50
55
  * net_crypto handshake packets between them have nowhere to route
package/dist/peer.js CHANGED
@@ -6,7 +6,7 @@ import nacl from "tweetnacl";
6
6
  import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
7
7
  import { LegacyBootstrapClient } from "./compat/bootstrap.js";
8
8
  import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, PACKET_TYPE_INVITE_REQUEST, PACKET_TYPE_INVITE_RESPONSE, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket, encodeInviteReqPacket } from "./compat/packet.js";
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";
9
+ import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, createOnionRequest0Tcp, 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
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";
12
12
  import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA, PACKET_ID_FILE_FEC } from "./compat/filetransfer.js";
@@ -280,6 +280,12 @@ export class Peer {
280
280
  // 25s forever for stale persisted entries.
281
281
  #dhtPkConsecutiveFailures = new Map();
282
282
  #lastSelfAnnounceStoredCount = -1;
283
+ // Diagnostics for the TCP-relay onion path (announce/discovery over TCP).
284
+ // Surfaced in dhtHealth so `agentnet diag` shows whether it's active without
285
+ // needing verbose logs: sent = onion requests handed to relays, recv = onion
286
+ // responses routed back over TCP.
287
+ #diagTcpOnionSent = 0;
288
+ #diagTcpOnionRecv = 0;
283
289
  // Per-friend last-logged route count from #discoverFriendRoutes so we
284
290
  // only emit a "routes=N for friend=…" debug line when it changes.
285
291
  #lastLoggedRoutesForFriend = new Map();
@@ -511,6 +517,24 @@ export class Peer {
511
517
  this.#tcpRelays?.requestRoute(senderKey);
512
518
  this.#handleTcpDatagram(senderKey, payload);
513
519
  });
520
+ // Onion responses routed back through a TCP relay (announce responses,
521
+ // onion data responses). This is the NAT-resilient discovery path: when
522
+ // UDP onion is blocked, the relay does the round-trip. Inject the raw
523
+ // onion packet into the SAME "datagram" stream the UDP transport feeds,
524
+ // so #onDatagram AND the ad-hoc announce/route waiters
525
+ // (#waitForAnnounceResponse) process it identically. Synthetic remote so
526
+ // no real UDP endpoint is adopted from it.
527
+ this.#tcpRelays.on("onionResponse", (packet) => {
528
+ if (packet.length === 0)
529
+ return;
530
+ this.#diagTcpOnionRecv += 1;
531
+ this.#debugVerboseLog(`tcp onion response received (${packet.length} bytes, type=${packet[0]})`);
532
+ this.#udp.emit("datagram", {
533
+ data: Buffer.from(packet),
534
+ remote: { address: "tcp-relay", port: 0 },
535
+ viaRelay: true
536
+ });
537
+ });
514
538
  // Don't await — pool reconnects on its own and we want start() to
515
539
  // return promptly so the demo can call joinNetwork() in parallel.
516
540
  void this.#tcpRelays.start().catch((error) => {
@@ -670,6 +694,8 @@ export class Peer {
670
694
  selfAnnounceStoredOn: this.#lastSelfAnnounceStoredCount,
671
695
  udpLocalPort: this.#udp?.localPort() ?? null,
672
696
  tcpRelayConnected: relays.length,
697
+ tcpOnionSent: this.#diagTcpOnionSent,
698
+ tcpOnionRecv: this.#diagTcpOnionRecv,
673
699
  tcpRelayEndpoints: relays.map((r) => ({ host: r.host, port: r.port })),
674
700
  };
675
701
  }
@@ -5243,6 +5269,36 @@ export class Peer {
5243
5269
  payloadForNodeD
5244
5270
  });
5245
5271
  await this.#sendPacket(packet, path.nodeA.node);
5272
+ // NAT-resilient duplicate: ALSO send this onion request over connected TCP
5273
+ // relays (toxcore create_onion_packet_tcp). The relay acts as node A and
5274
+ // forwards to node B over UDP, routing the response back over TCP — so
5275
+ // self-announce AND friend-route discovery survive a NAT that drops the UDP
5276
+ // onion return path (the residential-ISP case where selfAnnounceStoredOn
5277
+ // stays 0 and native peers can never find us). Same sendBack as the UDP
5278
+ // attempt, so whichever transport answers first resolves the waiter.
5279
+ if (this.#tcpRelays && this.#tcpRelays.connectedCount() > 0) {
5280
+ try {
5281
+ const tcpPacket = createOnionRequest0Tcp({
5282
+ nodeBHost: path.nodeB.host,
5283
+ nodeBPort: path.nodeB.port,
5284
+ nodeBPublicKey: path.nodeB.publicKey,
5285
+ nodeCHost: path.nodeC.host,
5286
+ nodeCPort: path.nodeC.port,
5287
+ nodeCPublicKey: path.nodeC.publicKey,
5288
+ nodeDHost: nodeD.host,
5289
+ nodeDPort: nodeD.port,
5290
+ payloadForNodeD
5291
+ });
5292
+ const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
5293
+ if (sent > 0) {
5294
+ this.#diagTcpOnionSent += 1;
5295
+ this.#debugVerboseLog(`tcp onion request sent via ${sent} relay(s) to ${nodeD.host}:${nodeD.port} (B=${path.nodeB.host} C=${path.nodeC.host})`);
5296
+ }
5297
+ }
5298
+ catch {
5299
+ /* malformed path node key — the UDP attempt above still stands */
5300
+ }
5301
+ }
5246
5302
  return path;
5247
5303
  }
5248
5304
  #selectOnionPath(nodeD, pathOffset = 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
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",
@@ -68,6 +68,7 @@
68
68
  "demo:tox-dht-crypto": "pnpm run build && node scripts/tox-dht-crypto-demo.mjs",
69
69
  "test:compat": "pnpm run build && node scripts/compat-selftest.mjs",
70
70
  "test:invite": "pnpm run build && node scripts/invite-selftest.mjs",
71
+ "test:tcp-onion": "pnpm run build && node scripts/tcp-onion-selftest.mjs",
71
72
  "smoke:join": "node scripts/smoke-join.mjs",
72
73
  "typecheck": "tsc -p tsconfig.json --noEmit",
73
74
  "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs"