@decentnetwork/peer 0.1.37 → 0.1.39

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.
@@ -64,7 +64,7 @@ export declare class TcpRelayPool extends EventEmitter {
64
64
  * online. Returns the count of relays that accepted the write — 0 if
65
65
  * the friend isn't online on any TCP relay.
66
66
  */
67
- sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): number;
67
+ sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array, droppable?: boolean): number;
68
68
  /** Diagnostics. */
69
69
  connectedCount(): number;
70
70
  /**
@@ -173,7 +173,7 @@ export class TcpRelayPool extends EventEmitter {
173
173
  * online. Returns the count of relays that accepted the write — 0 if
174
174
  * the friend isn't online on any TCP relay.
175
175
  */
176
- sendToFriend(friendPublicKey, payload) {
176
+ sendToFriend(friendPublicKey, payload, droppable = false) {
177
177
  if (friendPublicKey.length !== KEY_SIZE)
178
178
  return 0;
179
179
  // Send via the FIRST connected relay where the friend is online; only
@@ -191,7 +191,7 @@ export class TcpRelayPool extends EventEmitter {
191
191
  continue;
192
192
  if (!r.client.hasFriendOnline(friendPublicKey))
193
193
  continue;
194
- if (r.client.sendToFriend(friendPublicKey, payload)) {
194
+ if (r.client.sendToFriend(friendPublicKey, payload, droppable)) {
195
195
  return 1;
196
196
  }
197
197
  }
@@ -82,11 +82,11 @@ export declare class TcpRelayClient extends EventEmitter {
82
82
  * if the relay says they're offline. Caller should treat false as
83
83
  * "try another transport".
84
84
  */
85
- sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): boolean;
85
+ sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array, droppable?: boolean): boolean;
86
86
  /** Send a PING; relay echoes it as PONG. ping_id is opaque 8 bytes. */
87
87
  sendPing(): boolean;
88
88
  /** Send DATA payload to the friend at `connectionId`. */
89
- sendData(connectionId: number, payload: Uint8Array): boolean;
89
+ sendData(connectionId: number, payload: Uint8Array, droppable?: boolean): boolean;
90
90
  /** Send OOB_SEND (used for delivering friend requests via TCP relay). */
91
91
  sendOob(receiverPublicKey: Uint8Array, payload: Uint8Array): boolean;
92
92
  close(reason?: string): void;
@@ -37,6 +37,15 @@ import { incrementNonce } from "./net-crypto.js";
37
37
  const KEY_SIZE = 32;
38
38
  const NONCE_SIZE = 24;
39
39
  const MAC_SIZE = 16;
40
+ // Max bytes we let pile up in the relay socket's write buffer before we start
41
+ // DROPPING droppable (bulk IP) frames. Node's socket.write() buffers without
42
+ // bound when the kernel send buffer is full, so a congested relay link would
43
+ // otherwise queue SECONDS of IP packets — the multi-second bufferbloat behind
44
+ // the snoopy/lili 13s pings. ~64KB is well under a second of queue even on a
45
+ // slow transpacific relay; past it we behave like a real congested network and
46
+ // drop, letting the inner protocol (TCP/QUIC) retransmit instead of delivering
47
+ // stale packets late. Control frames (droppable=false) always go through.
48
+ const RELAY_SEND_BUFFER_CAP = 64 * 1024;
40
49
  /** TCP_HANDSHAKE_PLAIN_SIZE in toxcore — temp pubkey + initial nonce. */
41
50
  const TCP_HANDSHAKE_PLAIN_SIZE = KEY_SIZE + NONCE_SIZE;
42
51
  /** Outbound handshake: self_dht_pk(32) + nonce(24) + box(plain)(KEY_SIZE+NONCE_SIZE+MAC_SIZE = 72) = 128. */
@@ -276,13 +285,18 @@ export class TcpRelayClient extends EventEmitter {
276
285
  return opened;
277
286
  }
278
287
  /** Encrypt a payload and write it as one framed packet. */
279
- #sendFrame(payload) {
288
+ #sendFrame(payload, droppable = false) {
280
289
  if (this.#state !== "connected" || !this.#sessionKey || !this.#sentNonce || !this.#socket) {
281
290
  return false;
282
291
  }
283
292
  if (payload.length === 0 || payload.length > MAX_PACKET_SIZE - MAC_SIZE) {
284
293
  return false;
285
294
  }
295
+ // Bound the relay queue: drop bulk IP frames once the socket buffer is
296
+ // backed up rather than letting Node buffer seconds of packets (bufferbloat).
297
+ if (droppable && this.#socket.writableLength > RELAY_SEND_BUFFER_CAP) {
298
+ return false;
299
+ }
286
300
  const cipher = nacl.secretbox(payload, this.#sentNonce, this.#sessionKey);
287
301
  incrementNonce(this.#sentNonce);
288
302
  // Wire layout: [4-byte iveg][2-byte BE length][cipher].
@@ -418,13 +432,13 @@ export class TcpRelayClient extends EventEmitter {
418
432
  * if the relay says they're offline. Caller should treat false as
419
433
  * "try another transport".
420
434
  */
421
- sendToFriend(friendPublicKey, payload) {
435
+ sendToFriend(friendPublicKey, payload, droppable = false) {
422
436
  if (friendPublicKey.length !== KEY_SIZE)
423
437
  return false;
424
438
  const cid = this.#cidByFriend.get(Buffer.from(friendPublicKey).toString("hex"));
425
439
  if (cid === undefined)
426
440
  return false;
427
- return this.sendData(cid, payload);
441
+ return this.sendData(cid, payload, droppable);
428
442
  }
429
443
  /** Send a PING; relay echoes it as PONG. ping_id is opaque 8 bytes. */
430
444
  sendPing() {
@@ -432,12 +446,12 @@ export class TcpRelayClient extends EventEmitter {
432
446
  return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_PING), pingId]));
433
447
  }
434
448
  /** Send DATA payload to the friend at `connectionId`. */
435
- sendData(connectionId, payload) {
449
+ sendData(connectionId, payload, droppable = false) {
436
450
  if (connectionId < 16 || connectionId > 0xff)
437
451
  return false;
438
452
  if (payload.length === 0)
439
453
  return false;
440
- return this.#sendFrame(concatBytes([Uint8Array.of(connectionId), payload]));
454
+ return this.#sendFrame(concatBytes([Uint8Array.of(connectionId), payload]), droppable);
441
455
  }
442
456
  /** Send OOB_SEND (used for delivering friend requests via TCP relay). */
443
457
  sendOob(receiverPublicKey, payload) {
package/dist/peer.js CHANGED
@@ -2775,7 +2775,10 @@ export class Peer {
2775
2775
  // would silently drop on iPad's side and leave the session
2776
2776
  // looking healthy on our side while iPad never sees the
2777
2777
  // packets.
2778
- const sent = this.#tcpRelays.sendToFriend(tcpKey, packet);
2778
+ // Bulk IP data is droppable over the relay: under backpressure the
2779
+ // relay client drops it (bounded queue) rather than buffering seconds
2780
+ // of packets. Control frames pass isBulkData=false and always queue.
2781
+ const sent = this.#tcpRelays.sendToFriend(tcpKey, packet, isBulkData);
2779
2782
  if (sent > 0) {
2780
2783
  tcpOk = true;
2781
2784
  }
@@ -3055,7 +3058,7 @@ export class Peer {
3055
3058
  // "direct" option we advertise is our srflx (public) address, which the
3056
3059
  // NAT must hairpin back onto the LAN — most don't, so two machines in one
3057
3060
  // office silently fall back to the TURN relay (~260ms for a <1ms hop).
3058
- const lanIp = getLocalIpv4Addresses().find((ip) => isPrivateAddress(ip));
3061
+ const lanIp = getPhysicalLanAddresses().find((ip) => isPrivateAddress(ip));
3059
3062
  const lanOctets = lanIp ? lanIp.split(".").map((s) => parseInt(s, 10)) : undefined;
3060
3063
  const lanPort = this.#udp.localPort() ?? 0;
3061
3064
  const lanValid = !!lanOctets && lanOctets.length === 4 && !lanOctets.some((o) => Number.isNaN(o)) && lanPort > 0;
@@ -3144,8 +3147,9 @@ export class Peer {
3144
3147
  const lanPort = ((payload[16] << 8) | payload[17]) >>> 0;
3145
3148
  const sameLan = lanPort !== 0 &&
3146
3149
  isPrivateAddress(lanHost) &&
3147
- getLocalIpv4Subnets().some((s) => isInIpv4Subnet(lanHost, s)) &&
3148
- !(getLocalIpv4Addresses().includes(lanHost) && this.#udp.localPort() === lanPort);
3150
+ !isCgnatAddress(lanHost) &&
3151
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(lanHost, s)) &&
3152
+ !(getPhysicalLanAddresses().includes(lanHost) && this.#udp.localPort() === lanPort);
3149
3153
  if (sameLan) {
3150
3154
  this.#rememberEndpointCandidate(session, lanHost, lanPort);
3151
3155
  this.#debugLog(`udp-endpoint LAN candidate from ${friendId}: ${lanHost}:${lanPort} (same subnet) — punching`);
@@ -4430,6 +4434,72 @@ function isInIpv4Subnet(host, subnet) {
4430
4434
  return false;
4431
4435
  return ((ip & subnet.maskBits) >>> 0) === subnet.networkBits;
4432
4436
  }
4437
+ // Interface names that are VIRTUAL / overlay networks, not a physical LAN:
4438
+ // Tailscale & utun tunnels (utun* on macOS, tailscale*/tun* on Linux),
4439
+ // WireGuard (wg*), ZeroTier (zt*), the decentlan TUN itself (agentnet*), and
4440
+ // assorted macOS virtual links (awdl/llw/gif/stf/bridge). A LAN-direct host
4441
+ // candidate MUST be a physical-LAN address: advertising/punching an overlay
4442
+ // address (e.g. Tailscale 100.x, or our own 10.86.x mesh IP) sends the "direct"
4443
+ // path onto the overlay or loops into our own mesh, then silently falls back to
4444
+ // the relay — the snoopy/lili "lastUdpRecv=never -> bufferbloat" path. (lili
4445
+ // runs Tailscale; that's exactly this.)
4446
+ const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|zt|ham|agentnet|awdl|llw|gif|stf|bridge|vnic|vmnet|veth|docker)/i;
4447
+ /** True for RFC 6598 carrier-grade-NAT space 100.64.0.0/10 (Tailscale's range). */
4448
+ function isCgnatAddress(host) {
4449
+ const o = host.split(".");
4450
+ if (o.length !== 4)
4451
+ return false;
4452
+ const a = Number(o[0]);
4453
+ const b = Number(o[1]);
4454
+ return a === 100 && b >= 64 && b <= 127;
4455
+ }
4456
+ /** Local IPv4 addresses on PHYSICAL interfaces only (excludes overlay/virtual
4457
+ * interfaces by name and the CGNAT range). Used for LAN-direct host candidates. */
4458
+ function getPhysicalLanAddresses() {
4459
+ const out = [];
4460
+ try {
4461
+ for (const [name, list] of Object.entries(networkInterfaces())) {
4462
+ if (!list || VIRTUAL_IFACE_RE.test(name))
4463
+ continue;
4464
+ for (const info of list) {
4465
+ if (info.family !== "IPv4" || info.internal)
4466
+ continue;
4467
+ if (isCgnatAddress(info.address))
4468
+ continue;
4469
+ out.push(info.address);
4470
+ }
4471
+ }
4472
+ }
4473
+ catch {
4474
+ // best-effort
4475
+ }
4476
+ return out;
4477
+ }
4478
+ /** Subnets of PHYSICAL interfaces only — for matching a peer's host candidate
4479
+ * to OUR real LAN (so we never match a peer's Tailscale/TUN address against our
4480
+ * own overlay subnet). */
4481
+ function getPhysicalLanSubnets() {
4482
+ const out = [];
4483
+ try {
4484
+ for (const [name, list] of Object.entries(networkInterfaces())) {
4485
+ if (!list || VIRTUAL_IFACE_RE.test(name))
4486
+ continue;
4487
+ for (const info of list) {
4488
+ if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
4489
+ continue;
4490
+ const addr = ipv4ToInt(info.address);
4491
+ const mask = netmaskToInt(info.netmask);
4492
+ if (addr === undefined || mask === undefined || mask === 0)
4493
+ continue;
4494
+ out.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
4495
+ }
4496
+ }
4497
+ }
4498
+ catch {
4499
+ // best-effort
4500
+ }
4501
+ return out;
4502
+ }
4433
4503
  function isPrivateAddress(host) {
4434
4504
  // RFC1918 IPv4: 10/8, 172.16/12, 192.168/16; loopback 127/8;
4435
4505
  // link-local 169.254/16; carrier-grade NAT 100.64/10. IPv6 link-local fe80::/10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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",