@decentnetwork/peer 0.1.55 → 0.1.57

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.
package/dist/peer.d.ts CHANGED
@@ -194,6 +194,9 @@ export declare class Peer {
194
194
  * tcp-relay, discovery itself is broken — we don't even know
195
195
  * where to send a cookie request. */
196
196
  endpointCandidatesCount: number;
197
+ /** The actual candidate endpoints (host:port) — lets diag see whether a
198
+ * same-LAN host candidate (e.g. 10.0.0.x) was received but not adopted. */
199
+ endpointCandidates: string[];
197
200
  /** Last time we sent an outbound cookie request via UDP for this
198
201
  * peer (null = never). Used to confirm Phase 1.2 retries are
199
202
  * actually firing. */
package/dist/peer.js CHANGED
@@ -1062,6 +1062,7 @@ export class Peer {
1062
1062
  ourLocalUdpPort: this.#udp?.localPort() ?? null,
1063
1063
  friendUdpEndpoint: friendUdp,
1064
1064
  endpointCandidatesCount: s.endpointCandidates?.length ?? 0,
1065
+ endpointCandidates: (s.endpointCandidates ?? []).map((c) => `${c.host}:${c.port}`),
1065
1066
  cookieRequestSentMs: s.cookieRequestSentMs ?? null,
1066
1067
  };
1067
1068
  }
@@ -1470,13 +1471,21 @@ export class Peer {
1470
1471
  this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
1471
1472
  if (!wasEstablished) {
1472
1473
  this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
1473
- // If we only have a TCP-relay path, immediately tell the peer our
1474
- // public UDP endpoint so they can hole-punch don't wait for the
1475
- // 15s UDP-retry loop, which can miss a flapping relay session's
1476
- // short-lived establishment window. Both sides do this on every
1477
- // (re)establishment, so a brief tcp-relay window is enough to
1478
- // bootstrap the direct UDP path. See docs/UDP-DIRECT-PLAN.md.
1479
- if (state.hasTcpRoute && !state.remote) {
1474
+ // Send our UDP endpoint offer (public srflx + LAN host candidate)
1475
+ // whenever the current path isn't already a same-LAN direct one:
1476
+ // - relay-only sessions need it to bootstrap a direct UDP path; and
1477
+ // - sessions that came up over a PUBLIC UDP endpoint (internet
1478
+ // hairpin) when both peers are actually on the SAME LAN — without
1479
+ // the offer they never learn each other's 10.x/192.168.x address and
1480
+ // stay stuck on the public path, which a symmetric / hairpin NAT
1481
+ // breaks one-way (observed: two boxes on one LAN where each only saw
1482
+ // the other one-directionally). The peer's same-subnet filter drops
1483
+ // the LAN candidate when it doesn't apply, so offering is always safe.
1484
+ const r = state.remote;
1485
+ const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
1486
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
1487
+ const haveLanToOffer = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
1488
+ if ((state.hasTcpRoute && !r) || (!remoteIsSameLan && haveLanToOffer)) {
1480
1489
  void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
1481
1490
  }
1482
1491
  }
@@ -1620,7 +1629,19 @@ export class Peer {
1620
1629
  state.lastRelayRecvMs = Date.now();
1621
1630
  }
1622
1631
  else {
1623
- state.remote = { host: remote.address, port: remote.port };
1632
+ // Adopt the source as our send endpoint — but NEVER downgrade from a
1633
+ // same-LAN (physical) path to a non-LAN source. A packet that loops in
1634
+ // via the peer's public / Tailscale-exit endpoint must not knock us off
1635
+ // the direct LAN path, which is strictly better. (This is the other
1636
+ // half of the "use the physical LAN, not Tailscale" rule.)
1637
+ const cur = state.remote?.host;
1638
+ const onSameLanAlready = !!cur && !cur.startsWith("tcp:") && isPrivateAddress(cur) && !isCgnatAddress(cur) &&
1639
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(cur, s));
1640
+ const incomingSameLan = isPrivateAddress(remote.address) && !isCgnatAddress(remote.address) &&
1641
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(remote.address, s));
1642
+ if (incomingSameLan || !onSameLanAlready) {
1643
+ state.remote = { host: remote.address, port: remote.port };
1644
+ }
1624
1645
  if (!state.lastUdpRecvMs) {
1625
1646
  this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
1626
1647
  }
@@ -3345,8 +3366,19 @@ export class Peer {
3345
3366
  if (++ln >= 6)
3346
3367
  clearInterval(lanTimer);
3347
3368
  }, 120);
