@decentnetwork/peer 0.1.17 → 0.1.19

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.js CHANGED
@@ -16,6 +16,16 @@ import { loadOrCreateKeyPair } from "./crypto/keypair.js";
16
16
  import { base58ToBytes } from "./utils/base58.js";
17
17
  import { UdpTransport } from "./transport/udp.js";
18
18
  import { bytesToHex, concatBytes, randomBytes } from "./utils/bytes.js";
19
+ import { buildBindingRequest, decodeStun, decodeXorMappedAddress, findAttr, STUN_ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_SUCCESS } from "./stun.js";
20
+ import { TurnClient } from "./turn.js";
21
+ import { createSocket as createDgramSocket } from "dgram";
22
+ // Dedicated TURN relay servers — fixed public relays used as a stable
23
+ // fallback path when direct UDP hole-punch fails or flaps (symmetric NAT,
24
+ // NAT remaps, lossy direct path). A relay endpoint never NAT-flaps, so the
25
+ // path stays put even when the peers' NATs churn. Static long-term creds.
26
+ const TURN_RELAY_SERVERS = [
27
+ { host: "tokyo.fi.chat", port: 3478, username: "allcom", password: "allcompass" }
28
+ ];
19
29
  const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
20
30
  const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
21
31
  // How many in-flight announce step1 requests to fan out at once. Toxcore
@@ -115,11 +125,28 @@ const PACKET_ID_USERSTATUS = 50; // friend's user status (1 byte enum)
115
125
  const PACKET_ID_TYPING = 51; // typing indicator (1 byte bool)
116
126
  const PACKET_ID_MESSAGE = 64; // text message (Carrier FlatBuffers payload)
117
127
  const PACKET_ID_ACTION = 65; // /me action message
