@decentnetwork/peer 0.1.67 → 0.1.68

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
@@ -201,6 +201,10 @@ export declare class Peer {
201
201
  * peer (null = never). Used to confirm Phase 1.2 retries are
202
202
  * actually firing. */
203
203
  cookieRequestSentMs: number | null;
204
+ /** The peer's physical-LAN host we've locked onto (set by the periodic
205
+ * offer/maintenance LAN-adoption code). When set and equal to udpRemote's
206
+ * host, the per-packet receive path refuses a public/hairpin downgrade. */
207
+ lanRemoteHost: string | null;
204
208
  } | null;
205
209
  waitForFriendRequest(timeoutMs?: number): Promise<FriendRequest>;
206
210
  /**
package/dist/peer.js CHANGED
@@ -82,6 +82,11 @@ 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
+ // How long the direct UDP path may go without an inbound packet before we
86
+ // release a physical-LAN lock (so a peer that left the LAN can recover onto the
87
+ // public/relay path). 12s ≈ 3 missed 4s keepalives — long enough to never trip
88
+ // on a healthy same-LAN path, short enough to recover quickly.
89
+ const LAN_LOCK_STALE_MS = readEnvInt("DECENT_LAN_LOCK_STALE_MS", 12000);
85
90
  // Half-open breaker: if the peer proposes a NEW session key this many times over
86
91
  // this long while our session looks "alive", accept it (the peer can't decrypt
87
92
  // our current session — a desynced half-open session, observed as a flood of
@@ -1084,6 +1089,7 @@ export class Peer {
1084
1089
  endpointCandidatesCount: s.endpointCandidates?.length ?? 0,
1085
1090
  endpointCandidates: (s.endpointCandidates ?? []).map((c) => `${c.host}:${c.port}`),
1086
1091
  cookieRequestSentMs: s.cookieRequestSentMs ?? null,
1092
+ lanRemoteHost: s.lanRemoteHost ?? null,
1087
1093
  };
1088
1094
  }
1089
1095
  waitForFriendRequest(timeoutMs = 30000) {
@@ -1386,7 +1392,13 @@ export class Peer {
1386
1392
  state.hasTcpRoute = true;
1387
1393
  }
1388
1394
  else {
1389
- state.remote = { host: remote.address, port: remote.port };
1395
+ // Same LAN-lock guard as the crypto-data path: a duplicate handshake
1396
+ // retransmitted via the public/hairpin endpoint must not knock us off
1397
+ // a locked physical-LAN path. Cheap string compare — no syscall.
1398
+ const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
1399
+ if (!lockedOnLan || remote.address === state.lanRemoteHost) {
1400
+ state.remote = { host: remote.address, port: remote.port };
1401
+ }
1390
1402
  }
1391
1403
  state.lastPingRecvMs = Date.now();
1392
1404
  this.#debugVerboseLog(`hs_recv duplicate friend=${friendId} remote=${remote.address}:${remote.port} (session preserved)`);
@@ -1476,7 +1488,13 @@ export class Peer {
1476
1488
  // Don't set state.remote — there's no UDP endpoint for this peer.
1477
1489
  }
1478
1490
  else {
1479
- state.remote = { host: remote.address, port: remote.port };
1491
+ // LAN-lock guard (see crypto-data path): don't let an establish/
1492
+ // re-handshake arriving over the public/hairpin endpoint downgrade us
1493
+ // off a locked physical-LAN path. Cheap string compare — no syscall.
1494
+ const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
1495
+ if (!lockedOnLan || remote.address === state.lanRemoteHost) {
1496
+ state.remote = { host: remote.address, port: remote.port };
1497
+ }
1480
1498
  }
1481
1499
  if (isNewSession) {
1482
1500
  state.sendPacketNumber = 0;
@@ -3144,6 +3162,15 @@ export class Peer {
3144
3162
  session.endpointCandidates = next
3145
3163
  .sort((a, b) => b.updatedMs - a.updatedMs)
3146
3164
  .slice(0, 12);
3165
+ // The moment we learn the peer's physical-LAN host candidate, record it as
3166
+ // the LAN lock so the per-packet/handshake hot paths refuse a public/hairpin
3167
+ // downgrade (cheap string compare there, no syscall). This runs at
3168
+ // candidate-learn time (offer/discovery), not per packet, so the cached
3169
+ // getPhysicalLanSubnets() call is fine here.
3170
+ if (isPrivateAddress(host) && !isCgnatAddress(host) &&
3171
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(host, s))) {
3172
+ session.lanRemoteHost = host;
3173
+ }
3147
3174
  }
3148
3175
  /**
3149
3176
  * Learn our own server-reflexive (public) UDP endpoint by sending a
@@ -4061,6 +4088,17 @@ export class Peer {
4061
4088
  for (const [friendId, session] of this.#friendSessions.entries()) {
4062
4089
  if (!session.established)
4063
4090
  continue;
4091
+ // Release a stale LAN lock. If we're locked onto a LAN host but the
4092
+ // direct UDP path has gone quiet (peer left the LAN / changed network),
4093
+ // the lock would otherwise block recovery onto the public/relay path.
4094
+ // Clearing it lets the hot-path guard adopt a working endpoint again.
4095
+ if (session.lanRemoteHost &&
4096
+ session.remote?.host === session.lanRemoteHost &&
4097
+ session.lastUdpRecvMs !== undefined &&
4098
+ Date.now() - session.lastUdpRecvMs > LAN_LOCK_STALE_MS) {
4099
+ this.#debugLog(`lan_lock_released friend=${friendId} host=${session.lanRemoteHost} (no direct UDP recv for >${LAN_LOCK_STALE_MS}ms)`);
4100
+ session.lanRemoteHost = undefined;
4101
+ }
4064
4102
  const r = session.remote;
4065
4103
  const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
4066
4104
  getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
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",