3348
- const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
3349
- if (!haveRealUdp) {
3369
+ // The physical LAN is strictly better than ANY public path — direct, no
3370
+ // NAT, no relay, and crucially no Tailscale-exit hairpin (a peer whose
3371
+ // internet egresses via Tailscale advertises its exit's public IP as
3372
+ // its srflx, so the "direct" public path actually loops out through
3373
+ // Tailscale and back). So adopt the LAN candidate as session.remote even
3374
+ // when we already have a public "real UDP" remote — UNLESS we're already
3375
+ // on a same-LAN address (don't flap between two LAN candidates). Without
3376
+ // this the session stuck to the public/Tailscale endpoint and never sent
3377
+ // over the LAN (observed: session.remote stayed public, data on relay).
3378
+ const cur = session.remote?.host;
3379
+ const onSameLanAlready = !!cur && !cur.startsWith("tcp:") && isPrivateAddress(cur) && !isCgnatAddress(cur) &&
3380
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(cur, s));
3381
+ if (!onSameLanAlready) {
3350
3382
  session.remote = { host: lanHost, port: lanPort };
3351
3383
  }
3352
3384
  if (session.established) {
@@ -3961,6 +3993,25 @@ export class Peer {
3961
3993
  if (!node.isTcp)
3962
3994
  void this.#sendDhtPing(node);
3963
3995
  }
3996
+ // 4. LAN-direct upgrade. Re-offer our endpoint (public srflx + LAN host
3997
+ // candidate) to any established friend whose current path is NOT already
3998
+ // a same-LAN address, as long as we have a LAN address to advertise. This
3999
+ // upgrades sessions that came up over a public UDP endpoint (internet
4000
+ // hairpin) to the direct LAN path even without a re-establish, and self-
4001
+ // stops once the path becomes same-LAN. The peer's same-subnet filter
4002
+ // drops the candidate when it doesn't apply, so this is always safe.
4003
+ const haveLan = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
4004
+ if (haveLan) {
4005
+ for (const [friendId, session] of this.#friendSessions.entries()) {
4006
+ if (!session.established)
4007
+ continue;
4008
+ const r = session.remote;
4009
+ const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
4010
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
4011
+ if (!remoteIsSameLan)
4012
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
4013
+ }
4014
+ }
3964
4015
  }
3965
4016
  /**
3966
4017
  * Send an onion DHT-PK announcement to a friend so they learn our DHT
@@ -4893,7 +4944,15 @@ function isInIpv4Subnet(host, subnet) {
4893
4944
  // path onto the overlay or loops into our own mesh, then silently falls back to
4894
4945
  // the relay — the snoopy/lili "lastUdpRecv=never -> bufferbloat" path. (lili
4895
4946
  // runs Tailscale; that's exactly this.)
4896
- const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|zt|ham|agentnet|awdl|llw|gif|stf|bridge|vnic|vmnet|veth|docker)/i;
4947
+ // Names of interfaces that are NOT a real physical LAN — overlays (tailscale,
4948
+ // zerotier, wireguard, the agentnet TUN), and especially container/VM bridges.
4949
+ // Docker creates `docker0` AND per-network `br-<hash>` bridges (172.x); libvirt
4950
+ // `virbr0`; k8s `cni0`/`flannel`/`kube`. These were leaking their 172.x address
4951
+ // into the LAN host-candidate offer, so a peer on a real 10.x LAN advertised a
4952
+ // Docker-bridge IP its LAN partner could never match → LAN-direct never engaged
4953
+ // (observed: two boxes on one 10.0.0.0/24 stuck hairpinning through the public
4954
+ // internet). `^bridge` did NOT cover `br-…`.
4955
+ const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|zt|ham|agentnet|awdl|llw|gif|stf|bridge|br-|virbr|cni|flannel|weave|kube|vnet|vnic|vmnet|veth|docker)/i;
4897
4956
  /** True for RFC 6598 carrier-grade-NAT space 100.64.0.0/10 (Tailscale's range). */
4898
4957
  function isCgnatAddress(host) {
4899
4958
  const o = host.split(".");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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",