@decentnetwork/peer 0.1.79 → 0.1.82
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/compat/express.js +3 -1
- package/dist/compat/net-crypto.d.ts +4 -0
- package/dist/compat/net-crypto.js +41 -3
- package/dist/compat/packet.d.ts +24 -1
- package/dist/compat/packet.js +48 -0
- package/dist/compat/tcp-relay-pool.js +11 -1
- package/dist/peer.d.ts +18 -1
- package/dist/peer.js +691 -97
- package/dist/store/friends.d.ts +5 -0
- package/dist/types/peer.d.ts +27 -0
- package/package.json +3 -2
package/dist/peer.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
3
3
|
import { networkInterfaces } from "node:os";
|
|
4
|
-
import { dirname } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import nacl from "tweetnacl";
|
|
6
6
|
import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
|
|
7
7
|
import { LegacyBootstrapClient } from "./compat/bootstrap.js";
|
|
8
|
-
import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket } from "./compat/packet.js";
|
|
8
|
+
import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket } from "./compat/packet.js";
|
|
9
9
|
import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
|
|
10
10
|
import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
11
11
|
import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
|
|
@@ -255,6 +255,22 @@ export class Peer {
|
|
|
255
255
|
// unconnected friend (the "accepted friend that never connects" wedge —
|
|
256
256
|
// requestRoute only fired once at startup and was never retried).
|
|
257
257
|
#routeRequestCooldown = new Map();
|
|
258
|
+
// The onion route that carried the last successful announce exchange with
|
|
259
|
+
// each announce node ("host:port" -> path | "direct"). A node's ping_id is
|
|
260
|
+
// bound to the SOURCE ip:port it saw the request arrive from (the path's
|
|
261
|
+
// exit hop, or us when direct). Step2 of a self-announce must therefore
|
|
262
|
+
// travel the exact route step1 used — re-selecting a path (scores mutate
|
|
263
|
+
// between calls) or falling back direct changes the source, the node
|
|
264
|
+
// rejects the ping_id, and the announce NEVER stores (observed: zero
|
|
265
|
+
// isStored=2 across an entire run — nobody could find us to learn our
|
|
266
|
+
// announce data key).
|
|
267
|
+
#announceRouteUsed = new Map();
|
|
268
|
+
// Last-known DHT pubkey per friend (identity id -> dht pk). Sessions get
|
|
269
|
+
// deleted on timeout, but relay ROUTING_REQUESTs must keep using the key
|
|
270
|
+
// the friend handshakes relays with — for native peers that's the DHT key,
|
|
271
|
+
// not the identity key. Without this survives-teardown copy, every re-link
|
|
272
|
+
// attempt after a session teardown silently used the wrong key.
|
|
273
|
+
#friendDhtKeys = new Map();
|
|
258
274
|
// Per-friend cooldown for RE-SENDING the friend-request to a friend that's
|
|
259
275
|
// never been accepted (still "requested"). On reconnect / restart we must
|
|
260
276
|
// re-offer — the original request can be lost while the friend was offline.
|
|
@@ -290,6 +306,16 @@ export class Peer {
|
|
|
290
306
|
// #initiateSession every few seconds for every friend; without this
|
|
291
307
|
// dedupe, lower-pubkey side floods the log with "deferring to peer".
|
|
292
308
|
#initiateSkipLogged = new Set();
|
|
309
|
+
// In-flight Carrier BULKMSG reassemblies, keyed `${friendId}:${tid}`.
|
|
310
|
+
// Fragments are appended in arrival order (the lossless stream keeps
|
|
311
|
+
// them ordered) and the message completes when totalsz bytes arrived.
|
|
312
|
+
// Mirrors the C SDK's bulkmsgs hashtable with its 60s assembly timeout.
|
|
313
|
+
#bulkAssembly = new Map();
|
|
314
|
+
// When we FIRST deferred initiation to a higher-pubkey peer (per friend).
|
|
315
|
+
// If the peer hasn't established within the grace window we break the
|
|
316
|
+
// defer and initiate ourselves (see #initiateSession). Cleared whenever a
|
|
317
|
+
// session establishes so each outage gets a fresh window.
|
|
318
|
+
#initiateDeferSinceMs = new Map();
|
|
293
319
|
// Per-friend last time we deleted a desynced session (rate-limit; survives the
|
|
294
320
|
// session deletion, unlike a field on the session object itself).
|
|
295
321
|
#lastDesyncDeleteMs = new Map();
|
|
@@ -336,11 +362,19 @@ export class Peer {
|
|
|
336
362
|
keyPair: this.#keyPair,
|
|
337
363
|
transport: this.#udp
|
|
338
364
|
});
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
365
|
+
// The announce data key MUST survive restarts. Native toxcore peers cache
|
|
366
|
+
// our last-known data pk and keep encrypting onion data (DHT-PK announces,
|
|
367
|
+
// friend requests) to it until they re-discover our announce on the DHT —
|
|
368
|
+
// which can take a long time when our self-announce store rate is weak.
|
|
369
|
+
// With an ephemeral per-run key, every restart silently invalidated every
|
|
370
|
+
// friend's cached key: 100% of inbound onion data failed to decrypt
|
|
371
|
+
// ("onion data response decrypt failed"), so we never learned a native
|
|
372
|
+
// friend's UDP endpoint or current relay list, and a dropped relay route
|
|
373
|
+
// could never self-heal (the iOS online-flap bug).
|
|
374
|
+
{
|
|
375
|
+
const announceKeyPath = join(dirname(this.#opts.keyFile), "announce-data-key.json");
|
|
376
|
+
this.#announceDataKey = await loadOrCreateKeyPair(announceKeyPath);
|
|
377
|
+
}
|
|
344
378
|
this.#cookieSymmetricKey = randomBytes(32);
|
|
345
379
|
await this.#loadPersistedFriends();
|
|
346
380
|
if (this.#opts.expressNodes && this.#opts.expressNodes.length > 0) {
|
|
@@ -390,15 +424,23 @@ export class Peer {
|
|
|
390
424
|
this.#handleTcpDatagram(friendKey, payload);
|
|
391
425
|
});
|
|
392
426
|
this.#tcpRelays.on("friendOnline", (friendKey) => {
|
|
393
|
-
|
|
394
|
-
|
|
427
|
+
// The pool reports the key the peer handshook the relay with — for
|
|
428
|
+
// native peers that's the DHT key. Map it to the real friend id so
|
|
429
|
+
// route state + session kick land on the REAL session, not a ghost.
|
|
430
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
431
|
+
this.#debugLog(`tcp_relay friend_online ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
|
|
395
432
|
// Mark the (existing or new) session as having a TCP route so
|
|
396
433
|
// outbound goes through the relay even if no UDP endpoint is
|
|
397
434
|
// known. Then kick #initiateSession — this issues a cookie
|
|
398
435
|
// request that will travel via TCP if no UDP path exists.
|
|
399
436
|
const session = this.#friendSessions.get(friendId) ?? this.#newSessionShell();
|
|
400
437
|
session.hasTcpRoute = true;
|
|
401
|
-
|
|
438
|
+
if (friendId === carrierIdFromPublicKey(friendKey)) {
|
|
439
|
+
session.friendRealPublicKey ??= new Uint8Array(friendKey);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
session.friendDhtPublicKey ??= new Uint8Array(friendKey);
|
|
443
|
+
}
|
|
402
444
|
this.#friendSessions.set(friendId, session);
|
|
403
445
|
if (!session.established) {
|
|
404
446
|
// A fresh relay-online signal means the peer just (re)connected —
|
|
@@ -413,12 +455,18 @@ export class Peer {
|
|
|
413
455
|
}
|
|
414
456
|
});
|
|
415
457
|
this.#tcpRelays.on("friendOffline", (friendKey) => {
|
|
416
|
-
const friendId =
|
|
458
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
417
459
|
const session = this.#friendSessions.get(friendId);
|
|
418
460
|
if (session) {
|
|
419
461
|
session.hasTcpRoute = false;
|
|
420
462
|
}
|
|
421
|
-
this.#debugLog(`tcp_relay friend_offline ${friendId}`);
|
|
463
|
+
this.#debugLog(`tcp_relay friend_offline ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
|
|
464
|
+
// Re-assert the route right away (idempotent). A native peer that
|
|
465
|
+
// blips off a relay (network switch, app background, relay rotation)
|
|
466
|
+
// reconnects seconds later — with our ROUTING_REQUEST already parked,
|
|
467
|
+
// the relay re-links the moment it returns and fires friendOnline,
|
|
468
|
+
// instead of the session sitting pathless until the periodic sweep.
|
|
469
|
+
this.#tcpRelays?.requestRoute(friendKey);
|
|
422
470
|
});
|
|
423
471
|
this.#tcpRelays.on("status", (connected, total) => {
|
|
424
472
|
this.#debugLog(`tcp_pool ${connected}/${total} relays connected`);
|
|
@@ -465,14 +513,25 @@ export class Peer {
|
|
|
465
513
|
this.#debugLog(`tcp pool initial start failed: ${error.message}`);
|
|
466
514
|
});
|
|
467
515
|
// Pre-route every persisted friend so the pool watches for them
|
|
468
|
-
// the moment a relay is up
|
|
469
|
-
|
|
516
|
+
// the moment a relay is up — under BOTH keys: identity (JS peers)
|
|
517
|
+
// and the persisted DHT key (native peers handshake relays with it).
|
|
518
|
+
for (const [friendId, friend] of this.#friends.entries()) {
|
|
470
519
|
try {
|
|
471
520
|
const pk = base58ToBytes(friend.pubkey);
|
|
472
521
|
if (pk.length === 32)
|
|
473
522
|
this.#tcpRelays.requestRoute(pk);
|
|
474
523
|
}
|
|
475
524
|
catch { /* skip malformed */ }
|
|
525
|
+
if (friend.dhtPubkey && friend.dhtPubkey !== friend.pubkey) {
|
|
526
|
+
try {
|
|
527
|
+
const dhtPk = base58ToBytes(friend.dhtPubkey);
|
|
528
|
+
if (dhtPk.length === 32) {
|
|
529
|
+
this.#friendDhtKeys.set(friendId, dhtPk);
|
|
530
|
+
this.#tcpRelays.requestRoute(dhtPk);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch { /* skip malformed */ }
|
|
534
|
+
}
|
|
476
535
|
}
|
|
477
536
|
}
|
|
478
537
|
this.#started = true;
|
|
@@ -901,17 +960,58 @@ export class Peer {
|
|
|
901
960
|
}
|
|
902
961
|
}
|
|
903
962
|
const packet = encodeFriendMessagePacket(text);
|
|
963
|
+
// Carrier C peers accept at most 1024 raw bytes per friendmsg; larger
|
|
964
|
+
// messages MUST be split into BULKMSG fragments (shared tid, totalsz on
|
|
965
|
+
// the first fragment) or a native receiver drops them. The express
|
|
966
|
+
// fallback below still posts the single big MESSAGE packet — that's
|
|
967
|
+
// what the C SDK does offline too.
|
|
968
|
+
const rawText = new TextEncoder().encode(text);
|
|
969
|
+
let livePackets;
|
|
970
|
+
if (rawText.length > CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
971
|
+
if (rawText.length > CARRIER_MAX_APP_BULKMSG_LEN) {
|
|
972
|
+
throw new Error(`message exceeds bulk limit (${rawText.length} > ${CARRIER_MAX_APP_BULKMSG_LEN})`);
|
|
973
|
+
}
|
|
974
|
+
const tid = (BigInt(Date.now()) << 20n) ^ BigInt(Math.floor(Math.random() * 0xfffff));
|
|
975
|
+
livePackets = [];
|
|
976
|
+
for (let off = 0; off < rawText.length; off += CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
977
|
+
livePackets.push(encodeBulkMsgPacket({
|
|
978
|
+
totalsz: off === 0 ? rawText.length : 0,
|
|
979
|
+
tid,
|
|
980
|
+
data: rawText.subarray(off, Math.min(off + CARRIER_MAX_APP_MESSAGE_LEN, rawText.length))
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
livePackets = [packet];
|
|
986
|
+
}
|
|
904
987
|
// Try the live in-session path first when any transport is up
|
|
905
988
|
// (UDP via session.remote, or TCP relay via session.hasTcpRoute).
|
|
906
989
|
// #sendMessengerPacket internally fans out to both transports and
|
|
907
990
|
// returns true if at least one accepted; throws only if zero did.
|
|
991
|
+
//
|
|
992
|
+
// CRUCIAL: also require the session to be PROVEN — we've decrypted a real
|
|
993
|
+
// packet from the peer within FRIEND_TIMEOUT_MS. `established` alone can be a
|
|
994
|
+
// half-open / key-desynced session (common JS<->native case): our transport
|
|
995
|
+
// ACCEPTS the encrypted packet (so #sendMessengerPacket returns success, no
|
|
996
|
+
// throw) but the peer can't decrypt it, so the message is silently swallowed
|
|
997
|
+
// — and because the direct send "succeeded", the express fallback below never
|
|
998
|
+
// runs. Observed: an offline message to a desynced iOS peer showed "sent"
|
|
999
|
+
// yet never reached the peer OR the express relay (0 rows). Gating on recent
|
|
1000
|
+
// inbound routes such sends to express store-and-forward instead of losing
|
|
1001
|
+
// them. A genuinely-healthy session proves itself within a ping RTT (~4-8s),
|
|
1002
|
+
// so at most the first message of a cold conversation takes the express path.
|
|
1003
|
+
const provenInbound = session?.lastPingRecvMs !== undefined &&
|
|
1004
|
+
Date.now() - session.lastPingRecvMs < FRIEND_TIMEOUT_MS;
|
|
908
1005
|
const liveSession = session?.established
|
|
909
1006
|
&& session.sessionSharedKey
|
|
910
1007
|
&& session.ourBaseNonce
|
|
911
|
-
&& (session.remote || session.hasTcpRoute)
|
|
1008
|
+
&& (session.remote || session.hasTcpRoute)
|
|
1009
|
+
&& provenInbound;
|
|
912
1010
|
if (liveSession) {
|
|
913
1011
|
try {
|
|
914
|
-
|
|
1012
|
+
for (const p of livePackets) {
|
|
1013
|
+
await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, p);
|
|
1014
|
+
}
|
|
915
1015
|
return;
|
|
916
1016
|
}
|
|
917
1017
|
catch (error) {
|
|
@@ -991,6 +1091,40 @@ export class Peer {
|
|
|
991
1091
|
onText(cb) {
|
|
992
1092
|
this.#events.on("text", cb);
|
|
993
1093
|
}
|
|
1094
|
+
/** Files received inline over (bulk)messages — the iOS/C Carrier apps'
|
|
1095
|
+
* native way of sending images/audio online (FileModel JSON envelope). */
|
|
1096
|
+
onInlineFile(cb) {
|
|
1097
|
+
this.#events.on("inlineFile", cb);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Send a file INLINE over the message channel the way iOS/C Carrier apps
|
|
1101
|
+
* do: a FileModel JSON envelope ({data: base64, fileExtension, fileName,
|
|
1102
|
+
* type}) carried as a (bulk)message. This is the ONLY file path a native
|
|
1103
|
+
* Beagle client can receive online — the toxcore file transfer (80-82)
|
|
1104
|
+
* used by sendFile() is invisible to the Carrier C SDK. Best for images
|
|
1105
|
+
* and small files; the hard protocol cap is ~5MB (base64-inflated), keep
|
|
1106
|
+
* real use well below it.
|
|
1107
|
+
*/
|
|
1108
|
+
async sendInlineFile(pubkey, opts) {
|
|
1109
|
+
const dot = opts.name.lastIndexOf(".");
|
|
1110
|
+
const ext = dot > 0 ? opts.name.slice(dot).toLowerCase() : "";
|
|
1111
|
+
const base = dot > 0 ? opts.name.slice(0, dot) : opts.name;
|
|
1112
|
+
const type = opts.fileType ?? ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"].includes(ext) ? "image" :
|
|
1113
|
+
[".m4a", ".mp3", ".aac", ".wav", ".ogg"].includes(ext) ? "audio" :
|
|
1114
|
+
[".txt", ".md", ".log"].includes(ext) ? "text" : "unknown");
|
|
1115
|
+
// Key order matters for byte-identical envelopes with iOS (JSONEncoder
|
|
1116
|
+
// .sortedKeys): data, fileExtension, fileName, type — alphabetical.
|
|
1117
|
+
const envelope = JSON.stringify({
|
|
1118
|
+
data: Buffer.from(opts.data).toString("base64"),
|
|
1119
|
+
fileExtension: ext,
|
|
1120
|
+
fileName: base,
|
|
1121
|
+
type
|
|
1122
|
+
});
|
|
1123
|
+
if (envelope.length > CARRIER_MAX_APP_BULKMSG_LEN) {
|
|
1124
|
+
throw new Error(`inline file too large for the message channel (${envelope.length} bytes encoded, max ${CARRIER_MAX_APP_BULKMSG_LEN})`);
|
|
1125
|
+
}
|
|
1126
|
+
await this.sendText(pubkey, envelope);
|
|
1127
|
+
}
|
|
994
1128
|
/**
|
|
995
1129
|
* Send an application-defined custom packet to a friend. `id` selects the
|
|
996
1130
|
* channel and its delivery class by toxcore range: **160–191 lossless**
|
|
@@ -1136,21 +1270,61 @@ export class Peer {
|
|
|
1136
1270
|
* starts with `tcp:` so downstream code can recognize the origin
|
|
1137
1271
|
* and avoid setting `session.remote` to a UDP-shaped value.
|
|
1138
1272
|
*/
|
|
1273
|
+
/** Record a friend's DHT pubkey: in-memory (survives session teardown) and
|
|
1274
|
+
* persisted on the friend record (survives restarts, so start() can park
|
|
1275
|
+
* relay routes under the right key before the friend announces again). */
|
|
1276
|
+
#learnFriendDhtKey(friendId, dhtPublicKey) {
|
|
1277
|
+
if (dhtPublicKey.length !== 32)
|
|
1278
|
+
return;
|
|
1279
|
+
this.#friendDhtKeys.set(friendId, new Uint8Array(dhtPublicKey));
|
|
1280
|
+
const friend = this.#friends.get(friendId);
|
|
1281
|
+
if (friend) {
|
|
1282
|
+
const b58 = carrierIdFromPublicKey(dhtPublicKey);
|
|
1283
|
+
if (friend.dhtPubkey !== b58) {
|
|
1284
|
+
this.#friends.set(friendId, { ...friend, dhtPubkey: b58 });
|
|
1285
|
+
this.#persistFriends();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/** Resolve a TCP-relay-pool key (the key the peer handshook the relay
|
|
1290
|
+
* with) to OUR friend id. For JS peers the two coincide; native peers
|
|
1291
|
+
* (iOS/Android) sit on relays under their DHT key — without this
|
|
1292
|
+
* mapping, every pool event (friendOnline/friendOffline/inbound data
|
|
1293
|
+
* bookkeeping) landed on a GHOST session keyed by the DHT key while the
|
|
1294
|
+
* real session (keyed by identity) never saw route state change. */
|
|
1295
|
+
#friendIdForPoolKey(poolKey) {
|
|
1296
|
+
const asId = carrierIdFromPublicKey(poolKey);
|
|
1297
|
+
if (this.#friends.has(asId))
|
|
1298
|
+
return asId;
|
|
1299
|
+
for (const [friendId, dhtPk] of this.#friendDhtKeys) {
|
|
1300
|
+
if (bytesEqual(dhtPk, poolKey))
|
|
1301
|
+
return friendId;
|
|
1302
|
+
}
|
|
1303
|
+
return asId;
|
|
1304
|
+
}
|
|
1139
1305
|
#handleTcpDatagram(friendKey, payload) {
|
|
1140
|
-
const
|
|
1141
|
-
//
|
|
1306
|
+
const poolId = carrierIdFromPublicKey(friendKey);
|
|
1307
|
+
// Route-state bookkeeping belongs on the REAL friend's session (mapped
|
|
1308
|
+
// via the DHT key for native peers), not a ghost keyed by the pool key.
|
|
1309
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
1142
1310
|
let session = this.#friendSessions.get(friendId);
|
|
1143
1311
|
if (!session) {
|
|
1144
1312
|
session = this.#newSessionShell();
|
|
1145
|
-
session.friendRealPublicKey = new Uint8Array(friendKey);
|
|
1146
1313
|
this.#friendSessions.set(friendId, session);
|
|
1147
1314
|
}
|
|
1148
1315
|
session.hasTcpRoute = true;
|
|
1149
|
-
|
|
1150
|
-
|
|
1316
|
+
if (friendId !== poolId) {
|
|
1317
|
+
session.friendDhtPublicKey ??= new Uint8Array(friendKey);
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
session.friendRealPublicKey ??= new Uint8Array(friendKey);
|
|
1321
|
+
}
|
|
1322
|
+
// Feed through the regular dispatcher with synthetic remote. Keep the
|
|
1323
|
+
// POOL key in the remote tag: the crypto layer matched sessions by this
|
|
1324
|
+
// exact string during the (proven-working) handshake flow.
|
|
1151
1325
|
this.#onDatagram({
|
|
1152
1326
|
data: Buffer.from(payload),
|
|
1153
|
-
remote: { address: `tcp:${
|
|
1327
|
+
remote: { address: `tcp:${poolId}`, port: 0 }
|
|
1154
1328
|
});
|
|
1155
1329
|
}
|
|
1156
1330
|
/** True when the synthetic remote was constructed by #handleTcpDatagram. */
|
|
@@ -1250,6 +1424,15 @@ export class Peer {
|
|
|
1250
1424
|
this.#debugLog(`cookie response: no tcp relay accepted oob send for ${senderId}`);
|
|
1251
1425
|
}
|
|
1252
1426
|
}
|
|
1427
|
+
else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
|
|
1428
|
+
// The request arrived sourced from our own TUN address (or our own
|
|
1429
|
+
// socket) — an overlay routing artifact. Replying there loops into
|
|
1430
|
+
// ourselves and dies. Answer over the relay OOB path instead, which
|
|
1431
|
+
// the peer reliably receives. (A self PHYSICAL address with a
|
|
1432
|
+
// different port is a same-host peer and gets the normal UDP reply.)
|
|
1433
|
+
const sent = this.#tcpRelays?.sendOobToFriend(request.senderDhtPublicKey, response) ?? 0;
|
|
1434
|
+
this.#debugLog(`cookie response: source ${remote.address}:${remote.port} is an unroutable self-source — replied via tcp_oob instead (relays=${sent})`);
|
|
1435
|
+
}
|
|
1253
1436
|
else {
|
|
1254
1437
|
void this.#sendPacket(response, { host: remote.address, port: remote.port })
|
|
1255
1438
|
.then(() => {
|
|
@@ -1431,6 +1614,9 @@ export class Peer {
|
|
|
1431
1614
|
if (!state.friendDhtPublicKey)
|
|
1432
1615
|
state.friendDhtPublicKey = hs.senderDhtPublicKey;
|
|
1433
1616
|
}
|
|
1617
|
+
if (hs.senderDhtPublicKey && hs.senderDhtPublicKey.length === 32) {
|
|
1618
|
+
this.#learnFriendDhtKey(friendId, hs.senderDhtPublicKey);
|
|
1619
|
+
}
|
|
1434
1620
|
const wasEstablished = state.established === true;
|
|
1435
1621
|
// Detect a real session reset: the peer is offering NEW session
|
|
1436
1622
|
// keys (different ephemeral session pubkey from before). When
|
|
@@ -1479,14 +1665,25 @@ export class Peer {
|
|
|
1479
1665
|
// the current session is dead — fall through and accept the new one.
|
|
1480
1666
|
const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
|
|
1481
1667
|
const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
|
|
1482
|
-
if
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1668
|
+
// The peer has RE-KEYED if it's sending us crypto data we can't decrypt
|
|
1669
|
+
// more recently than the last packet we could — our "alive" signal above
|
|
1670
|
+
// is stale (it counts packets from just before the peer flapped). This is
|
|
1671
|
+
// the flapping iOS/native case: iOS drops, re-handshakes with a fresh
|
|
1672
|
+
// session key, then sends online messages we can't decrypt because we're
|
|
1673
|
+
// still holding the dead keys and rejecting its fix-up handshake for the
|
|
1674
|
+
// whole 32s timeout. Accept the fresh handshake NOW to resync. Safe: a
|
|
1675
|
+
// healthy session never produces undecryptable data, so peerReKeyed stays
|
|
1676
|
+
// false for it — this can't churn a live session (the reverted "half-open
|
|
1677
|
+
// breaker" churned because it accepted new keys with NO such evidence).
|
|
1678
|
+
const peerReKeyed = state.undecryptableRecvMs !== undefined &&
|
|
1679
|
+
state.undecryptableRecvMs > (state.lastPingRecvMs ?? 0);
|
|
1680
|
+
if (sinceEstablished > 1000 && currentSessionAlive && !peerReKeyed) {
|
|
1487
1681
|
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
|
|
1488
1682
|
return;
|
|
1489
1683
|
}
|
|
1684
|
+
if (peerReKeyed) {
|
|
1685
|
+
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (peer re-keyed: undecryptable data since last good packet)`);
|
|
1686
|
+
}
|
|
1490
1687
|
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
|
|
1491
1688
|
`lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
|
|
1492
1689
|
}
|
|
@@ -1495,6 +1692,10 @@ export class Peer {
|
|
|
1495
1692
|
state.sessionSharedKey = nacl.box.before(hs.sessionPublicKey, state.ourSessionKeyPair.secretKey);
|
|
1496
1693
|
state.established = true;
|
|
1497
1694
|
state.sessionEstablishedAtMs = Date.now();
|
|
1695
|
+
// Fresh session up — reset the higher-pubkey defer window for the
|
|
1696
|
+
// next outage (see #initiateSession).
|
|
1697
|
+
this.#initiateDeferSinceMs.delete(friendId);
|
|
1698
|
+
this.#initiateSkipLogged.delete(friendId);
|
|
1498
1699
|
if (this.#remoteIsTcp(remote)) {
|
|
1499
1700
|
state.hasTcpRoute = true;
|
|
1500
1701
|
// Don't set state.remote — there's no UDP endpoint for this peer.
|
|
@@ -1505,6 +1706,11 @@ export class Peer {
|
|
|
1505
1706
|
if (isNewSession) {
|
|
1506
1707
|
state.sendPacketNumber = 0;
|
|
1507
1708
|
state.receiveBufferStart = 0;
|
|
1709
|
+
// Packet numbers restart at 0 — parked retransmit payloads from
|
|
1710
|
+
// the previous session are meaningless (and would resend under
|
|
1711
|
+
// colliding numbers). Drop them.
|
|
1712
|
+
state.sendArray = undefined;
|
|
1713
|
+
state.sendBufferStartNum = undefined;
|
|
1508
1714
|
}
|
|
1509
1715
|
else {
|
|
1510
1716
|
state.sendPacketNumber ??= 0;
|
|
@@ -1670,6 +1876,13 @@ export class Peer {
|
|
|
1670
1876
|
}
|
|
1671
1877
|
state.lastRelayRecvMs = Date.now();
|
|
1672
1878
|
}
|
|
1879
|
+
else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
|
|
1880
|
+
// Sourced from our own TUN address / own socket (overlay routing
|
|
1881
|
+
// artifact): the payload is real — deliver it — but this is NOT a
|
|
1882
|
+
// usable bidirectional UDP path. Don't adopt it (replies loop into
|
|
1883
|
+
// our own interface) and don't mark UDP-confirmed, or the punch
|
|
1884
|
+
// machinery stops trying to establish a REAL direct path.
|
|
1885
|
+
}
|
|
1673
1886
|
else {
|
|
1674
1887
|
// Adopt the source as our send endpoint — via #adoptRemote, which
|
|
1675
1888
|
// refuses to downgrade off a locked physical-LAN path to a public /
|
|
@@ -1688,12 +1901,44 @@ export class Peer {
|
|
|
1688
1901
|
if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
|
|
1689
1902
|
continue;
|
|
1690
1903
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1904
|
+
// Track the receive nonce from the EXACT nonce this packet used
|
|
1905
|
+
// (openCryptoDataPacket reconstructs it with full carry/borrow).
|
|
1906
|
+
// The old two-byte overwrite dropped the carry into byte 21 when
|
|
1907
|
+
// loss spanned a 64k counter wrap, permanently desyncing the
|
|
1908
|
+
// session. Only move FORWARD — a late reordered packet must not
|
|
1909
|
+
// drag the tracked nonce backward.
|
|
1910
|
+
{
|
|
1911
|
+
const trackedLow = ((state.peerBaseNonce[state.peerBaseNonce.length - 2] << 8) | state.peerBaseNonce[state.peerBaseNonce.length - 1]) >>> 0;
|
|
1912
|
+
const fwd = ((opened.nonceLast2 - trackedLow) & 0xffff) < 0x8000;
|
|
1913
|
+
if (fwd) {
|
|
1914
|
+
state.peerBaseNonce = opened.usedNonce.slice();
|
|
1915
|
+
incrementNonce(state.peerBaseNonce);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1695
1918
|
const kind = opened.payload[0];
|
|
1696
1919
|
const inner = opened.payload.slice(1);
|
|
1920
|
+
// Advance our ack point ONLY for kinds that actually consume a
|
|
1921
|
+
// reliable packet number on the sender. Toxcore sends REQUEST(1)
|
|
1922
|
+
// and lossy (>=192) packets with num = send_array.buffer_end — the
|
|
1923
|
+
// NEXT UNUSED number, not stored in its send array. Counting those
|
|
1924
|
+
// advanced our receiveBufferStart to buffer_end+1, i.e. we then
|
|
1925
|
+
// acked one more packet than the peer ever sent — and toxcore's
|
|
1926
|
+
// handle_data_packet_core REJECTS any packet whose buffer_start is
|
|
1927
|
+
// out of its send window (clear_buffer_until -> return -1). Result:
|
|
1928
|
+
// from the peer's FIRST retransmit request onward, every packet we
|
|
1929
|
+
// sent was discarded whole (fresh and retransmitted alike) and the
|
|
1930
|
+
// peer went deaf until it re-keyed — the final piece of the iOS
|
|
1931
|
+
// session churn. The JS droppable bulk channel (bulkDataPacketId)
|
|
1932
|
+
// reuses the current number without consuming it, so it must not
|
|
1933
|
+
// advance the ack point either (also fixes reliable packets being
|
|
1934
|
+
// dropped as duplicates after a droppable burst under the same
|
|
1935
|
+
// number).
|
|
1936
|
+
const consumesReliableNumber = kind !== PACKET_ID_REQUEST &&
|
|
1937
|
+
!(kind !== undefined && kind >= 192) &&
|
|
1938
|
+
kind !== this.#opts.bulkDataPacketId;
|
|
1939
|
+
if (consumesReliableNumber) {
|
|
1940
|
+
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
1941
|
+
}
|
|
1697
1942
|
// Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
|
|
1698
1943
|
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
1699
1944
|
return;
|
|
@@ -1729,9 +1974,13 @@ export class Peer {
|
|
|
1729
1974
|
return;
|
|
1730
1975
|
}
|
|
1731
1976
|
if (kind === PACKET_ID_REQUEST) {
|
|
1732
|
-
// Lossless retransmission request
|
|
1733
|
-
//
|
|
1734
|
-
|
|
1977
|
+
// Lossless retransmission request (toxcore handle_request_packet).
|
|
1978
|
+
// Bytes are 1-based deltas walking our send window from
|
|
1979
|
+
// sendBufferStartNum: a matching delta marks that packet number
|
|
1980
|
+
// REQUESTED (peer is missing it — resend); every walked number
|
|
1981
|
+
// that doesn't match is implicitly ACKED (peer has it — drop from
|
|
1982
|
+
// the buffer). 0 is a +255 filler.
|
|
1983
|
+
this.#handleRetransmitRequest(friendId, state, inner);
|
|
1735
1984
|
return;
|
|
1736
1985
|
}
|
|
1737
1986
|
if (kind === PACKET_ID_SHARE_RELAYS) {
|
|
@@ -1810,17 +2059,44 @@ export class Peer {
|
|
|
1810
2059
|
return;
|
|
1811
2060
|
}
|
|
1812
2061
|
if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
|
|
1813
|
-
|
|
1814
|
-
//
|
|
1815
|
-
|
|
2062
|
+
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
2063
|
+
// Decode the Carrier FlatBuffers wrapper when present. A BULKMSG
|
|
2064
|
+
// fragment (Carrier C splits any message >1024 bytes this way —
|
|
2065
|
+
// e.g. iOS Beagle images, which are FileModel JSON envelopes)
|
|
2066
|
+
// goes to the reassembler and only emits once complete; before
|
|
2067
|
+
// this, every fragment surfaced as a separate garbage "text".
|
|
2068
|
+
let text;
|
|
2069
|
+
let carrier;
|
|
2070
|
+
try {
|
|
2071
|
+
carrier = decodeCarrierPacket(inner);
|
|
2072
|
+
}
|
|
2073
|
+
catch { /* raw utf-8 peer */ }
|
|
2074
|
+
if (carrier?.type === PACKET_TYPE_BULKMSG) {
|
|
2075
|
+
const complete = this.#assembleBulkMsg(friendId, carrier);
|
|
2076
|
+
if (!complete)
|
|
2077
|
+
return; // more fragments pending
|
|
2078
|
+
text = decodeUtf8Best(complete);
|
|
2079
|
+
this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
|
|
2080
|
+
}
|
|
2081
|
+
else if (carrier?.type === PACKET_TYPE_MESSAGE) {
|
|
2082
|
+
text = new TextDecoder().decode(carrier.data);
|
|
2083
|
+
}
|
|
2084
|
+
else {
|
|
2085
|
+
text = decodeUtf8Best(inner);
|
|
2086
|
+
}
|
|
1816
2087
|
if (!text) {
|
|
1817
2088
|
this.#debugLog(`crypto message packet decode failed from ${friendId}`);
|
|
1818
2089
|
return;
|
|
1819
2090
|
}
|
|
1820
|
-
|
|
2091
|
+
// iOS Beagle sends files inline as a FileModel JSON envelope
|
|
2092
|
+
// ({data: base64, fileExtension, fileName, type}) — surface those
|
|
2093
|
+
// as files, not as a wall of base64 text.
|
|
2094
|
+
if (this.#tryEmitInlineFile(friendId, text, "online"))
|
|
2095
|
+
return;
|
|
1821
2096
|
this.#events.emit("text", {
|
|
1822
2097
|
pubkey: friendId,
|
|
1823
|
-
text
|
|
2098
|
+
text,
|
|
2099
|
+
via: "online"
|
|
1824
2100
|
});
|
|
1825
2101
|
this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
|
|
1826
2102
|
return;
|
|
@@ -1852,6 +2128,25 @@ export class Peer {
|
|
|
1852
2128
|
// CRASH (TURN DNS); with that fixed, sessions stay converged on their own.
|
|
1853
2129
|
// Just drop the undecryptable packet — net_crypto's normal re-handshake
|
|
1854
2130
|
// path recovers a genuinely dead session.
|
|
2131
|
+
// If this came over the TCP relay (synthetic tcp:<friendId> remote) we KNOW
|
|
2132
|
+
// which friend sent it. Record the decrypt failure so the handshake path can
|
|
2133
|
+
// distinguish a re-keyed peer (desync — accept its fresh handshake now) from
|
|
2134
|
+
// a live one, instead of holding dead keys for the full timeout while every
|
|
2135
|
+
// online message is silently dropped.
|
|
2136
|
+
if (remote.address.startsWith("tcp:")) {
|
|
2137
|
+
// remote carries the POOL key id (native peers: DHT key) — map to
|
|
2138
|
+
// the real friend so the desync marker lands on the real session.
|
|
2139
|
+
let desyncFriendId = remote.address.slice(4);
|
|
2140
|
+
try {
|
|
2141
|
+
desyncFriendId = this.#friendIdForPoolKey(base58ToBytes(desyncFriendId));
|
|
2142
|
+
}
|
|
2143
|
+
catch { /* keep raw id */ }
|
|
2144
|
+
const ds = this.#friendSessions.get(desyncFriendId);
|
|
2145
|
+
if (ds?.established) {
|
|
2146
|
+
ds.undecryptableRecvMs = Date.now();
|
|
2147
|
+
this.#debugVerboseLog(`undecryptable crypto data from ${desyncFriendId} (peer likely re-keyed)`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
1855
2150
|
this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
|
|
1856
2151
|
return;
|
|
1857
2152
|
}
|
|
@@ -1949,6 +2244,7 @@ export class Peer {
|
|
|
1949
2244
|
session.lastDhtPkNoReplay = noReplay;
|
|
1950
2245
|
session.friendRealPublicKey ??= senderPublicKey;
|
|
1951
2246
|
session.friendDhtPublicKey = friendDhtPublicKey;
|
|
2247
|
+
this.#learnFriendDhtKey(senderId, friendDhtPublicKey);
|
|
1952
2248
|
// A DHT-PK announce from this friend IS the proof they've accepted
|
|
1953
2249
|
// our friend request — only friends actively trying to reach us send
|
|
1954
2250
|
// these. Mark acceptedAt so subsequent peer.sendFriendRequest calls
|
|
@@ -2165,7 +2461,11 @@ export class Peer {
|
|
|
2165
2461
|
// Malformed pubkey, fall through.
|
|
2166
2462
|
}
|
|
2167
2463
|
}
|
|
2168
|
-
|
|
2464
|
+
// Offline images/audio arrive as the same FileModel JSON envelope
|
|
2465
|
+
// (iOS posts the whole thing as one express MESSAGE packet).
|
|
2466
|
+
if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
|
|
2467
|
+
return;
|
|
2468
|
+
this.#events.emit("text", { pubkey: fromUserId, text, via: "offline" });
|
|
2169
2469
|
}
|
|
2170
2470
|
catch {
|
|
2171
2471
|
// Ignore invalid offline payloads.
|
|
@@ -2459,7 +2759,10 @@ export class Peer {
|
|
|
2459
2759
|
dataPublicKey: this.#announceDataKey.publicKey,
|
|
2460
2760
|
sendBack: c.sendBack,
|
|
2461
2761
|
allowDirectFallback: true,
|
|
2462
|
-
attempts: SELF_ANNOUNCE_ATTEMPTS
|
|
2762
|
+
attempts: SELF_ANNOUNCE_ATTEMPTS,
|
|
2763
|
+
// ping_id is source-bound: step2 must exit from the same hop
|
|
2764
|
+
// step1 did or the node rejects it and nothing ever stores.
|
|
2765
|
+
reuseRoute: true
|
|
2463
2766
|
})));
|
|
2464
2767
|
for (let j = 0; j < step1Hits.length; j++) {
|
|
2465
2768
|
const { c, resp1 } = step1Hits[j];
|
|
@@ -2474,6 +2777,7 @@ export class Peer {
|
|
|
2474
2777
|
catch { /* best-effort */ }
|
|
2475
2778
|
if (final.isStored === 2) {
|
|
2476
2779
|
storedNodes.push(c.node);
|
|
2780
|
+
this.#debugLog(`self announce STORED on ${c.node.host}:${c.node.port} (total ${storedNodes.length})`);
|
|
2477
2781
|
// Keep dhtHealth.selfAnnounceStoredOn live within the loop,
|
|
2478
2782
|
// not just at the end, so we see growth in real time.
|
|
2479
2783
|
this.#lastSelfAnnounceStoredCount = storedNodes.length;
|
|
@@ -2564,8 +2868,17 @@ export class Peer {
|
|
|
2564
2868
|
// sessions a long grace so a blackout doesn't churn them via re-handshake;
|
|
2565
2869
|
// unproven sessions still time out fast to self-heal a wedged handshake.
|
|
2566
2870
|
const proven = session.lastPingRecvMs !== undefined;
|
|
2567
|
-
|
|
2568
|
-
|
|
2871
|
+
// A relay-only session (synthetic tcp: remote, no UDP path) whose
|
|
2872
|
+
// relay route is gone has NO transport at all — nothing can arrive
|
|
2873
|
+
// and nothing we send leaves. The proven-session blackout grace
|
|
2874
|
+
// exists for UDP paths that black out (GFW) while both ends keep
|
|
2875
|
+
// keys; it must NOT keep a transportless relay session as a 180s
|
|
2876
|
+
// zombie (iOS flap: peer sees us vanish while we still show them
|
|
2877
|
+
// online and drop every send). Fast-track those to teardown.
|
|
2878
|
+
const relayOnly = !session.remote || session.remote.host.startsWith("tcp:");
|
|
2879
|
+
const pathless = relayOnly && !session.hasTcpRoute;
|
|
2880
|
+
const deadline = proven && !pathless ? PROVEN_SESSION_HARD_TIMEOUT_MS : FRIEND_TIMEOUT_MS;
|
|
2881
|
+
if (proven && !pathless && silentFor > FRIEND_TIMEOUT_MS && silentFor <= deadline) {
|
|
2569
2882
|
// Blackout grace: keep the proven session and keep probing (the
|
|
2570
2883
|
// keepalive + UDP re-punch + relay-keepalive below all still run). Do
|
|
2571
2884
|
// NOT delete or re-handshake — that's what desyncs and churns. The peer,
|
|
@@ -2587,6 +2900,11 @@ export class Peer {
|
|
|
2587
2900
|
const pk = base58ToBytes(friend.pubkey);
|
|
2588
2901
|
if (pk.length === 32)
|
|
2589
2902
|
this.#tcpRelays?.requestRoute(pk);
|
|
2903
|
+
// Native peers sit on relays under their DHT key — re-request
|
|
2904
|
+
// that too or the relay never re-links after this teardown.
|
|
2905
|
+
const dhtPk = this.#friendDhtKeys.get(friendId);
|
|
2906
|
+
if (dhtPk && dhtPk.length === 32)
|
|
2907
|
+
this.#tcpRelays?.requestRoute(dhtPk);
|
|
2590
2908
|
}
|
|
2591
2909
|
catch { /* skip malformed */ }
|
|
2592
2910
|
continue;
|
|
@@ -2774,30 +3092,46 @@ export class Peer {
|
|
|
2774
3092
|
// bridged them, cookie+handshake fired once, didn't complete, and
|
|
2775
3093
|
// nothing re-tried it. Counting the relay route lets the retry fire.
|
|
2776
3094
|
const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
3095
|
+
// Route parking runs for EVERY non-established friend — deliberately
|
|
3096
|
+
// NOT gated on haveEndpoint. A stale persisted remoteHost (e.g. a
|
|
3097
|
+
// bootstrap ip:port cached by an old bug) counts as an "endpoint" and
|
|
3098
|
+
// used to suppress this block entirely, so the friend was never
|
|
3099
|
+
// watched on any relay and could only ever be reached by spamming
|
|
3100
|
+
// cookies at the dead address (the iOS-simulator-forever-offline bug).
|
|
3101
|
+
// For a peer whose DHT self-announce never stored (WSL2, symmetric
|
|
3102
|
+
// NAT, restrictive firewall), the TCP relay is the ONLY bootstrap:
|
|
3103
|
+
// ask our relays to route to the friend's keys; when both ends share
|
|
3104
|
+
// a relay the pool fires `friendOnline` -> #initiateSession.
|
|
3105
|
+
// Re-asserted on a cooldown — cheap and idempotent.
|
|
3106
|
+
{
|
|
2789
3107
|
const lastRouteReq = this.#routeRequestCooldown.get(friendId) ?? 0;
|
|
2790
3108
|
if (this.#tcpRelays && now - lastRouteReq > 15_000) {
|
|
2791
3109
|
try {
|
|
2792
3110
|
const pk = base58ToBytes(friend.pubkey);
|
|
3111
|
+
// The relay routes CRYPTO_DATA by the key the peer CONNECTED to the
|
|
3112
|
+
// relay with. For a NATIVE peer (iOS/Android) that's its DHT key,
|
|
3113
|
+
// NOT its identity key — and the send path already routes by
|
|
3114
|
+
// friendDhtPublicKey. Requesting the route ONLY under the identity
|
|
3115
|
+
// key (as before) meant the relay never linked us to the peer: its
|
|
3116
|
+
// handshake arrives OOB (which needs no route), but every continuous
|
|
3117
|
+
// CRYPTO_DATA packet (chat + keepalives) was silently dropped, so the
|
|
3118
|
+
// session came up then went dead in 32s. Request the DHT key too so
|
|
3119
|
+
// the routed path actually establishes; keep the identity key for
|
|
3120
|
+
// toxcore peers where the two match.
|
|
3121
|
+
const dhtPk = this.#friendSessions.get(friendId)?.friendDhtPublicKey ?? this.#friendDhtKeys.get(friendId);
|
|
3122
|
+
if (dhtPk && dhtPk.length === 32) {
|
|
3123
|
+
this.#tcpRelays.requestRoute(dhtPk);
|
|
3124
|
+
}
|
|
2793
3125
|
if (pk.length === 32) {
|
|
2794
3126
|
this.#tcpRelays.requestRoute(pk);
|
|
2795
3127
|
this.#routeRequestCooldown.set(friendId, now);
|
|
2796
|
-
this.#
|
|
3128
|
+
this.#debugVerboseLog(`re-requesting relay route for unconnected friend ${friendId}${dhtPk ? " (+dht key)" : ""}`);
|
|
2797
3129
|
}
|
|
2798
3130
|
}
|
|
2799
3131
|
catch { /* skip malformed pubkey */ }
|
|
2800
3132
|
}
|
|
3133
|
+
}
|
|
3134
|
+
if (!haveEndpoint) {
|
|
2801
3135
|
// Re-SEND the friend-request to a friend we've never had accepted
|
|
2802
3136
|
// (still "requested"). The original can be lost — the friend was
|
|
2803
3137
|
// offline/restarting, a relay blipped, or onion had no return path.
|
|
@@ -2874,6 +3208,140 @@ export class Peer {
|
|
|
2874
3208
|
});
|
|
2875
3209
|
}
|
|
2876
3210
|
}
|
|
3211
|
+
/** Append a Carrier BULKMSG fragment; returns the full reassembled
|
|
3212
|
+
* message when the last fragment lands, undefined while pending.
|
|
3213
|
+
* Mirrors the C SDK's handle_friend_bulkmsg: totalsz on the first
|
|
3214
|
+
* fragment sizes the buffer; fragments append in arrival order. */
|
|
3215
|
+
#assembleBulkMsg(friendId, frag) {
|
|
3216
|
+
const now = Date.now();
|
|
3217
|
+
// Lazy expiry sweep (C uses a 60s assembly timeout).
|
|
3218
|
+
for (const [key, entry] of this.#bulkAssembly) {
|
|
3219
|
+
if (entry.expireAtMs < now)
|
|
3220
|
+
this.#bulkAssembly.delete(key);
|
|
3221
|
+
}
|
|
3222
|
+
const key = `${friendId}:${frag.tid.toString()}`;
|
|
3223
|
+
let entry = this.#bulkAssembly.get(key);
|
|
3224
|
+
if (!entry) {
|
|
3225
|
+
if (!frag.totalsz || frag.totalsz > CARRIER_MAX_APP_BULKMSG_LEN) {
|
|
3226
|
+
this.#debugLog(`bulkmsg from ${friendId} with invalid/missing totalsz ${frag.totalsz} — dropped`);
|
|
3227
|
+
return undefined;
|
|
3228
|
+
}
|
|
3229
|
+
entry = { total: frag.totalsz, chunks: [], got: 0, expireAtMs: now + 60_000 };
|
|
3230
|
+
this.#bulkAssembly.set(key, entry);
|
|
3231
|
+
}
|
|
3232
|
+
if (frag.data.length === 0 || entry.got + frag.data.length > entry.total) {
|
|
3233
|
+
this.#debugLog(`bulkmsg fragment from ${friendId} overflows totalsz — dropped`);
|
|
3234
|
+
this.#bulkAssembly.delete(key);
|
|
3235
|
+
return undefined;
|
|
3236
|
+
}
|
|
3237
|
+
entry.chunks.push(frag.data);
|
|
3238
|
+
entry.got += frag.data.length;
|
|
3239
|
+
if (entry.got < entry.total)
|
|
3240
|
+
return undefined;
|
|
3241
|
+
this.#bulkAssembly.delete(key);
|
|
3242
|
+
return concatBytes(entry.chunks);
|
|
3243
|
+
}
|
|
3244
|
+
/** iOS Beagle sends files inline as a JSON envelope over (bulk)messages:
|
|
3245
|
+
* {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}.
|
|
3246
|
+
* Detect it, decode, and emit an inlineFile event instead of dumping
|
|
3247
|
+
* base64 into the chat. Returns true when handled. */
|
|
3248
|
+
#tryEmitInlineFile(friendId, text, via) {
|
|
3249
|
+
const trimmed = text.trimStart();
|
|
3250
|
+
if (!trimmed.startsWith("{") || !trimmed.includes("\"fileName\"") || !trimmed.includes("\"data\"")) {
|
|
3251
|
+
return false;
|
|
3252
|
+
}
|
|
3253
|
+
try {
|
|
3254
|
+
const parsed = JSON.parse(trimmed);
|
|
3255
|
+
if (typeof parsed.data !== "string" || typeof parsed.fileName !== "string")
|
|
3256
|
+
return false;
|
|
3257
|
+
const data = Uint8Array.from(Buffer.from(parsed.data, "base64"));
|
|
3258
|
+
const ext = parsed.fileExtension ?? "";
|
|
3259
|
+
const name = parsed.fileName.endsWith(ext) ? parsed.fileName : parsed.fileName + ext;
|
|
3260
|
+
this.#events.emit("inlineFile", {
|
|
3261
|
+
pubkey: friendId,
|
|
3262
|
+
name,
|
|
3263
|
+
fileType: parsed.type,
|
|
3264
|
+
data,
|
|
3265
|
+
via
|
|
3266
|
+
});
|
|
3267
|
+
this.#debugLog(`inline file from ${friendId}: "${name}" (${data.length} bytes, ${via})`);
|
|
3268
|
+
return true;
|
|
3269
|
+
}
|
|
3270
|
+
catch {
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
/** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
|
|
3275
|
+
* (a) implicitly ACK every walked packet number that isn't requested,
|
|
3276
|
+
* (b) retransmit the requested ones from the send buffer. */
|
|
3277
|
+
#handleRetransmitRequest(friendId, session, data) {
|
|
3278
|
+
const start = session.sendBufferStartNum;
|
|
3279
|
+
const end = session.sendPacketNumber;
|
|
3280
|
+
if (start === undefined || end === undefined || !session.sendArray || session.sendArray.size === 0) {
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
let cursor = 0;
|
|
3284
|
+
let n = 1;
|
|
3285
|
+
const requested = [];
|
|
3286
|
+
for (let i = start; i !== end; i = (i + 1) >>> 0) {
|
|
3287
|
+
if (cursor >= data.length)
|
|
3288
|
+
break;
|
|
3289
|
+
if (n === data[cursor]) {
|
|
3290
|
+
requested.push(i);
|
|
3291
|
+
cursor++;
|
|
3292
|
+
n = 0;
|
|
3293
|
+
}
|
|
3294
|
+
else {
|
|
3295
|
+
// Peer has this one — implicit ack, free it.
|
|
3296
|
+
session.sendArray.delete(i);
|
|
3297
|
+
}
|
|
3298
|
+
if (n === 255) {
|
|
3299
|
+
n = 1;
|
|
3300
|
+
if (data[cursor] !== 0)
|
|
3301
|
+
return; // malformed filler
|
|
3302
|
+
cursor++;
|
|
3303
|
+
}
|
|
3304
|
+
else {
|
|
3305
|
+
n++;
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
// Advance the window past the acked prefix.
|
|
3309
|
+
let s = start;
|
|
3310
|
+
while (s !== end && !session.sendArray.has(s))
|
|
3311
|
+
s = (s + 1) >>> 0;
|
|
3312
|
+
session.sendBufferStartNum = s;
|
|
3313
|
+
if (requested.length === 0)
|
|
3314
|
+
return;
|
|
3315
|
+
this.#debugLog(`retransmit request from ${friendId}: resending ${requested.length} packet(s) [${requested.slice(0, 8).join(",")}${requested.length > 8 ? ",…" : ""}]`);
|
|
3316
|
+
void (async () => {
|
|
3317
|
+
for (const num of requested) {
|
|
3318
|
+
const payload = session.sendArray?.get(num);
|
|
3319
|
+
if (!payload)
|
|
3320
|
+
continue;
|
|
3321
|
+
try {
|
|
3322
|
+
await this.#resendReliablePacket(friendId, session, num, payload);
|
|
3323
|
+
}
|
|
3324
|
+
catch { /* transport hiccup — peer will re-request */ }
|
|
3325
|
+
}
|
|
3326
|
+
})();
|
|
3327
|
+
}
|
|
3328
|
+
/** Re-send a parked reliable packet with its ORIGINAL packet number but
|
|
3329
|
+
* the CURRENT nonce counter (toxcore retransmissions do the same — the
|
|
3330
|
+
* receiver reconstructs the nonce from the wire bytes, while the packet
|
|
3331
|
+
* number slots it into the gap in their receive window). */
|
|
3332
|
+
async #resendReliablePacket(friendId, session, packetNumber, payload) {
|
|
3333
|
+
if (!session.established || !session.sessionSharedKey || !session.ourBaseNonce)
|
|
3334
|
+
return;
|
|
3335
|
+
const encrypted = createCryptoDataPacket({
|
|
3336
|
+
sessionSharedKey: session.sessionSharedKey,
|
|
3337
|
+
sentNonce: session.ourBaseNonce,
|
|
3338
|
+
bufferStart: session.receiveBufferStart ?? 0,
|
|
3339
|
+
packetNumber,
|
|
3340
|
+
payload
|
|
3341
|
+
});
|
|
3342
|
+
incrementNonce(session.ourBaseNonce);
|
|
3343
|
+
await this.#sendToFriend(friendId, encrypted, session, false, false);
|
|
3344
|
+
}
|
|
2877
3345
|
async #sendMessengerPacket(friendId, kind, data) {
|
|
2878
3346
|
const session = this.#friendSessions.get(friendId);
|
|
2879
3347
|
if (!session?.established || !session.sessionSharedKey || !session.ourBaseNonce) {
|
|
@@ -2892,7 +3360,35 @@ export class Peer {
|
|
|
2892
3360
|
payload
|
|
2893
3361
|
});
|
|
2894
3362
|
incrementNonce(session.ourBaseNonce);
|
|
2895
|
-
|
|
3363
|
+
// Reliable-sequence hygiene. Only packets we will actually DELIVER reliably
|
|
3364
|
+
// may consume the reliable packet-number sequence. The app's high-volume bulk
|
|
3365
|
+
// channel (bulkDataPacketId, e.g. decentlan IP=163) and toxcore lossy packets
|
|
3366
|
+
// (192-254) are DROPPED under backpressure — if they burn a reliable number
|
|
3367
|
+
// first, the drop punches a PERMANENT hole in the stream. A native (iOS/C)
|
|
3368
|
+
// peer does strict in-order net_crypto delivery and stalls EVERYTHING after a
|
|
3369
|
+
// hole (incl. chat 64), sending PACKET_ID_REQUEST for a retransmit we never do
|
|
3370
|
+
// (no retransmit queue) — silently wedging online chat with native peers.
|
|
3371
|
+
// File transfer is bulk but NEVER dropped (queued reliably), so it keeps its
|
|
3372
|
+
// number. So advance the reliable number for everything EXCEPT the droppable
|
|
3373
|
+
// bulk-IP channel and lossy packets; the crypto nonce still advances above so
|
|
3374
|
+
// decryption stays in sync regardless.
|
|
3375
|
+
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
|
|
3376
|
+
const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
|
|
3377
|
+
const isDroppable = (isBulkData && !isFileTransfer) || kind >= 192;
|
|
3378
|
+
if (!isDroppable) {
|
|
3379
|
+
session.sendPacketNumber = (packetNumber + 1) >>> 0;
|
|
3380
|
+
// Park the plaintext for retransmission (native peers stall their
|
|
3381
|
+
// whole lossless stream on a gap until we honor PACKET_ID_REQUEST).
|
|
3382
|
+
session.sendArray ??= new Map();
|
|
3383
|
+
session.sendBufferStartNum ??= packetNumber;
|
|
3384
|
+
session.sendArray.set(packetNumber, payload);
|
|
3385
|
+
// Bound the buffer: beyond this window the peer has either acked via
|
|
3386
|
+
// REQUEST packets or the session is beyond saving anyway.
|
|
3387
|
+
while (session.sendArray.size > 1024 && session.sendBufferStartNum !== session.sendPacketNumber) {
|
|
3388
|
+
session.sendArray.delete(session.sendBufferStartNum);
|
|
3389
|
+
session.sendBufferStartNum = (session.sendBufferStartNum + 1) >>> 0;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
2896
3392
|
session.lastPingSentMs = Date.now();
|
|
2897
3393
|
// Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
|
|
2898
3394
|
// traffic. Once UDP is established it should ride UDP exclusively and
|
|
@@ -2925,12 +3421,10 @@ export class Peer {
|
|
|
2925
3421
|
// happens to finish before reordering bites. Pinning file transfer to a
|
|
2926
3422
|
// single transport keeps the chunks in send order. (Reliability across a
|
|
2927
3423
|
// lossy path still wants a reorder buffer + retransmit — tracked separately.)
|
|
2928
|
-
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
|
|
2929
|
-
const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
|
|
2930
3424
|
// File transfer rides the relay RELIABLY (never dropped under backpressure):
|
|
2931
3425
|
// a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
|
|
2932
3426
|
// chunk corrupts the file (no retransmit). So bulk IP data stays droppable
|
|
2933
|
-
// while file transfer is queued.
|
|
3427
|
+
// while file transfer is queued. (isBulkData / isFileTransfer computed above.)
|
|
2934
3428
|
await this.#sendToFriend(friendId, encrypted, session, isBulkData, isFileTransfer);
|
|
2935
3429
|
}
|
|
2936
3430
|
/**
|
|
@@ -3134,6 +3628,11 @@ export class Peer {
|
|
|
3134
3628
|
if (!friend) {
|
|
3135
3629
|
return;
|
|
3136
3630
|
}
|
|
3631
|
+
// An unroutable self-source is never the friend's endpoint (see
|
|
3632
|
+
// #adoptRemote) — don't let it poison the in-memory record either.
|
|
3633
|
+
if (this.#isUnroutableSelfSource(host, port)) {
|
|
3634
|
+
return;
|
|
3635
|
+
}
|
|
3137
3636
|
// Important: do NOT persist this candidate to disk. Speculative endpoint
|
|
3138
3637
|
// hints (from DHT-PK extras, sendnodes-derived knownNodes, etc.) are
|
|
3139
3638
|
// frequently stale relay addresses that aren't actually the friend's
|
|
@@ -3166,6 +3665,7 @@ export class Peer {
|
|
|
3166
3665
|
}
|
|
3167
3666
|
if (dhtPublicKey) {
|
|
3168
3667
|
session.friendDhtPublicKey = dhtPublicKey;
|
|
3668
|
+
this.#learnFriendDhtKey(friendId, dhtPublicKey);
|
|
3169
3669
|
}
|
|
3170
3670
|
}
|
|
3171
3671
|
/** Single choke point for setting session.remote (the direct-UDP send
|
|
@@ -3176,7 +3676,26 @@ export class Peer {
|
|
|
3176
3676
|
* is just a string compare and is safe to call from the per-packet hot path.
|
|
3177
3677
|
* Replacing the per-packet getPhysicalLanSubnets() call here was the fix for
|
|
3178
3678
|
* the CCTV CPU regression. */
|
|
3679
|
+
/** True when replying to host:port would never reach a peer: the host is
|
|
3680
|
+
* one of our own TUN/overlay addresses (replies re-enter the overlay
|
|
3681
|
+
* router and loop), or it's literally our own UDP socket. A self
|
|
3682
|
+
* PHYSICAL address with a DIFFERENT port is routable — that's a
|
|
3683
|
+
* same-host peer (e.g. the iOS simulator running on this Mac). */
|
|
3684
|
+
#isUnroutableSelfSource(host, port) {
|
|
3685
|
+
if (isOwnVirtualAddress(host))
|
|
3686
|
+
return true;
|
|
3687
|
+
if (!isOwnAddress(host))
|
|
3688
|
+
return false;
|
|
3689
|
+
const ourPort = this.#udp?.localPort();
|
|
3690
|
+
return ourPort !== undefined && port === ourPort;
|
|
3691
|
+
}
|
|
3179
3692
|
#adoptRemote(session, host, port) {
|
|
3693
|
+
// NEVER adopt an unroutable self-source as a friend's remote — replies
|
|
3694
|
+
// would loop into our own interface and die, the peer never hears back
|
|
3695
|
+
// and re-handshakes forever.
|
|
3696
|
+
if (this.#isUnroutableSelfSource(host, port)) {
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3180
3699
|
if (session.lanRemoteHost &&
|
|
3181
3700
|
session.remote?.host === session.lanRemoteHost &&
|
|
3182
3701
|
host !== session.lanRemoteHost) {
|
|
@@ -3685,13 +4204,33 @@ export class Peer {
|
|
|
3685
4204
|
}
|
|
3686
4205
|
}
|
|
3687
4206
|
if (pkCmp < 0) {
|
|
3688
|
-
// We're the responder side — wait for peer's COOKIE_REQUEST
|
|
3689
|
-
//
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
4207
|
+
// We're the responder side — wait for peer's COOKIE_REQUEST...
|
|
4208
|
+
// but NOT forever. A higher-pubkey peer that still holds a proven
|
|
4209
|
+
// session from before OUR restart never re-initiates (its side looks
|
|
4210
|
+
// healthy; only its data — encrypted with keys we no longer have —
|
|
4211
|
+
// arrives here as an endless undecryptable flood). Deferring
|
|
4212
|
+
// unconditionally is a PERMANENT deadlock: observed live as a dozen
|
|
4213
|
+
// fleet peers "online at the relay, spraying undecryptable data,
|
|
4214
|
+
// never reconnecting" after a mac-dev restart. Toxcore's tie-break
|
|
4215
|
+
// assumes both sides know the session is dead; here only WE do. So:
|
|
4216
|
+
// defer for a grace window; if the peer hasn't established by then,
|
|
4217
|
+
// initiate anyway — the duplicate-handshake fast path and the
|
|
4218
|
+
// re-handshake acceptance rules handle the both-initiate races the
|
|
4219
|
+
// defer was protecting against.
|
|
4220
|
+
const firstDefer = this.#initiateDeferSinceMs.get(friendId);
|
|
4221
|
+
const now = Date.now();
|
|
4222
|
+
if (firstDefer === undefined) {
|
|
4223
|
+
this.#initiateDeferSinceMs.set(friendId, now);
|
|
4224
|
+
}
|
|
4225
|
+
if (firstDefer === undefined || now - firstDefer < 30_000) {
|
|
4226
|
+
// Log once per friend to avoid noise from repeated probe calls.
|
|
4227
|
+
if (!this.#initiateSkipLogged.has(friendId)) {
|
|
4228
|
+
this.#initiateSkipLogged.add(friendId);
|
|
4229
|
+
this.#debugLog(`initiate session: deferring to higher-pubkey peer ${friendId}`);
|
|
4230
|
+
}
|
|
4231
|
+
return false;
|
|
3693
4232
|
}
|
|
3694
|
-
|
|
4233
|
+
this.#debugLog(`initiate session: higher-pubkey peer ${friendId} hasn't established in ${Math.round((now - firstDefer) / 1000)}s — breaking defer, initiating`);
|
|
3695
4234
|
}
|
|
3696
4235
|
// Prefer DHT key learned from DHTPK updates; fallback to real key.
|
|
3697
4236
|
const friendDhtPk = session?.friendDhtPublicKey ?? friendRealPk;
|
|
@@ -4229,26 +4768,22 @@ export class Peer {
|
|
|
4229
4768
|
if (ourUdpPort && ourUdpPort > 0) {
|
|
4230
4769
|
const ourDhtPk = this.#keyPair?.publicKey;
|
|
4231
4770
|
if (ourDhtPk && ourDhtPk.length === 32) {
|
|
4232
|
-
// Enumerate non-loopback IPv4 addresses on this host
|
|
4233
|
-
//
|
|
4234
|
-
//
|
|
4771
|
+
// Enumerate non-loopback IPv4 addresses on this host — PHYSICAL
|
|
4772
|
+
// interfaces only (getPhysicalLanAddresses applies VIRTUAL_IFACE_RE).
|
|
4773
|
+
// The old raw networkInterfaces() walk also advertised our own TUN
|
|
4774
|
+
// address (agentnet0's 10.86.x.y): native peers then sent their UDP
|
|
4775
|
+
// cookie/handshake/session packets to our VIRTUAL ip — into the TUN
|
|
4776
|
+
// maze — and we replied to that same poisoned source, so neither
|
|
4777
|
+
// side's packets arrived and the peer saw us flap online/offline
|
|
4778
|
+
// forever (the iOS-simulator churn).
|
|
4235
4779
|
try {
|
|
4236
|
-
const ifaces = (await import("os")).networkInterfaces();
|
|
4237
4780
|
const seenIps = new Set();
|
|
4238
|
-
for (const
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
for (const addr of iface) {
|
|
4242
|
-
if (addr.family !== "IPv4")
|
|
4243
|
-
continue;
|
|
4244
|
-
if (addr.internal)
|
|
4245
|
-
continue;
|
|
4246
|
-
if (addr.address === "0.0.0.0")
|
|
4781
|
+
for (const address of getPhysicalLanAddresses()) {
|
|
4782
|
+
{
|
|
4783
|
+
if (seenIps.has(address))
|
|
4247
4784
|
continue;
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
seenIps.add(addr.address);
|
|
4251
|
-
const parts = addr.address
|
|
4785
|
+
seenIps.add(address);
|
|
4786
|
+
const parts = address
|
|
4252
4787
|
.split(".")
|
|
4253
4788
|
.map((p) => Number.parseInt(p, 10));
|
|
4254
4789
|
if (parts.length !== 4 || parts.some((n) => !(n >= 0 && n <= 255)))
|
|
@@ -4429,32 +4964,53 @@ export class Peer {
|
|
|
4429
4964
|
dataPublicKey: opts.dataPublicKey,
|
|
4430
4965
|
sendBack: opts.sendBack
|
|
4431
4966
|
});
|
|
4432
|
-
const
|
|
4433
|
-
|
|
4967
|
+
const nodeId = `${opts.node.host}:${opts.node.port}`;
|
|
4968
|
+
const pinnedRoute = opts.reuseRoute ? this.#announceRouteUsed.get(nodeId) : undefined;
|
|
4969
|
+
const exchange = async (forcedPath) => {
|
|
4434
4970
|
const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
|
|
4435
4971
|
requesterSecretKey: opts.senderSecretKey,
|
|
4436
4972
|
nodePublicKey: opts.nodePublicKey
|
|
4437
4973
|
});
|
|
4438
|
-
await this.#sendThroughOnionPath(request, opts.node,
|
|
4974
|
+
const routeUsed = await this.#sendThroughOnionPath(request, opts.node, 0, forcedPath);
|
|
4439
4975
|
const response = await waiter;
|
|
4440
4976
|
if (response) {
|
|
4441
|
-
this.#recordNodeSuccess(
|
|
4977
|
+
this.#recordNodeSuccess(nodeId);
|
|
4978
|
+
this.#announceRouteUsed.set(nodeId, routeUsed);
|
|
4442
4979
|
return response;
|
|
4443
4980
|
}
|
|
4444
|
-
this.#recordNodeFailure(
|
|
4981
|
+
this.#recordNodeFailure(nodeId);
|
|
4982
|
+
return undefined;
|
|
4983
|
+
};
|
|
4984
|
+
if (pinnedRoute) {
|
|
4985
|
+
// The pinned route is the only one whose source the node's ping_id can
|
|
4986
|
+
// match — retry it rather than wandering to other paths.
|
|
4987
|
+
const pinnedAttempts = Math.max(1, opts.attempts ?? 3);
|
|
4988
|
+
for (let attempt = 0; attempt < pinnedAttempts; attempt++) {
|
|
4989
|
+
const response = await exchange(pinnedRoute);
|
|
4990
|
+
if (response)
|
|
4991
|
+
return response;
|
|
4992
|
+
}
|
|
4993
|
+
return undefined;
|
|
4445
4994
|
}
|
|
4446
|
-
|
|
4995
|
+
const attempts = opts.attempts ?? 3;
|
|
4996
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
4447
4997
|
const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
|
|
4448
4998
|
requesterSecretKey: opts.senderSecretKey,
|
|
4449
4999
|
nodePublicKey: opts.nodePublicKey
|
|
4450
5000
|
});
|
|
4451
|
-
await this.#
|
|
5001
|
+
const routeUsed = await this.#sendThroughOnionPath(request, opts.node, attempt);
|
|
4452
5002
|
const response = await waiter;
|
|
4453
5003
|
if (response) {
|
|
4454
|
-
this.#recordNodeSuccess(
|
|
5004
|
+
this.#recordNodeSuccess(nodeId);
|
|
5005
|
+
this.#announceRouteUsed.set(nodeId, routeUsed);
|
|
4455
5006
|
return response;
|
|
4456
5007
|
}
|
|
4457
|
-
this.#recordNodeFailure(
|
|
5008
|
+
this.#recordNodeFailure(nodeId);
|
|
5009
|
+
}
|
|
5010
|
+
if (opts.allowDirectFallback) {
|
|
5011
|
+
const response = await exchange("direct");
|
|
5012
|
+
if (response)
|
|
5013
|
+
return response;
|
|
4458
5014
|
}
|
|
4459
5015
|
return undefined;
|
|
4460
5016
|
}
|
|
@@ -4513,12 +5069,12 @@ export class Peer {
|
|
|
4513
5069
|
const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
|
|
4514
5070
|
await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
|
|
4515
5071
|
}
|
|
4516
|
-
async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0) {
|
|
4517
|
-
const path = this.#selectOnionPath(nodeD, pathOffset);
|
|
5072
|
+
async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
|
|
5073
|
+
const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
|
|
4518
5074
|
if (!path) {
|
|
4519
5075
|
this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
|
|
4520
5076
|
await this.#sendPacket(payloadForNodeD, nodeD);
|
|
4521
|
-
return;
|
|
5077
|
+
return "direct";
|
|
4522
5078
|
}
|
|
4523
5079
|
this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
|
|
4524
5080
|
const packet = createOnionRequest0({
|
|
@@ -4534,6 +5090,7 @@ export class Peer {
|
|
|
4534
5090
|
payloadForNodeD
|
|
4535
5091
|
});
|
|
4536
5092
|
await this.#sendPacket(packet, path.nodeA.node);
|
|
5093
|
+
return path;
|
|
4537
5094
|
}
|
|
4538
5095
|
#selectOnionPath(nodeD, pathOffset = 0) {
|
|
4539
5096
|
const nodeDId = `${nodeD.host}:${nodeD.port}`;
|
|
@@ -4754,6 +5311,14 @@ export class Peer {
|
|
|
4754
5311
|
remotePort: undefined,
|
|
4755
5312
|
status: record.status === "online" ? "offline" : record.status
|
|
4756
5313
|
};
|
|
5314
|
+
// Scrub a remoteHost on one of our own VIRTUAL/TUN interfaces (older
|
|
5315
|
+
// builds could cache the self-poisoned TUN source) — sends there
|
|
5316
|
+
// loop through the overlay and die. Self PHYSICAL addresses are kept:
|
|
5317
|
+
// a same-host peer (iOS simulator) legitimately lives there.
|
|
5318
|
+
if (trusted.remoteHost && isOwnVirtualAddress(trusted.remoteHost)) {
|
|
5319
|
+
trusted.remoteHost = undefined;
|
|
5320
|
+
trusted.remotePort = undefined;
|
|
5321
|
+
}
|
|
4757
5322
|
this.#friends.set(record.pubkey, trusted);
|
|
4758
5323
|
}
|
|
4759
5324
|
this.#debugLog(`loaded ${this.#friends.size} persisted friends`);
|
|
@@ -5116,6 +5681,8 @@ function isCgnatAddress(host) {
|
|
|
5116
5681
|
let _lanIfaceCacheMs = 0;
|
|
5117
5682
|
let _lanAddrsCache = [];
|
|
5118
5683
|
let _lanSubnetsCache = [];
|
|
5684
|
+
let _allOwnAddrsCache = new Set();
|
|
5685
|
+
let _ownVirtualAddrsCache = new Set();
|
|
5119
5686
|
function refreshLanIfaceCache() {
|
|
5120
5687
|
const now = Date.now();
|
|
5121
5688
|
if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
|
|
@@ -5123,12 +5690,22 @@ function refreshLanIfaceCache() {
|
|
|
5123
5690
|
_lanIfaceCacheMs = now;
|
|
5124
5691
|
const addrs = [];
|
|
5125
5692
|
const subnets = [];
|
|
5693
|
+
const allOwn = new Set();
|
|
5694
|
+
const ownVirtual = new Set();
|
|
5126
5695
|
try {
|
|
5127
5696
|
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
5128
|
-
if (!list
|
|
5697
|
+
if (!list)
|
|
5129
5698
|
continue;
|
|
5699
|
+
const virtual = VIRTUAL_IFACE_RE.test(name);
|
|
5130
5700
|
for (const info of list) {
|
|
5131
|
-
if (info.family !== "IPv4"
|
|
5701
|
+
if (info.family !== "IPv4")
|
|
5702
|
+
continue;
|
|
5703
|
+
// ALL own addresses — including virtual/TUN interfaces — feed the
|
|
5704
|
+
// self-source checks (see isOwnAddress/isOwnVirtualAddress).
|
|
5705
|
+
allOwn.add(info.address);
|
|
5706
|
+
if (virtual)
|
|
5707
|
+
ownVirtual.add(info.address);
|
|
5708
|
+
if (virtual || info.internal || isCgnatAddress(info.address))
|
|
5132
5709
|
continue;
|
|
5133
5710
|
addrs.push(info.address);
|
|
5134
5711
|
const addr = ipv4ToInt(info.address);
|
|
@@ -5144,6 +5721,23 @@ function refreshLanIfaceCache() {
|
|
|
5144
5721
|
}
|
|
5145
5722
|
_lanAddrsCache = addrs;
|
|
5146
5723
|
_lanSubnetsCache = subnets;
|
|
5724
|
+
_allOwnAddrsCache = allOwn;
|
|
5725
|
+
_ownVirtualAddrsCache = ownVirtual;
|
|
5726
|
+
}
|
|
5727
|
+
/** True when host is one of THIS machine's own IPv4 addresses (any
|
|
5728
|
+
* interface, physical or virtual/TUN). Cached (see refreshLanIfaceCache). */
|
|
5729
|
+
function isOwnAddress(host) {
|
|
5730
|
+
refreshLanIfaceCache();
|
|
5731
|
+
return _allOwnAddrsCache.has(host);
|
|
5732
|
+
}
|
|
5733
|
+
/** True when host belongs to one of our own VIRTUAL/TUN interfaces (e.g.
|
|
5734
|
+
* agentnet0's 10.86.x.y). Packets sent there re-enter the overlay router
|
|
5735
|
+
* and loop — never a usable peer endpoint. A self PHYSICAL address with a
|
|
5736
|
+
* DIFFERENT port is fine: that's a same-host peer (e.g. the iOS simulator
|
|
5737
|
+
* on this Mac), and local delivery works. */
|
|
5738
|
+
function isOwnVirtualAddress(host) {
|
|
5739
|
+
refreshLanIfaceCache();
|
|
5740
|
+
return _ownVirtualAddrsCache.has(host);
|
|
5147
5741
|
}
|
|
5148
5742
|
function getPhysicalLanAddresses() {
|
|
5149
5743
|
refreshLanIfaceCache();
|