@decentnetwork/peer 0.1.66 → 0.1.67

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
@@ -82,6 +82,23 @@ const FRIEND_PING_INTERVAL_MS = readEnvInt("DECENT_FRIEND_PING_INTERVAL_MS", 400
82
82
  // slow enough not to burn CPU on idle peers. Tunable via env.
83
83
  const FRIEND_CONNECTION_LOOP_MS = readEnvInt("DECENT_FRIEND_CONNECTION_LOOP_MS", 250);
84
84
  const FRIEND_TIMEOUT_MS = readEnvInt("DECENT_FRIEND_TIMEOUT_MS", 32000);
85
+ // Half-open breaker: if the peer proposes a NEW session key this many times over
86
+ // this long while our session looks "alive", accept it (the peer can't decrypt
87
+ // our current session — a desynced half-open session, observed as a flood of
88
+ // "no session matched"). Each handshake carries a fresh cookie so these are
89
+ // genuine attempts, not replays. Bounded so it never fires on a healthy session.
90
+ const REHS_ACCEPT_COUNT = readEnvInt("DECENT_REHS_ACCEPT_COUNT", 4);
91
+ const REHS_ACCEPT_MS = readEnvInt("DECENT_REHS_ACCEPT_MS", 2500);
92
+ const REHS_WINDOW_MS = readEnvInt("DECENT_REHS_WINDOW_MS", 8000);
93
+ // Re-handshake when we keep receiving undecryptable crypto data from a known
94
+ // friend's endpoint (desynced sessions), rate-limited per friend.
95
+ // Conservative: deleting a session forces a re-handshake which ROTATES our
96
+ // ephemeral key, so doing it too eagerly turns a transient blip into a
97
+ // rotate→desync→delete thrash. Only nuke a session that's been UNABLE to
98
+ // decrypt anything for a long time (genuinely dead, well past any
99
+ // establishment-transition window), and at most once per long interval.
100
+ const REINIT_ON_DESYNC_MS = readEnvInt("DECENT_REINIT_ON_DESYNC_MS", 12000);
101
+ const REINIT_STUCK_MS = readEnvInt("DECENT_REINIT_STUCK_MS", 20000);
85
102
  // How long a relay path stays "confirmed" (carrying bulk data on its own, no
86
103
  // tcp-relay fan-out) after the last packet received over it. The relay
87
104
  // keepalive refreshes this every ~4s on a healthy path, so a generous window
@@ -256,6 +273,9 @@ export class Peer {
256
273
  // #initiateSession every few seconds for every friend; without this
257
274
  // dedupe, lower-pubkey side floods the log with "deferring to peer".
258
275
  #initiateSkipLogged = new Set();
276
+ // Per-friend last time we deleted a desynced session (rate-limit; survives the
277
+ // session deletion, unlike a field on the session object itself).
278
+ #lastDesyncDeleteMs = new Map();
259
279
  // Cached server-reflexive (public) UDP endpoint of our #udp socket,
260
280
  // learned via STUN. Cone-NAT mappings are stable for the socket
261
281
  // lifetime, so we only re-probe every ~60s. Used to tell friends where
@@ -1062,6 +1082,7 @@ export class Peer {
1062
1082
  ourLocalUdpPort: this.#udp?.localPort() ?? null,
1063
1083
  friendUdpEndpoint: friendUdp,
1064
1084
  endpointCandidatesCount: s.endpointCandidates?.length ?? 0,
1085
+ endpointCandidates: (s.endpointCandidates ?? []).map((c) => `${c.host}:${c.port}`),
1065
1086
  cookieRequestSentMs: s.cookieRequestSentMs ?? null,
1066
1087
  };
1067
1088
  }
@@ -1435,9 +1456,10 @@ export class Peer {
1435
1456
  const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
1436
1457
  const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
1437
1458
  if (sinceEstablished > 1000 && currentSessionAlive) {
1438
- // Verbose only peers retransmit handshakes aggressively, so
1439
- // this line fires dozens of times per minute on a stuck pair
1440
- // and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
1459
+ // Reject a new-key handshake while our session is alive — toxcore does
1460
+ // the same. (The "half-open breaker" that accepted these after N tries
1461
+ // was reverted: accepting a new key ROTATES the session, which churned
1462
+ // healthy sessions; the real fix was the TURN-crash fix, not this.)
1441
1463
  this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
1442
1464
  return;
1443
1465
  }
@@ -1470,13 +1492,21 @@ export class Peer {
1470
1492
  this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
1471
1493
  if (!wasEstablished) {
1472
1494
  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) {
1495
+ // Send our UDP endpoint offer (public srflx + LAN host candidate)
1496
+ // whenever the current path isn't already a same-LAN direct one:
1497
+ // - relay-only sessions need it to bootstrap a direct UDP path; and
1498
+ // - sessions that came up over a PUBLIC UDP endpoint (internet
1499
+ // hairpin) when both peers are actually on the SAME LAN — without
1500
+ // the offer they never learn each other's 10.x/192.168.x address and
1501
+ // stay stuck on the public path, which a symmetric / hairpin NAT
1502
+ // breaks one-way (observed: two boxes on one LAN where each only saw
1503
+ // the other one-directionally). The peer's same-subnet filter drops
1504
+ // the LAN candidate when it doesn't apply, so offering is always safe.
1505
+ const r = state.remote;
1506
+ const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
1507
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
1508
+ const haveLanToOffer = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
1509
+ if ((state.hasTcpRoute && !r) || (!remoteIsSameLan && haveLanToOffer)) {
1480
1510
  void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
1481
1511
  }
1482
1512
  }
@@ -1598,34 +1628,54 @@ export class Peer {
1598
1628
  innerKind === PACKET_ID_FILE_SENDREQUEST ||
1599
1629
  innerKind === PACKET_ID_FILE_CONTROL ||
1600
1630
  innerKind === PACKET_ID_FILE_DATA;
1601
- if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
1602
- continue;
1603
- }
1604
- state.peerBaseNonce[state.peerBaseNonce.length - 2] = packet[1];
1605
- state.peerBaseNonce[state.peerBaseNonce.length - 1] = packet[2];
1606
- incrementNonce(state.peerBaseNonce);
1631
+ // Confirm the transport path + liveness for EVERY decrypted packet —
1632
+ // INCLUDING a dual-send duplicate. Receiving over this transport proves
1633
+ // it works, so this MUST run BEFORE the dedup drop below. Otherwise the
1634
+ // duplicate UDP copy (the relay copy already advanced the buffer) is
1635
+ // dropped without ever marking the direct-UDP path live → lastUdpRecvMs
1636
+ // stays unset → udpFresh false → bulk data (files) never ride the LAN
1637
+ // even when LAN UDP is flowing both ways (the snoopy slow-file bug).
1607
1638
  state.lastPingRecvMs = Date.now();
1608
- // Same TCP-vs-UDP rule as the handshake handlers: don't poison
1609
- // the UDP send-back endpoint when this packet came in via TCP.
1610
1639
  if (this.#remoteIsTcp(remote)) {
1611
1640
  state.hasTcpRoute = true;
1612
1641
  }
1613
1642
  else if (viaRelay) {
1614
- // Arrived over the TURN relay. Track relay freshness separately;
1615
- // do NOT set state.remote (that's the direct endpoint). The relay
1616
- // address it came from is already in state.relayRemote.
1617
1643
  if (!state.lastRelayRecvMs) {
1618
1644
  this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
1619
1645
  }
1620
1646
  state.lastRelayRecvMs = Date.now();
1621
1647
  }
1622
1648
  else {
1623
- state.remote = { host: remote.address, port: remote.port };
1649
+ // Adopt the source as our send endpoint — but NEVER downgrade from a
1650
+ // locked same-LAN (physical) path to a non-LAN source (a hairpin packet
1651
+ // via the peer's public / Tailscale-exit endpoint must not knock us off
1652
+ // the direct LAN path, which is strictly better).
1653
+ //
1654
+ // CCTV-safe: the LAN decision (which needs a networkInterfaces() syscall)
1655
+ // is made ONLY in the periodic offer/maintenance paths, which record the
1656
+ // locked peer LAN host in state.lanRemoteHost. Here in the per-packet hot
1657
+ // path we do a single string compare — NO interface syscall — so a
1658
+ // high-rate stream (CCTV: thousands of packets/sec) never pays for it.
1659
+ // (The per-packet getPhysicalLanSubnets() call this replaces was the
1660
+ // CCTV CPU regression.)
1661
+ const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
1662
+ if (!lockedOnLan || remote.address === state.lanRemoteHost) {
1663
+ state.remote = { host: remote.address, port: remote.port };
1664
+ }
1624
1665
  if (!state.lastUdpRecvMs) {
1625
1666
  this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
1626
1667
  }
1627
1668
  state.lastUdpRecvMs = Date.now();
1628
1669
  }
1670
+ // De-duplicate: skip the DISPATCH + nonce advance for an already-seen
1671
+ // packet number. The transport confirmation above already ran, so a
1672
+ // duplicate still keeps the path fresh.
1673
+ if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
1674
+ continue;
1675
+ }
1676
+ state.peerBaseNonce[state.peerBaseNonce.length - 2] = packet[1];
1677
+ state.peerBaseNonce[state.peerBaseNonce.length - 1] = packet[2];
1678
+ incrementNonce(state.peerBaseNonce);
1629
1679
  state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
1630
1680
  const kind = opened.payload[0];
1631
1681
  const inner = opened.payload.slice(1);
@@ -1778,7 +1828,16 @@ export class Peer {
1778
1828
  this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
1779
1829
  return;
1780
1830
  }
1781
- this.#debugLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
1831
+ // Reverted: we used to DELETE a "desynced" session here to force a clean
1832
+ // re-handshake. It caused more harm than good — deleting a session
1833
+ // interrupts the data plane, and a transient/late undecryptable packet
1834
+ // (common over the relay) triggered a delete→re-key→re-desync thrash that
1835
+ // churned MANY friends' sessions (observed: CCTV jitter + slow file
1836
+ // transfer to 4090, not just snoopy). The real instability was the daemon
1837
+ // CRASH (TURN DNS); with that fixed, sessions stay converged on their own.
1838
+ // Just drop the undecryptable packet — net_crypto's normal re-handshake
1839
+ // path recovers a genuinely dead session.
1840
+ this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
1782
1841
  return;
1783
1842
  }
1784
1843
  if (packet[0] === NET_PACKET_ONION_DATA_RESPONSE && this.#announceDataKey) {
@@ -3177,11 +3236,13 @@ export class Peer {
3177
3236
  resolve();
3178
3237
  });
3179
3238
  });
