@decentnetwork/peer 0.1.62 → 0.1.64

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.
Files changed (2) hide show
  1. package/dist/peer.js +51 -18
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -92,7 +92,13 @@ const REHS_ACCEPT_MS = readEnvInt("DECENT_REHS_ACCEPT_MS", 2500);
92
92
  const REHS_WINDOW_MS = readEnvInt("DECENT_REHS_WINDOW_MS", 8000);
93
93
  // Re-handshake when we keep receiving undecryptable crypto data from a known
94
94
  // friend's endpoint (desynced sessions), rate-limited per friend.
95
- const REINIT_ON_DESYNC_MS = readEnvInt("DECENT_REINIT_ON_DESYNC_MS", 2000);
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);
96
102
  // How long a relay path stays "confirmed" (carrying bulk data on its own, no
97
103
  // tcp-relay fan-out) after the last packet received over it. The relay
98
104
  // keepalive refreshes this every ~4s on a healthy path, so a generous window
@@ -267,6 +273,9 @@ export class Peer {
267
273
  // #initiateSession every few seconds for every friend; without this
268
274
  // dedupe, lower-pubkey side floods the log with "deferring to peer".
269
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();
270
279
  // Cached server-reflexive (public) UDP endpoint of our #udp socket,
271
280
  // learned via STUN. Cone-NAT mappings are stable for the socket
272
281
  // lifetime, so we only re-probe every ~60s. Used to tell friends where
@@ -1832,24 +1841,41 @@ export class Peer {
1832
1841
  this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
1833
1842
  return;
1834
1843
  }
1835
- // We received crypto data we can't decrypt from a KNOWN friend's endpoint:
1836
- // our two sessions are desynced (different keys) and NEITHER side will
1837
- // re-handshake on its own, because each believes it is still established
1838
- // (the half-open breaker can't help no handshakes are being exchanged).
1839
- // Kick a fresh handshake so we re-converge. Rate-limited per friend so a
1840
- // flood of undecryptable packets doesn't spam cookie requests.
1841
- if (!this.#remoteIsTcp(remote) && !viaRelay) {
1844
+ // We received crypto data we can't decrypt from a KNOWN friend: our two
1845
+ // sessions are desynced (different keys) and both are "established", so
1846
+ // neither re-handshakes (#initiateSession no-ops on an established session,
1847
+ // and the lower-pubkey side defers entirely). DELETE the stale session so
1848
+ // the connection loop re-establishes cleanly via the single-initiator path
1849
+ // (higher pubkey initiates a fresh cookie chain, lower responds, both
1850
+ // derive the SAME key). This covers BOTH transports — the desync shows up
1851
+ // over the TCP relay (remote = tcp:<friendId>:0) as well as direct UDP.
1852
+ let desyncedFid;
1853
+ if (this.#remoteIsTcp(remote)) {
1854
+ const m = /^tcp:([A-Za-z0-9]+)/.exec(remote.address);
1855
+ if (m && this.#friendSessions.has(m[1]))
1856
+ desyncedFid = m[1];
1857
+ }
1858
+ else if (!viaRelay) {
1842
1859
  for (const [fid, st] of this.#friendSessions.entries()) {
1843
- const matches = (st.remote?.host === remote.address && st.remote?.port === remote.port) ||
1844
- (st.endpointCandidates ?? []).some((c) => c.host === remote.address && c.port === remote.port);
1845
- if (!matches)
1846
- continue;
1847
- if (Date.now() - (st.lastReinitMs ?? 0) > REINIT_ON_DESYNC_MS) {
1848
- st.lastReinitMs = Date.now();
1849
- this.#debugLog(`no-session-matched from ${remote.address}:${remote.port} (friend ${fid}) — desynced, re-initiating handshake`);
1850
- void this.#initiateSession(fid).catch(() => undefined);
1860
+ if ((st.remote?.host === remote.address && st.remote?.port === remote.port) ||
1861
+ (st.endpointCandidates ?? []).some((c) => c.host === remote.address && c.port === remote.port)) {
1862
+ desyncedFid = fid;
1863
+ break;
1851
1864
  }
1852
- break;
1865
+ }
1866
+ }
1867
+ if (desyncedFid) {
1868
+ const st = this.#friendSessions.get(desyncedFid);
1869
+ // Only a GENUINELY stuck session (no successful decrypt for a while) —
1870
+ // never nuke a healthy one on a stray/late undecryptable packet — and
1871
+ // rate-limited via a map that survives the delete.
1872
+ const working = st.lastPingRecvMs !== undefined && Date.now() - st.lastPingRecvMs < REINIT_STUCK_MS;
1873
+ const lastDel = this.#lastDesyncDeleteMs.get(desyncedFid) ?? 0;
1874
+ if (!working && Date.now() - lastDel > REINIT_ON_DESYNC_MS) {
1875
+ this.#lastDesyncDeleteMs.set(desyncedFid, Date.now());
1876
+ this.#debugLog(`no-session-matched from ${remote.address} (friend ${desyncedFid}) — desynced, deleting session for clean re-handshake`);
1877
+ this.#friendSessions.delete(desyncedFid);
1878
+ void this.#initiateSession(desyncedFid).catch(() => undefined);
1853
1879
  }
1854
1880
  }
1855
1881
  this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
@@ -3476,7 +3502,14 @@ export class Peer {
3476
3502
  // upgrading the path to direct UDP in both directions with no new
3477
3503
  // handshake. If UDP never works, the periodic offer re-punches.
3478
3504
  const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
3479
- if (!haveRealUdp) {
3505
+ // NEVER point the session at the public srflx when we know a same-LAN
3506
+ // candidate for this peer: the physical LAN is strictly better, and letting
3507
+ // the srflx win here caused a LAN↔public FLAP (the LAN block above set
3508
+ // remote=LAN, then this overwrote it with the Tailscale-exit public IP),
3509
+ // so the direct LAN path never stabilised and udpRecv never confirmed.
3510
+ const haveLanCandidate = (session.endpointCandidates ?? []).some((c) => isPrivateAddress(c.host) && !isCgnatAddress(c.host) &&
3511
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(c.host, s)));
3512
+ if (!haveRealUdp && !haveLanCandidate) {
3480
3513
  session.remote = { host, port };
3481
3514
  }
3482
3515
  // Nudge a keepalive out immediately so the upgrade doesn't wait for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
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",