128
+ // Custom lossless range (toxcore reserves 160-191 for custom lossless
129
+ // packets). We use 160 to carry a peer's server-reflexive UDP endpoint
130
+ // so the other side can hole-punch directly — the job onion-announce was
131
+ // supposed to do but can't against the live bootnodes. Peers that don't
132
+ // understand this ID simply fall through and ignore it. Payload is 6
133
+ // bytes: 4-byte IPv4 + 2-byte big-endian port.
134
+ const PACKET_ID_UDP_ENDPOINT = 160;
118
135
  export class Peer {
119
136
  #opts;
120
137
  #events = new EventEmitter();
121
138
  #keyPair;
122
139
  #udp = new UdpTransport();
140
+ // TURN relay (stable fallback path). One node-level allocation on its own
141
+ // dedicated UDP socket — kept separate from #udp so net_crypto/DHT and the
142
+ // TURN control protocol never share a socket (no demux). Inbound relayed
143
+ // data is injected into #onDatagram as if it arrived directly from the
144
+ // peer's relay address; outbound to a peer's relay address goes through
145
+ // #turnClient.sendTo. Lazily allocated on first need.
146
+ #turnClient;
147
+ #turnSocket;
148
+ #ourRelayAddr;
149
+ #turnAllocating;
123
150
  #bootstrap;
124
151
  #dht = new LegacyDhtClient();
125
152
  #knownNodes;
@@ -192,6 +219,11 @@ export class Peer {
192
219
  // #initiateSession every few seconds for every friend; without this
193
220
  // dedupe, lower-pubkey side floods the log with "deferring to peer".
194
221
  #initiateSkipLogged = new Set();
222
+ // Cached server-reflexive (public) UDP endpoint of our #udp socket,
223
+ // learned via STUN. Cone-NAT mappings are stable for the socket
224
+ // lifetime, so we only re-probe every ~60s. Used to tell friends where
225
+ // to hole-punch us (PACKET_ID_UDP_ENDPOINT).
226
+ #srflxCache;
195
227
  // Per-friend tracking sets so we send our nickname/status/greeting once
196
228
  // per session rather than on every PACKET_ID_ONLINE arrival.
197
229
  #profileSentTo = new Set();
@@ -385,6 +417,17 @@ export class Peer {
385
417
  }
386
418
  this.#udp.off("datagram", this.#onDatagram);
387
419
  await this.#udp.stop();
420
+ // Release the TURN allocation + its dedicated socket.
421
+ try {
422
+ this.#turnClient?.close();
423
+ this.#turnSocket?.close();
424
+ }
425
+ catch {
426
+ // best-effort
427
+ }
428
+ this.#turnClient = undefined;
429
+ this.#turnSocket = undefined;
430
+ this.#ourRelayAddr = undefined;
388
431
  this.#started = false;
389
432
  }
390
433
  pubkey() {
@@ -880,7 +923,7 @@ export class Peer {
880
923
  #remoteIsTcp(remote) {
881
924
  return remote.address.startsWith("tcp:");
882
925
  }
883
- #onDatagram = ({ data, remote }) => {
926
+ #onDatagram = ({ data, remote, viaRelay }) => {
884
927
  if (!this.#keyPair) {
885
928
  return;
886
929
  }
@@ -1178,13 +1221,26 @@ export class Peer {
1178
1221
  isNewSession &&
1179
1222
  state.sessionEstablishedAtMs !== undefined) {
1180
1223
  const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
1181
- if (sinceEstablished > 1000) {
1224
+ // Only reject the fresh handshake if our CURRENT session is
1225
+ // actually alive — i.e. carrying data. A wedged "established"
1226
+ // session that's receiving nothing must NOT block a re-handshake,
1227
+ // or a peer that dropped + restarted can never reconnect: it keeps
1228
+ // sending fresh handshakes and we keep rejecting them against a
1229
+ // corpse. (This is the all-exits-outage bug: mac held a dead
1230
+ // "established" session for cn and ignored every reconnect
1231
+ // attempt.) If we haven't received a packet within FRIEND_TIMEOUT_MS
1232
+ // the current session is dead — fall through and accept the new one.
1233
+ const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
1234
+ const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
1235
+ if (sinceEstablished > 1000 && currentSessionAlive) {
1182
1236
  // Verbose only — peers retransmit handshakes aggressively, so
1183
1237
  // this line fires dozens of times per minute on a stuck pair
1184
1238
  // and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
1185
- this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on established session, ${sinceEstablished}ms after establish)`);
1239
+ this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
1186
1240
  return;
1187
1241
  }
1242
+ this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
1243
+ `lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
1188
1244
  }
1189
1245
  state.peerSessionPublicKey = hs.sessionPublicKey;
1190
1246
  state.peerBaseNonce = hs.baseNonce.slice();
@@ -1212,6 +1268,15 @@ export class Peer {
1212
1268
  this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
1213
1269
  if (!wasEstablished) {
1214
1270
  this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
1271
+ // If we only have a TCP-relay path, immediately tell the peer our
1272
+ // public UDP endpoint so they can hole-punch — don't wait for the
1273
+ // 15s UDP-retry loop, which can miss a flapping relay session's
1274
+ // short-lived establishment window. Both sides do this on every
1275
+ // (re)establishment, so a brief tcp-relay window is enough to
1276
+ // bootstrap the direct UDP path. See docs/UDP-DIRECT-PLAN.md.
1277
+ if (state.hasTcpRoute && !state.remote) {
1278
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
1279
+ }
1215
1280
  }
1216
1281
  const friend = this.#friends.get(friendId);
1217
1282
  if (friend) {
@@ -1307,8 +1372,21 @@ export class Peer {
1307
1372
  if (this.#remoteIsTcp(remote)) {
1308
1373
  state.hasTcpRoute = true;
1309
1374
  }
1375
+ else if (viaRelay) {
1376
+ // Arrived over the TURN relay. Track relay freshness separately;
1377
+ // do NOT set state.remote (that's the direct endpoint). The relay
1378
+ // address it came from is already in state.relayRemote.
1379
+ if (!state.lastRelayRecvMs) {
1380
+ this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
1381
+ }
1382
+ state.lastRelayRecvMs = Date.now();
1383
+ }
1310
1384
  else {
1311
1385
  state.remote = { host: remote.address, port: remote.port };
1386
+ if (!state.lastUdpRecvMs) {
1387
+ this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
1388
+ }
1389
+ state.lastUdpRecvMs = Date.now();
1312
1390
  }
1313
1391
  state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
1314
1392
  const kind = opened.payload[0];
@@ -1339,6 +1417,11 @@ export class Peer {
1339
1417
  this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
1340
1418
  return;
1341
1419
  }
1420
+ if (kind === PACKET_ID_UDP_ENDPOINT) {
1421
+ // Peer told us their public UDP endpoint — hole-punch to it.
1422
+ this.#handleUdpEndpointOffer(friendId, inner);
1423
+ return;
1424
+ }
1342
1425
  if (kind === PACKET_ID_REQUEST) {
1343
1426
  // Lossless retransmission request from peer. Full reliable stream
1344
1427
  // not yet implemented in JS port, so just acknowledge by logging.
@@ -2091,10 +2174,33 @@ export class Peer {
2091
2174
  const session = this.#friendSessions.get(friendId);
2092
2175
  // Established session: keepalive + timeout monitoring
2093
2176
  if (session?.established) {
2094
- if (session.lastPingRecvMs && now - session.lastPingRecvMs > FRIEND_TIMEOUT_MS) {
2095
- this.#debugLog(`session timeout for ${friendId} (no ping in ${FRIEND_TIMEOUT_MS}ms)`);
2177
+ // Liveness: time out a session that hasn't received ANY packet
2178
+ // within FRIEND_TIMEOUT_MS measured from the last received ping
2179
+ // OR, if we've never received one, from when the session was
2180
+ // established. The previous `lastPingRecvMs &&` guard skipped the
2181
+ // check entirely when lastPingRecvMs was undefined, so a session
2182
+ // that established but never carried a single packet (the path
2183
+ // died at/just-after handshake) lived forever reporting
2184
+ // transport=both while IP forwarding was 100% loss, and never tore
2185
+ // down to re-handshake. Tearing it down here drops it to the
2186
+ // non-established branch below, which re-initiates a fresh
2187
+ // connection — turning a permanent wedge into a ~timeout self-heal.
2188
+ const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
2189
+ if (now - lastAlive > FRIEND_TIMEOUT_MS) {
2190
+ this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
2191
+ `lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
2096
2192
  this.#friendSessions.delete(friendId);
2097
2193
  this.#setFriendOffline(friendId);
2194
+ // Re-assert the relay route so the friend-connection bootstrap
2195
+ // (CONNECTION_NOTIFICATION -> #initiateSession) can re-fire on
2196
+ // re-establishment. requestRoute is idempotent, so this is a
2197
+ // no-op if the route is still live.
2198
+ try {
2199
+ const pk = base58ToBytes(friend.pubkey);
2200
+ if (pk.length === 32)
2201
+ this.#tcpRelays?.requestRoute(pk);
2202
+ }
2203
+ catch { /* skip malformed */ }
2098
2204
  continue;
2099
2205
  }
2100
2206
  if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
@@ -2125,14 +2231,55 @@ export class Peer {
2125
2231
  // with the real endpoint and #sendMessengerPacket picks UDP
2126
2232
  // over TCP from then on. Throttled to UDP_RETRY_INTERVAL_MS so
2127
2233
  // the loop's 250ms tick doesn't flood DHT lookups.
2128
- const realUdpRemote = session.remote &&
2129
- !session.remote.host?.startsWith("tcp:") &&
2130
- session.remote.port !== 0;
2131
- if (!realUdpRemote && session.hasTcpRoute) {
2132
- const UDP_RETRY_INTERVAL_MS = 15_000;
2234
+ // Gate on whether direct UDP is *confirmed* (we've actually
2235
+ // received a packet over a real UDP endpoint recently), NOT on
2236
+ // whether session.remote happens to hold a UDP-shaped value —
2237
+ // #handleUdpEndpointOffer sets session.remote optimistically
2238
+ // before the punch is proven, so using it here would stop the
2239
+ // offer/punch retry prematurely and deadlock a half-punched pair.
2240
+ // Treat the UDP path as needing re-punch once it's been quiet for
2241
+ // >4s — matched to #sendToFriend's 4s freshness window, so the
2242
+ // moment bulk data starts bridging over the relay we're already
2243
+ // re-offering + re-punching to restore the direct path (e.g. after
2244
+ // a NAT port remap). A healthy path refreshes lastUdpRecvMs every
2245
+ // ~1s via keepalive/data, so this never fires while UDP works.
2246
+ // Relay keepalive — runs even when the direct path is perfectly
2247
+ // healthy, so the TURN relay stays ready as an instant fallback.
2248
+ // Without this the relay's permission (and the candidate exchange)
2249
+ // silently lapse after ~5min and a later direct-path failure falls
2250
+ // all the way back to the slow tcp-relay. Re-send our offer (so the
2251
+ // peer refreshes ITS permission for our relay) and re-permit the
2252
+ // peer's relay on our own allocation. Cheap, every 2min.
2253
+ if (session.established && session.hasTcpRoute) {
2254
+ const RELAY_KEEPALIVE_MS = 120_000;
2255
+ if (now - (session.lastRelayKeepaliveMs ?? 0) > RELAY_KEEPALIVE_MS) {
2256
+ session.lastRelayKeepaliveMs = now;
2257
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
2258
+ if (session.relayRemote && this.#turnClient) {
2259
+ const rr = session.relayRemote;
2260
+ void this.#turnClient
2261
+ .createPermission({ family: 4, address: rr.host, port: rr.port })
2262
+ .then(() => { session.relayPermittedAtMs = Date.now(); })
2263
+ .catch(() => undefined);
2264
+ }
2265
+ }
2266
+ }
2267
+ const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 4_000;
2268
+ if (!udpConfirmed && session.hasTcpRoute) {
2269
+ // Aggressive re-punch cadence: the relay path is lossy, so a few
2270
+ // offers may drop before one lands and both sides punch in the
2271
+ // same window. 3s keeps recovery snappy without flooding.
2272
+ const UDP_RETRY_INTERVAL_MS = 3_000;
2133
2273
  const lastTry = session.lastUdpRetryMs ?? 0;
2134
2274
  if (now - lastTry > UDP_RETRY_INTERVAL_MS) {
2135
2275
  session.lastUdpRetryMs = now;
2276
+ // Out-of-band UDP endpoint exchange (works where the broken
2277
+ // onion-announce DHT discovery below does not): tell the peer
2278
+ // our STUN-learned public endpoint over the messenger channel.
2279
+ // When they do the same, both sides feed each other's endpoint
2280
+ // into endpointCandidates and hole-punch. Cone↔cone gets a
2281
+ // direct ~one-RTT UDP path; see docs/UDP-DIRECT-PLAN.md.
2282
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
2136
2283
  // Recompute friend's real public key for the DHT-PK push.
2137
2284
  // Same fallback chain as the cooldown branch below.
2138
2285
  let friendRealPk = session.friendRealPublicKey;
@@ -2227,10 +2374,18 @@ export class Peer {
2227
2374
  });
2228
2375
  }
2229
2376
  }
2230
- // No active session: try to establish one if we know the friend's endpoint.
2231
- // If we don't, the C++-initiated path can still bring up the session
2232
- // when peer reaches us so this is only one of two ways to recover.
2233
- const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote;
2377
+ // No active session: try to establish one if we know the friend's
2378
+ // endpoint. A friend reachable over the TCP relay counts the cookie
2379
+ // request + handshake go over the relay OOB (the log shows
2380
+ // `cookie_sent udp=0 tcp=1`). Without including hasTcpRoute here, a
2381
+ // relay-only friend whose handshake didn't complete on the first try
2382
+ // (peer's handshake-back was lost) was NEVER retried — the cookie
2383
+ // retry below was gated on a UDP endpoint — so the session stayed
2384
+ // stuck forever at "friend_online but never established". That is the
2385
+ // post-restart reconnection wedge that took down cn/callpass: relay
2386
+ // bridged them, cookie+handshake fired once, didn't complete, and
2387
+ // nothing re-tried it. Counting the relay route lets the retry fire.
2388
+ const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
2234
2389
  if (!haveEndpoint) {
2235
2390
  const dhtPk = session?.friendDhtPublicKey;
2236
2391
  if (dhtPk) {
@@ -2312,7 +2467,17 @@ export class Peer {
2312
2467
  incrementNonce(session.ourBaseNonce);
2313
2468
  session.sendPacketNumber = (packetNumber + 1) >>> 0;
2314
2469
  session.lastPingSentMs = Date.now();
2315
- await this.#sendToFriend(friendId, encrypted, session);
2470
+ // Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
2471
+ // traffic. Once UDP is established it should ride UDP exclusively and
2472
+ // NEVER spill onto the TCP relay during a UDP hiccup — the relay's
2473
+ // China↔US queue backs up 20s+ and every spilled packet returns as a
2474
+ // hugely-delayed duplicate. Control packets (keepalives, endpoint
2475
+ // offers, profile) keep the relay fallback so the session and the
2476
+ // re-punch survive a UDP outage. Brief UDP gaps just drop a few data
2477
+ // packets (the proxied TCP stream retransmits) and the re-punch loop
2478
+ // restores the direct path within a few seconds.
2479
+ const isBulkData = kind === PACKET_ID_MESSAGE;
2480
+ await this.#sendToFriend(friendId, encrypted, session, isBulkData);
2316
2481
  }
2317
2482
  /**
2318
2483
  * Send `packet` to `friendId` over whichever transport(s) are
@@ -2322,12 +2487,28 @@ export class Peer {
2322
2487
  * receiving end (toxcore does the same when both transports are up).
2323
2488
  * Throws only if zero transports succeeded.
2324
2489
  */
2325
- async #sendToFriend(friendId, packet, session) {
2490
+ async #sendToFriend(friendId, packet, session, isBulkData = false) {
2326
2491
  const s = session ?? this.#friendSessions.get(friendId);
2327
2492
  let udpOk = false;
2328
2493
  let tcpOk = false;
2329
2494
  let firstError;
2330
- if (s?.remote) {
2495
+ const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
2496
+ // "Fresh" = we've received UDP from this peer within the last few
2497
+ // seconds, so the direct endpoint is currently live. When the peer's
2498
+ // NAT remaps its port (observed periodically on residential/cloud
2499
+ // NATs) the old endpoint goes dark and lastUdpRecvMs goes stale.
2500
+ const udpFresh = !!realUdpRemote &&
2501
+ s?.lastUdpRecvMs !== undefined &&
2502
+ Date.now() - s.lastUdpRecvMs < 4_000;
2503
+ // Bulk IP data rides the direct path ONLY while it's fresh. When it
2504
+ // goes stale (likely a NAT remap mid-stream), we do NOT keep firing
2505
+ // into the dead endpoint and we do NOT duplicate onto UDP — we bridge
2506
+ // over the relay until the re-punch restores UDP (which refreshes
2507
+ // lastUdpRecvMs). This converts a multi-second blackout into a few
2508
+ // seconds of higher relay latency. Control packets always probe UDP
2509
+ // (to keep the NAT mapping warm) so we still try UDP for them.
2510
+ const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
2511
+ if (tryUdp && s?.remote) {
2331
2512
  try {
2332
2513
  await this.#sendPacket(packet, s.remote);
2333
2514
  udpOk = true;
@@ -2336,6 +2517,25 @@ export class Peer {
2336
2517
  firstError = error;
2337
2518
  }
2338
2519
  }
2520
+ // Fresh direct path → send over UDP only: no relay fan-out, no
2521
+ // duplicates, no relay backlog. Applies to both data and control.
2522
+ if (udpFresh && udpOk) {
2523
+ return;
2524
+ }
2525
+ // Tier 2: TURN relay (tokyo) — a fast (~280ms) stable fallback that
2526
+ // never NAT-flaps. Used when the direct path isn't fresh (remap, loss,
2527
+ // symmetric NAT). For bulk data, once the relay path is *confirmed*
2528
+ // (we've received over it recently) it carries data on its own — no
2529
+ // tcp-relay fan-out, no duplicates. Until confirmed, we still also try
2530
+ // tcp below so a packet isn't lost while the relay path warms up.
2531
+ let relayOk = false;
2532
+ if (s?.relayRemote && s.relayPermitted) {
2533
+ relayOk = this.#sendViaRelay(packet, s.relayRemote);
2534
+ const relayConfirmed = s.lastRelayRecvMs !== undefined && Date.now() - s.lastRelayRecvMs < 8_000;
2535
+ if (isBulkData && relayOk && relayConfirmed) {
2536
+ return;
2537
+ }
2538
+ }
2339
2539
  if (this.#tcpRelays) {
2340
2540
  // The TCP relay routes by the friend's DHT pubkey (= the pubkey
2341
2541
  // they handshook the relay with), not by the friend's real
@@ -2356,7 +2556,7 @@ export class Peer {
2356
2556
  }
2357
2557
  }
2358
2558
  }
2359
- if (!udpOk && !tcpOk) {
2559
+ if (!udpOk && !relayOk && !tcpOk) {
2360
2560
  throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
2361
2561
  }
2362
2562
  }
@@ -2473,6 +2673,259 @@ export class Peer {
2473
2673
  .sort((a, b) => b.updatedMs - a.updatedMs)
2474
2674
  .slice(0, 12);
2475
2675
  }
2676
+ /**
2677
+ * Learn our own server-reflexive (public) UDP endpoint by sending a
2678
+ * STUN binding-request to a bootnode on its STUN port (3478) over the
2679
+ * SAME #udp socket net_crypto uses — so the reflexive port matches
2680
+ * where our crypto data will actually arrive. Cached briefly because
2681
+ * a cone NAT's mapping is stable for the socket's lifetime.
2682
+ *
2683
+ * Returns undefined if no bootnode answers within the timeout.
2684
+ */
2685
+ async #gatherOwnSrflx() {
2686
+ // Short cache: when our NAT remaps the socket's external port, we must
2687
+ // re-learn it quickly so the re-punch offers the *new* endpoint. A
2688
+ // long cache would keep us advertising a dead port for its full
2689
+ // lifetime, stalling recovery. Gather is only called during
2690
+ // (re)establishment/re-punch, so a short cache costs little.
2691
+ const CACHE_MS = 5_000;
2692
+ const now = Date.now();
2693
+ if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
2694
+ return this.#srflxCache.addr;
2695
+ }
2696
+ const bootnodes = this.#opts.bootstrapNodes;
2697
+ if (!bootnodes || bootnodes.length === 0)
2698
+ return undefined;
2699
+ // Try up to 3 bootnodes; first answer wins.
2700
+ for (const bn of bootnodes.slice(0, 3)) {
2701
+ const req = buildBindingRequest();
2702
+ const txnHex = Buffer.from(req.slice(8, 20)).toString("hex");
2703
+ const addr = await new Promise((resolve) => {
2704
+ const interceptor = (data, rinfo) => {
2705
+ if (rinfo.address !== bn.host || rinfo.port !== 3478)
2706
+ return false;
2707
+ const msg = decodeStun(data);
2708
+ if (!msg || msg.type !== STUN_BINDING_SUCCESS)
2709
+ return false;
2710
+ if (Buffer.from(msg.transactionId).toString("hex") !== txnHex)
2711
+ return false;
2712
+ const xma = findAttr(msg, STUN_ATTR_XOR_MAPPED_ADDRESS);
2713
+ const parsed = xma ? decodeXorMappedAddress(xma, msg.transactionId) : undefined;
2714
+ this.#udp.removeStunInterceptor(interceptor);
2715
+ clearTimeout(timer);
2716
+ resolve(parsed ? { host: parsed.address, port: parsed.port } : undefined);
2717
+ return true; // consume — don't let it reach net_crypto/DHT dispatch
2718
+ };
2719
+ const timer = setTimeout(() => {
2720
+ this.#udp.removeStunInterceptor(interceptor);
2721
+ resolve(undefined);
2722
+ }, 2000);
2723
+ this.#udp.addStunInterceptor(interceptor);
2724
+ this.#udp.sendDirect(Buffer.from(req), bn.host, 3478).catch(() => {
2725
+ this.#udp.removeStunInterceptor(interceptor);
2726
+ clearTimeout(timer);
2727
+ resolve(undefined);
2728
+ });
2729
+ });
2730
+ if (addr) {
2731
+ this.#srflxCache = { addr, atMs: now };
2732
+ return addr;
2733
+ }
2734
+ }
2735
+ return undefined;
2736
+ }
2737
+ /**
2738
+ * Tell a friend our public UDP endpoint over the (already-working,
2739
+ * possibly tcp-relay) messenger channel, so they can hole-punch to us.
2740
+ * This is the out-of-band substitute for the broken DHT/onion-announce
2741
+ * endpoint discovery. Both peers do this symmetrically; on receipt each
2742
+ * feeds the other's endpoint into endpointCandidates and punches.
2743
+ */
2744
+ /**
2745
+ * Lazily allocate our node-level TURN relay on its own dedicated UDP
2746
+ * socket. Returns our public relay address (on the TURN server) which we
2747
+ * advertise to peers so they can reach us via the relay even when direct
2748
+ * UDP can't be punched. Relayed data is injected into #onDatagram tagged
2749
+ * `viaRelay` so it updates the relay freshness, not the direct endpoint.
2750
+ */
2751
+ async #ensureTurnRelay() {
2752
+ if (this.#ourRelayAddr)
2753
+ return this.#ourRelayAddr;
2754
+ if (this.#turnAllocating)
2755
+ return this.#turnAllocating;
2756
+ this.#turnAllocating = (async () => {
2757
+ for (const srv of TURN_RELAY_SERVERS) {
2758
+ try {
2759
+ const sock = createDgramSocket("udp4");
2760
+ await new Promise((resolve, reject) => {
2761
+ sock.once("error", reject);
2762
+ sock.bind(0, () => {
2763
+ sock.off("error", reject);
2764
+ resolve();
2765
+ });
2766
+ });
2767
+ const client = new TurnClient({
2768
+ sock,
2769
+ creds: { host: srv.host, port: srv.port, realm: "", username: srv.username, password: srv.password }
2770
+ });
2771
+ const alloc = await client.allocate();
2772
+ client.onData((peer, data) => {
2773
+ // Re-inject relayed payload as if it arrived directly from the
2774
+ // peer's relay address. viaRelay keeps it off the direct path.
2775
+ this.#onDatagram({
2776
+ data: Buffer.from(data),
2777
+ remote: { address: peer.address, port: peer.port },
2778
+ viaRelay: true
2779
+ });
2780
+ });
2781
+ this.#turnSocket = sock;
2782
+ this.#turnClient = client;
2783
+ this.#ourRelayAddr = { host: alloc.relayedAddress.address, port: alloc.relayedAddress.port };
2784
+ this.#debugLog(`turn relay allocated on ${srv.host}: ${this.#ourRelayAddr.host}:${this.#ourRelayAddr.port}`);
2785
+ return this.#ourRelayAddr;
2786
+ }
2787
+ catch (error) {
2788
+ this.#debugLog(`turn relay allocate failed on ${srv.host}: ${error.message}`);
2789
+ }
2790
+ }
2791
+ return undefined;
2792
+ })();
2793
+ const result = await this.#turnAllocating;
2794
+ this.#turnAllocating = undefined;
2795
+ return result;
2796
+ }
2797
+ /** Wrap + send a packet to a peer's TURN relay address via our own
2798
+ * allocation. Mirrors #sendPacket's carrier-magic framing so the peer's
2799
+ * #onDatagram processes it identically to a direct datagram. */
2800
+ #sendViaRelay(packet, relay) {
2801
+ if (!this.#turnClient)
2802
+ return false;
2803
+ const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
2804
+ try {
2805
+ this.#turnClient.sendTo({ family: 4, address: relay.host, port: relay.port }, wrapped);
2806
+ return true;
2807
+ }
2808
+ catch {
2809
+ return false;
2810
+ }
2811
+ }
2812
+ async #sendUdpEndpointOffer(friendId) {
2813
+ const session = this.#friendSessions.get(friendId);
2814
+ if (!session?.established)
2815
+ return;
2816
+ const srflx = await this.#gatherOwnSrflx();
2817
+ if (!srflx)
2818
+ return;
2819
+ const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
2820
+ if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
2821
+ return;
2822
+ // Also advertise our TURN relay address (bytes 6-11) as a second
2823
+ // candidate. Best-effort: if the relay can't be allocated we just send
2824
+ // the 6-byte srflx-only offer (older peers ignore the extra bytes).
2825
+ const relay = await this.#ensureTurnRelay().catch(() => undefined);
2826
+ const relayOctets = relay ? relay.host.split(".").map((s) => parseInt(s, 10)) : undefined;
2827
+ const relayValid = relayOctets && relayOctets.length === 4 && !relayOctets.some((o) => Number.isNaN(o));
2828
+ const payload = new Uint8Array(relayValid ? 12 : 6);
2829
+ payload.set(octets, 0);
2830
+ payload[4] = (srflx.port >> 8) & 0xff;
2831
+ payload[5] = srflx.port & 0xff;
2832
+ if (relayValid && relay) {
2833
+ payload.set(relayOctets, 6);
2834
+ payload[10] = (relay.port >> 8) & 0xff;
2835
+ payload[11] = relay.port & 0xff;
2836
+ }
2837
+ try {
2838
+ await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
2839
+ this.#debugLog(`udp-endpoint offer sent to ${friendId}: direct=${srflx.host}:${srflx.port}` +
2840
+ (relayValid && relay ? ` relay=${relay.host}:${relay.port}` : ""));
2841
+ }
2842
+ catch {
2843
+ // best-effort — the retry loop will try again
2844
+ }
2845
+ }
2846
+ /**
2847
+ * A friend told us their public UDP endpoint. Feed it as a high-value
2848
+ * candidate and immediately spray a few raw punch datagrams at it to
2849
+ * open our own cone-NAT mapping toward their address:port, then kick a
2850
+ * session initiation (the higher-pubkey side sends the cookie request;
2851
+ * both sides' mappings are now open so it gets through). Mirrors the
2852
+ * proven standalone hole-punch.
2853
+ */
2854
+ #handleUdpEndpointOffer(friendId, payload) {
2855
+ if (payload.length < 6)
2856
+ return;
2857
+ const host = `${payload[0]}.${payload[1]}.${payload[2]}.${payload[3]}`;
2858
+ const port = ((payload[4] << 8) | payload[5]) >>> 0;
2859
+ if (port === 0)
2860
+ return;
2861
+ const session = this.#friendSessions.get(friendId);
2862
+ if (!session)
2863
+ return;
2864
+ // If the offer carries a TURN relay candidate (bytes 6-11), remember it
2865
+ // and permit it on our own allocation so data can flow peer→relay→us.
2866
+ if (payload.length >= 12) {
2867
+ const relayHost = `${payload[6]}.${payload[7]}.${payload[8]}.${payload[9]}`;
2868
+ const relayPort = ((payload[10] << 8) | payload[11]) >>> 0;
2869
+ if (relayPort !== 0) {
2870
+ const changed = session.relayRemote?.host !== relayHost || session.relayRemote?.port !== relayPort;
2871
+ session.relayRemote = { host: relayHost, port: relayPort };
2872
+ if (changed)
2873
+ session.relayPermitted = false;
2874
+ // (Re)create the permission on first sight, on address change, or
2875
+ // when the existing one is aging toward coturn's ~5min expiry.
2876
+ const permitAgeMs = Date.now() - (session.relayPermittedAtMs ?? 0);
2877
+ if (!session.relayPermitted || permitAgeMs > 90_000) {
2878
+ void this.#ensureTurnRelay()
2879
+ .then(() => this.#turnClient?.createPermission({ family: 4, address: relayHost, port: relayPort }))
2880
+ .then(() => {
2881
+ const first = !session.relayPermitted;
2882
+ session.relayPermitted = true;
2883
+ session.relayPermittedAtMs = Date.now();
2884
+ if (first)
2885
+ this.#debugLog(`turn permission created for ${friendId} relay ${relayHost}:${relayPort}`);
2886
+ })
2887
+ .catch(() => undefined);
2888
+ }
2889
+ }
2890
+ }
2891
+ // Don't punch toward ourselves.
2892
+ if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
2893
+ return;
2894
+ this.#rememberEndpointCandidate(session, host, port);
2895
+ this.#debugLog(`udp-endpoint offer from ${friendId}: ${host}:${port} — punching`);
2896
+ // Spray a handful of tiny punch datagrams (0xF2 is not a recognized
2897
+ // Carrier/DHT/net_crypto packet type, so the peer's #onDatagram
2898
+ // ignores them — their only purpose is opening our NAT mapping
2899
+ // toward host:port so the peer's crypto data can reach us).
2900
+ const punch = Uint8Array.of(0xf2);
2901
+ let n = 0;
2902
+ const punchTimer = setInterval(() => {
2903
+ this.#udp.sendDirectSync(Buffer.from(punch), host, port);
2904
+ if (++n >= 6)
2905
+ clearInterval(punchTimer);
2906
+ }, 120);
2907
+ // Point this session's UDP remote at the peer's reflexive endpoint
2908
+ // (unless we already have a confirmed real UDP remote). hasTcpRoute
2909
+ // stays true, so #sendToFriend fans crypto data out over BOTH UDP
2910
+ // and TCP relay: TCP keeps the session alive even if the punch
2911
+ // fails, while every UDP-bound keepalive/packet that makes it
2912
+ // through the freshly-punched hole causes the peer's CRYPTO_DATA
2913
+ // handler to set THEIR remote to our endpoint (peer.ts ~1564) —
2914
+ // upgrading the path to direct UDP in both directions with no new
2915
+ // handshake. If UDP never works, the periodic offer re-punches.
2916
+ const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
2917
+ if (!haveRealUdp) {
2918
+ session.remote = { host, port };
2919
+ }
2920
+ // Nudge a keepalive out immediately so the upgrade doesn't wait for
2921
+ // the next periodic ALIVE — fans out over UDP (punched) + TCP.
2922
+ if (session.established) {
2923
+ void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
2924
+ }
2925
+ else {
2926
+ void this.#initiateSession(friendId).catch(() => undefined);
2927
+ }
2928
+ }
2476
2929
  #collectSessionEndpointCandidates(friendId, friend, session) {
2477
2930
  // Three buckets: same-LAN private (highest priority for direct UDP),
2478
2931
  // public/routable, and other private (different LAN, last-resort).