3180
- // CRITICAL: persistent 'error' listener. A TURN send to an unresolvable
3181
- // host (transient DNS NXDOMAIN, e.g. tokyo.fi.chat) emits an 'error' on
3182
- // this dgram socket; with no listener Node re-throws it as an uncaught
3183
- // exception and CRASHES THE WHOLE DAEMON (every session dropped, mass
3184
- // re-handshake churn). Swallow it the relay just won't allocate here.
3239
+ // CRITICAL: a persistent 'error' listener. A TURN send to an
3240
+ // unresolvable host (DNS NXDOMAIN e.g. tokyo.fi.chat on a box whose
3241
+ // resolver doesn't know it) emits an 'error' on this dgram socket;
3242
+ // with no listener, Node re-throws it as an uncaught exception and
3243
+ // CRASHES THE ENTIRE DAEMON (observed: snoopy repeatedly dying on
3244
+ // "getaddrinfo ENOTFOUND tokyo.fi.chat", which churned every session).
3245
+ // Swallow it — the relay simply won't allocate/relay over this server.
3185
3246
  sock.on("error", (e) => this.#debugLog(`turn socket error (${srv.host}): ${e.message}`));
3186
3247
  const client = new TurnClient({
3187
3248
  sock,
@@ -3351,10 +3412,24 @@ export class Peer {
3351
3412
  if (++ln >= 6)
3352
3413
  clearInterval(lanTimer);
3353
3414
  }, 120);
3354
- const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
3355
- if (!haveRealUdp) {
3415
+ // The physical LAN is strictly better than ANY public path — direct, no
3416
+ // NAT, no relay, and crucially no Tailscale-exit hairpin (a peer whose
3417
+ // internet egresses via Tailscale advertises its exit's public IP as
3418
+ // its srflx, so the "direct" public path actually loops out through
3419
+ // Tailscale and back). So adopt the LAN candidate as session.remote even
3420
+ // when we already have a public "real UDP" remote — UNLESS we're already
3421
+ // on a same-LAN address (don't flap between two LAN candidates). Without
3422
+ // this the session stuck to the public/Tailscale endpoint and never sent
3423
+ // over the LAN (observed: session.remote stayed public, data on relay).
3424
+ const cur = session.remote?.host;
3425
+ const onSameLanAlready = !!cur && !cur.startsWith("tcp:") && isPrivateAddress(cur) && !isCgnatAddress(cur) &&
3426
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(cur, s));
3427
+ if (!onSameLanAlready) {
3356
3428
  session.remote = { host: lanHost, port: lanPort };
3357
3429
  }
3430
+ // Record the locked LAN host so the per-packet receive path can refuse a
3431
+ // public/hairpin downgrade with a plain string compare (no syscall).
3432
+ session.lanRemoteHost = lanHost;
3358
3433
  if (session.established) {
3359
3434
  void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
3360
3435
  }
@@ -3389,7 +3464,14 @@ export class Peer {
3389
3464
  // upgrading the path to direct UDP in both directions with no new
3390
3465
  // handshake. If UDP never works, the periodic offer re-punches.
3391
3466
  const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
3392
- if (!haveRealUdp) {
3467
+ // NEVER point the session at the public srflx when we know a same-LAN
3468
+ // candidate for this peer: the physical LAN is strictly better, and letting
3469
+ // the srflx win here caused a LAN↔public FLAP (the LAN block above set
3470
+ // remote=LAN, then this overwrote it with the Tailscale-exit public IP),
3471
+ // so the direct LAN path never stabilised and udpRecv never confirmed.
3472
+ const haveLanCandidate = (session.endpointCandidates ?? []).some((c) => isPrivateAddress(c.host) && !isCgnatAddress(c.host) &&
3473
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(c.host, s)));
3474
+ if (!haveRealUdp && !haveLanCandidate) {
3393
3475
  session.remote = { host, port };
3394
3476
  }
3395
3477
  // Nudge a keepalive out immediately so the upgrade doesn't wait for
@@ -3967,6 +4049,46 @@ export class Peer {
3967
4049
  if (!node.isTcp)
3968
4050
  void this.#sendDhtPing(node);
3969
4051
  }
4052
+ // 4. LAN-direct upgrade. Re-offer our endpoint (public srflx + LAN host
4053
+ // candidate) to any established friend whose current path is NOT already
4054
+ // a same-LAN address, as long as we have a LAN address to advertise. This
4055
+ // upgrades sessions that came up over a public UDP endpoint (internet
4056
+ // hairpin) to the direct LAN path even without a re-establish, and self-
4057
+ // stops once the path becomes same-LAN. The peer's same-subnet filter
4058
+ // drops the candidate when it doesn't apply, so this is always safe.
4059
+ const haveLan = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
4060
+ if (haveLan) {
4061
+ for (const [friendId, session] of this.#friendSessions.entries()) {
4062
+ if (!session.established)
4063
+ continue;
4064
+ const r = session.remote;
4065
+ const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
4066
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
4067
+ if (remoteIsSameLan) {
4068
+ // Already on a physical-LAN path (e.g. the session established directly
4069
+ // over LAN). Lock it so the per-packet hot path's cheap string-compare
4070
+ // guard refuses any later public/hairpin downgrade — no syscall there.
4071
+ session.lanRemoteHost = r.host;
4072
+ }
4073
+ if (!remoteIsSameLan) {
4074
+ // If we ALREADY hold a same-LAN candidate for this peer (learned from
4075
+ // an earlier offer), switch the session onto it NOW — don't wait to
4076
+ // receive yet another offer at exactly the right moment. That delay is
4077
+ // what kept sessions stuck on the public/Tailscale-hairpin path after
4078
+ // the staggered restarts. Then send a keepalive over the LAN so the
4079
+ // peer learns our LAN source and replies over it; the downgrade guard
4080
+ // keeps us there once it sticks.
4081
+ const lanCand = (session.endpointCandidates ?? []).find((c) => isPrivateAddress(c.host) && !isCgnatAddress(c.host) &&
4082
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(c.host, s)));
4083
+ if (lanCand) {
4084
+ session.remote = { host: lanCand.host, port: lanCand.port };
4085
+ session.lanRemoteHost = lanCand.host;
4086
+ void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
4087
+ }
4088
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
4089
+ }
4090
+ }
4091
+ }
3970
4092
  }
3971
4093
  /**
3972
4094
  * Send an onion DHT-PK announcement to a friend so they learn our DHT
@@ -4899,7 +5021,15 @@ function isInIpv4Subnet(host, subnet) {
4899
5021
  // path onto the overlay or loops into our own mesh, then silently falls back to
4900
5022
  // the relay — the snoopy/lili "lastUdpRecv=never -> bufferbloat" path. (lili
4901
5023
  // runs Tailscale; that's exactly this.)
4902
- const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|zt|ham|agentnet|awdl|llw|gif|stf|bridge|vnic|vmnet|veth|docker)/i;
5024
+ // Names of interfaces that are NOT a real physical LAN — overlays (tailscale,
5025
+ // zerotier, wireguard, the agentnet TUN), and especially container/VM bridges.
5026
+ // Docker creates `docker0` AND per-network `br-<hash>` bridges (172.x); libvirt
5027
+ // `virbr0`; k8s `cni0`/`flannel`/`kube`. These were leaking their 172.x address
5028
+ // into the LAN host-candidate offer, so a peer on a real 10.x LAN advertised a
5029
+ // Docker-bridge IP its LAN partner could never match → LAN-direct never engaged
5030
+ // (observed: two boxes on one 10.0.0.0/24 stuck hairpinning through the public
5031
+ // internet). `^bridge` did NOT cover `br-…`.
5032
+ 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;
4903
5033
  /** True for RFC 6598 carrier-grade-NAT space 100.64.0.0/10 (Tailscale's range). */
4904
5034
  function isCgnatAddress(host) {
4905
5035
  const o = host.split(".");
@@ -4911,50 +5041,53 @@ function isCgnatAddress(host) {
4911
5041
  }
4912
5042
  /** Local IPv4 addresses on PHYSICAL interfaces only (excludes overlay/virtual
4913
5043
  * interfaces by name and the CGNAT range). Used for LAN-direct host candidates. */
4914
- function getPhysicalLanAddresses() {
4915
- const out = [];
5044
+ // CACHED interface enumeration. getPhysicalLanSubnets/Addresses are called on
5045
+ // the receive hot path (the same-LAN downgrade guard runs per packet); calling
5046
+ // networkInterfaces() — a syscall — for every CRYPTO_DATA packet pegged the CPU
5047
+ // on a high-rate stream (CCTV jitter/instability). Cache the result for 5s; LAN
5048
+ // interfaces almost never change, so this is safe and ~free.
5049
+ let _lanIfaceCacheMs = 0;
5050
+ let _lanAddrsCache = [];
5051
+ let _lanSubnetsCache = [];
5052
+ function refreshLanIfaceCache() {
5053
+ const now = Date.now();
5054
+ if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
5055
+ return;
5056
+ _lanIfaceCacheMs = now;
5057
+ const addrs = [];
5058
+ const subnets = [];
4916
5059
  try {
4917
5060
  for (const [name, list] of Object.entries(networkInterfaces())) {
4918
5061
  if (!list || VIRTUAL_IFACE_RE.test(name))
4919
5062
  continue;
4920
5063
  for (const info of list) {
4921
- if (info.family !== "IPv4" || info.internal)
4922
- continue;
4923
- if (isCgnatAddress(info.address))
5064
+ if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
4924
5065
  continue;
4925
- out.push(info.address);
5066
+ addrs.push(info.address);
5067
+ const addr = ipv4ToInt(info.address);
5068
+ const mask = netmaskToInt(info.netmask);
5069
+ if (addr !== undefined && mask !== undefined && mask !== 0) {
5070
+ subnets.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
5071
+ }
4926
5072
  }
4927
5073
  }
4928
5074
  }
4929
5075
  catch {
4930
5076
  // best-effort
4931
5077
  }
4932
- return out;
5078
+ _lanAddrsCache = addrs;
5079
+ _lanSubnetsCache = subnets;
5080
+ }
5081
+ function getPhysicalLanAddresses() {
5082
+ refreshLanIfaceCache();
5083
+ return _lanAddrsCache;
4933
5084
  }
4934
5085
  /** Subnets of PHYSICAL interfaces only — for matching a peer's host candidate
4935
5086
  * to OUR real LAN (so we never match a peer's Tailscale/TUN address against our
4936
- * own overlay subnet). */
5087
+ * own overlay subnet). Cached (see refreshLanIfaceCache). */
4937
5088
  function getPhysicalLanSubnets() {
4938
- const out = [];
4939
- try {
4940
- for (const [name, list] of Object.entries(networkInterfaces())) {
4941
- if (!list || VIRTUAL_IFACE_RE.test(name))
4942
- continue;
4943
- for (const info of list) {
4944
- if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
4945
- continue;
4946
- const addr = ipv4ToInt(info.address);
4947
- const mask = netmaskToInt(info.netmask);
4948
- if (addr === undefined || mask === undefined || mask === 0)
4949
- continue;
4950
- out.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
4951
- }
4952
- }
4953
- }
4954
- catch {
4955
- // best-effort
4956
- }
4957
- return out;
5089
+ refreshLanIfaceCache();
5090
+ return _lanSubnetsCache;
4958
5091
  }
4959
5092
  function isPrivateAddress(host) {
4960
5093
  // RFC1918 IPv4: 10/8, 172.16/12, 192.168/16; loopback 127/8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
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",