@decentnetwork/peer 0.1.78 → 0.1.81
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/filetransfer.d.ts +1 -0
- package/dist/compat/filetransfer.js +14 -0
- package/dist/compat/net-crypto.d.ts +4 -0
- package/dist/compat/net-crypto.js +41 -3
- package/dist/compat/tcp-relay-pool.js +11 -1
- package/dist/peer.d.ts +2 -0
- package/dist/peer.js +529 -91
- package/dist/store/friends.d.ts +5 -0
- package/dist/types/peer.d.ts +11 -0
- package/package.json +3 -2
package/dist/compat/express.js
CHANGED
|
@@ -28,6 +28,7 @@ export class LegacyExpressClient {
|
|
|
28
28
|
return {
|
|
29
29
|
host: node.host,
|
|
30
30
|
port: node.port,
|
|
31
|
+
tls: node.tls !== false, // default HTTPS; opt into HTTP with tls:false
|
|
31
32
|
sharedKey: nacl.box.before(expressPk, this.#selfKeyPair.secretKey)
|
|
32
33
|
};
|
|
33
34
|
});
|
|
@@ -152,7 +153,8 @@ export class LegacyExpressClient {
|
|
|
152
153
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
153
154
|
}
|
|
154
155
|
async #http(node, method, path, body) {
|
|
155
|
-
const
|
|
156
|
+
const scheme = node.tls ? "https" : "http";
|
|
157
|
+
const url = `${scheme}://${node.host}:${node.port}/${path}`;
|
|
156
158
|
const controller = new AbortController();
|
|
157
159
|
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
158
160
|
try {
|
|
@@ -54,6 +54,7 @@ export declare class FileTransferManager {
|
|
|
54
54
|
}): string | null;
|
|
55
55
|
accept(friendId: string, fileNumber: number): void;
|
|
56
56
|
cancel(friendId: string, fileNumber: number, isSending: boolean): void;
|
|
57
|
+
cancelByFileId(friendId: string, fileIdHex: string, isSending: boolean): boolean;
|
|
57
58
|
handlePacket(friendId: string, packetId: number, payload: Uint8Array): boolean;
|
|
58
59
|
clearFriend(friendId: string): void;
|
|
59
60
|
}
|
|
@@ -311,6 +311,20 @@ export class FileTransferManager {
|
|
|
311
311
|
else
|
|
312
312
|
this.#endRecv(friendId, fileNumber);
|
|
313
313
|
}
|
|
314
|
+
// Cancel by content fileId (hex) — the daemon/UI tracks transfers by fileId,
|
|
315
|
+
// not the internal fileNumber, so it can stop a specific in-flight transfer.
|
|
316
|
+
cancelByFileId(friendId, fileIdHex, isSending) {
|
|
317
|
+
const inner = (isSending ? this.#sending : this.#receiving).get(friendId);
|
|
318
|
+
if (!inner)
|
|
319
|
+
return false;
|
|
320
|
+
for (const [fileNumber, st] of inner) {
|
|
321
|
+
if (hex(st.fileId) === fileIdHex) {
|
|
322
|
+
this.cancel(friendId, fileNumber, isSending);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
314
328
|
handlePacket(friendId, packetId, payload) {
|
|
315
329
|
if (packetId === PACKET_ID_FILE_SENDREQUEST) {
|
|
316
330
|
this.#onSendRequest(friendId, payload);
|
|
@@ -80,5 +80,9 @@ export declare function openCryptoDataPacket(packet: Uint8Array, opts: {
|
|
|
80
80
|
bufferStart: number;
|
|
81
81
|
packetNumber: number;
|
|
82
82
|
nonceLast2: number;
|
|
83
|
+
/** The exact nonce this packet decrypted with. The caller must track
|
|
84
|
+
* recvBaseNonce = usedNonce (+1 for the next expected) — NOT overwrite
|
|
85
|
+
* just the low two bytes, which loses the carry into byte 21. */
|
|
86
|
+
usedNonce: Uint8Array;
|
|
83
87
|
} | undefined;
|
|
84
88
|
export declare function incrementNonce(nonce: Uint8Array): void;
|
|
@@ -191,9 +191,27 @@ export function openCryptoDataPacket(packet, opts) {
|
|
|
191
191
|
if (packet.length <= CRYPTO_DATA_PACKET_MIN_SIZE || packet[0] !== NET_PACKET_CRYPTO_DATA) {
|
|
192
192
|
return undefined;
|
|
193
193
|
}
|
|
194
|
+
// The wire carries only the low 16 bits of the sender's nonce counter.
|
|
195
|
+
// Reconstruct the full nonce like toxcore handle_data_packet: advance a
|
|
196
|
+
// COPY of our tracked nonce by the uint16 difference WITH CARRY into the
|
|
197
|
+
// upper bytes. The old code overwrote the two low bytes in place, which
|
|
198
|
+
// silently dropped the carry whenever packet loss spanned a 64k nonce
|
|
199
|
+
// wrap — from then on EVERY packet failed to decrypt until re-handshake
|
|
200
|
+
// (observed as endless "no session matched" from high-rate peers, whose
|
|
201
|
+
// counters wrap every ~30-60s of CCTV/file traffic).
|
|
194
202
|
const nonce = opts.recvBaseNonce.slice();
|
|
195
|
-
nonce[nonce.length - 2]
|
|
196
|
-
|
|
203
|
+
const trackedLow = ((nonce[nonce.length - 2] << 8) | nonce[nonce.length - 1]) >>> 0;
|
|
204
|
+
const wireLow = ((packet[1] << 8) | packet[2]) >>> 0;
|
|
205
|
+
const diff = (wireLow - trackedLow) & 0xffff;
|
|
206
|
+
if (diff < 0x8000) {
|
|
207
|
+
addToNonceCounter(nonce, diff);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// The wire counter is BEHIND our tracked one — a reordered/duplicate
|
|
211
|
+
// packet from earlier in the stream (dual-path UDP+relay delivery
|
|
212
|
+
// reorders constantly). Step backward with borrow.
|
|
213
|
+
subFromNonceCounter(nonce, 0x10000 - diff);
|
|
214
|
+
}
|
|
197
215
|
const cipher = packet.slice(3);
|
|
198
216
|
const plain = nacl.secretbox.open(cipher, nonce, opts.sessionSharedKey);
|
|
199
217
|
if (!plain || plain.length <= CRYPTO_DATA_PLAIN_HEADER_LENGTH) {
|
|
@@ -212,9 +230,29 @@ export function openCryptoDataPacket(packet, opts) {
|
|
|
212
230
|
payload: plain.slice(payloadOffset),
|
|
213
231
|
bufferStart,
|
|
214
232
|
packetNumber,
|
|
215
|
-
nonceLast2: ((packet[1] << 8) | packet[2]) >>> 0
|
|
233
|
+
nonceLast2: ((packet[1] << 8) | packet[2]) >>> 0,
|
|
234
|
+
usedNonce: nonce
|
|
216
235
|
};
|
|
217
236
|
}
|
|
237
|
+
/** Add `count` to the big-endian nonce counter with carry (toxcore
|
|
238
|
+
* increment_nonce_number). */
|
|
239
|
+
function addToNonceCounter(nonce, count) {
|
|
240
|
+
let carry = count >>> 0;
|
|
241
|
+
for (let i = nonce.length - 1; i >= 0 && carry > 0; i--) {
|
|
242
|
+
const sum = nonce[i] + (carry & 0xff);
|
|
243
|
+
nonce[i] = sum & 0xff;
|
|
244
|
+
carry = (carry >>> 8) + (sum > 0xff ? 1 : 0);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Subtract `count` from the big-endian nonce counter with borrow. */
|
|
248
|
+
function subFromNonceCounter(nonce, count) {
|
|
249
|
+
let borrow = count >>> 0;
|
|
250
|
+
for (let i = nonce.length - 1; i >= 0 && borrow > 0; i--) {
|
|
251
|
+
const sub = nonce[i] - (borrow & 0xff);
|
|
252
|
+
nonce[i] = sub & 0xff;
|
|
253
|
+
borrow = (borrow >>> 8) + (sub < 0 ? 1 : 0);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
218
256
|
function createCookie(opts) {
|
|
219
257
|
const timestamp = BigInt(Math.floor(Date.now() / 1000));
|
|
220
258
|
const contents = concatBytes([
|
|
@@ -23,7 +23,17 @@ import { EventEmitter } from "node:events";
|
|
|
23
23
|
import { base58ToBytes } from "../utils/base58.js";
|
|
24
24
|
import { TcpRelayClient } from "./tcp-relay.js";
|
|
25
25
|
const KEY_SIZE = 32;
|
|
26
|
-
|
|
26
|
+
// Connect to (up to) this many bootstrap nodes as persistent TCP relays. It was
|
|
27
|
+
// 3 — but the pool picks the FIRST N in order, so a peer only met friends whose
|
|
28
|
+
// relays happened to be in its own top-3. A NATIVE peer (iOS/Android) reaches us
|
|
29
|
+
// ONLY over a relay we both sit on (OOB cookie handshake); if our top-3 doesn't
|
|
30
|
+
// include the relay it uses, it's stuck "connecting" forever — even though we
|
|
31
|
+
// share the node further down the list (observed: iOS uses sh.callt.net =
|
|
32
|
+
// 47.100.103.201 as its #1 relay, which sat at #7 in our bootstrap so we never
|
|
33
|
+
// connected to it). Covering more of the shared bootstrap set makes the same-
|
|
34
|
+
// relay rendezvous reliable for native peers. Cheap: a handful of extra idle TCP
|
|
35
|
+
// keepalives.
|
|
36
|
+
const MAX_RELAY_CONNECTIONS = 8;
|
|
27
37
|
const RECONNECT_BASE_MS = 5_000;
|
|
28
38
|
const RECONNECT_MAX_MS = 120_000;
|
|
29
39
|
export class TcpRelayPool extends EventEmitter {
|
package/dist/peer.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export declare class Peer {
|
|
|
111
111
|
acceptFile(userid: string, fileNumber: number): void;
|
|
112
112
|
/** Cancel a transfer (isSending = true if we're the sender). */
|
|
113
113
|
cancelFile(userid: string, fileNumber: number, isSending?: boolean): void;
|
|
114
|
+
/** Cancel an in-flight transfer by its content fileId (hex). Returns true if found. */
|
|
115
|
+
cancelFileById(userid: string, fileId: string, isSending?: boolean): boolean;
|
|
114
116
|
/** Incoming file offer: { friendId, fileNumber, fileId, name, size, kind }. */
|
|
115
117
|
onFile(cb: (offer: {
|
|
116
118
|
friendId: string;
|
package/dist/peer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
@@ -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,11 @@ 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
|
+
// When we FIRST deferred initiation to a higher-pubkey peer (per friend).
|
|
310
|
+
// If the peer hasn't established within the grace window we break the
|
|
311
|
+
// defer and initiate ourselves (see #initiateSession). Cleared whenever a
|
|
312
|
+
// session establishes so each outage gets a fresh window.
|
|
313
|
+
#initiateDeferSinceMs = new Map();
|
|
293
314
|
// Per-friend last time we deleted a desynced session (rate-limit; survives the
|
|
294
315
|
// session deletion, unlike a field on the session object itself).
|
|
295
316
|
#lastDesyncDeleteMs = new Map();
|
|
@@ -336,11 +357,19 @@ export class Peer {
|
|
|
336
357
|
keyPair: this.#keyPair,
|
|
337
358
|
transport: this.#udp
|
|
338
359
|
});
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
360
|
+
// The announce data key MUST survive restarts. Native toxcore peers cache
|
|
361
|
+
// our last-known data pk and keep encrypting onion data (DHT-PK announces,
|
|
362
|
+
// friend requests) to it until they re-discover our announce on the DHT —
|
|
363
|
+
// which can take a long time when our self-announce store rate is weak.
|
|
364
|
+
// With an ephemeral per-run key, every restart silently invalidated every
|
|
365
|
+
// friend's cached key: 100% of inbound onion data failed to decrypt
|
|
366
|
+
// ("onion data response decrypt failed"), so we never learned a native
|
|
367
|
+
// friend's UDP endpoint or current relay list, and a dropped relay route
|
|
368
|
+
// could never self-heal (the iOS online-flap bug).
|
|
369
|
+
{
|
|
370
|
+
const announceKeyPath = join(dirname(this.#opts.keyFile), "announce-data-key.json");
|
|
371
|
+
this.#announceDataKey = await loadOrCreateKeyPair(announceKeyPath);
|
|
372
|
+
}
|
|
344
373
|
this.#cookieSymmetricKey = randomBytes(32);
|
|
345
374
|
await this.#loadPersistedFriends();
|
|
346
375
|
if (this.#opts.expressNodes && this.#opts.expressNodes.length > 0) {
|
|
@@ -390,15 +419,23 @@ export class Peer {
|
|
|
390
419
|
this.#handleTcpDatagram(friendKey, payload);
|
|
391
420
|
});
|
|
392
421
|
this.#tcpRelays.on("friendOnline", (friendKey) => {
|
|
393
|
-
|
|
394
|
-
|
|
422
|
+
// The pool reports the key the peer handshook the relay with — for
|
|
423
|
+
// native peers that's the DHT key. Map it to the real friend id so
|
|
424
|
+
// route state + session kick land on the REAL session, not a ghost.
|
|
425
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
426
|
+
this.#debugLog(`tcp_relay friend_online ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
|
|
395
427
|
// Mark the (existing or new) session as having a TCP route so
|
|
396
428
|
// outbound goes through the relay even if no UDP endpoint is
|
|
397
429
|
// known. Then kick #initiateSession — this issues a cookie
|
|
398
430
|
// request that will travel via TCP if no UDP path exists.
|
|
399
431
|
const session = this.#friendSessions.get(friendId) ?? this.#newSessionShell();
|
|
400
432
|
session.hasTcpRoute = true;
|
|
401
|
-
|
|
433
|
+
if (friendId === carrierIdFromPublicKey(friendKey)) {
|
|
434
|
+
session.friendRealPublicKey ??= new Uint8Array(friendKey);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
session.friendDhtPublicKey ??= new Uint8Array(friendKey);
|
|
438
|
+
}
|
|
402
439
|
this.#friendSessions.set(friendId, session);
|
|
403
440
|
if (!session.established) {
|
|
404
441
|
// A fresh relay-online signal means the peer just (re)connected —
|
|
@@ -413,12 +450,18 @@ export class Peer {
|
|
|
413
450
|
}
|
|
414
451
|
});
|
|
415
452
|
this.#tcpRelays.on("friendOffline", (friendKey) => {
|
|
416
|
-
const friendId =
|
|
453
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
417
454
|
const session = this.#friendSessions.get(friendId);
|
|
418
455
|
if (session) {
|
|
419
456
|
session.hasTcpRoute = false;
|
|
420
457
|
}
|
|
421
|
-
this.#debugLog(`tcp_relay friend_offline ${friendId}`);
|
|
458
|
+
this.#debugLog(`tcp_relay friend_offline ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
|
|
459
|
+
// Re-assert the route right away (idempotent). A native peer that
|
|
460
|
+
// blips off a relay (network switch, app background, relay rotation)
|
|
461
|
+
// reconnects seconds later — with our ROUTING_REQUEST already parked,
|
|
462
|
+
// the relay re-links the moment it returns and fires friendOnline,
|
|
463
|
+
// instead of the session sitting pathless until the periodic sweep.
|
|
464
|
+
this.#tcpRelays?.requestRoute(friendKey);
|
|
422
465
|
});
|
|
423
466
|
this.#tcpRelays.on("status", (connected, total) => {
|
|
424
467
|
this.#debugLog(`tcp_pool ${connected}/${total} relays connected`);
|
|
@@ -465,14 +508,25 @@ export class Peer {
|
|
|
465
508
|
this.#debugLog(`tcp pool initial start failed: ${error.message}`);
|
|
466
509
|
});
|
|
467
510
|
// Pre-route every persisted friend so the pool watches for them
|
|
468
|
-
// the moment a relay is up
|
|
469
|
-
|
|
511
|
+
// the moment a relay is up — under BOTH keys: identity (JS peers)
|
|
512
|
+
// and the persisted DHT key (native peers handshake relays with it).
|
|
513
|
+
for (const [friendId, friend] of this.#friends.entries()) {
|
|
470
514
|
try {
|
|
471
515
|
const pk = base58ToBytes(friend.pubkey);
|
|
472
516
|
if (pk.length === 32)
|
|
473
517
|
this.#tcpRelays.requestRoute(pk);
|
|
474
518
|
}
|
|
475
519
|
catch { /* skip malformed */ }
|
|
520
|
+
if (friend.dhtPubkey && friend.dhtPubkey !== friend.pubkey) {
|
|
521
|
+
try {
|
|
522
|
+
const dhtPk = base58ToBytes(friend.dhtPubkey);
|
|
523
|
+
if (dhtPk.length === 32) {
|
|
524
|
+
this.#friendDhtKeys.set(friendId, dhtPk);
|
|
525
|
+
this.#tcpRelays.requestRoute(dhtPk);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch { /* skip malformed */ }
|
|
529
|
+
}
|
|
476
530
|
}
|
|
477
531
|
}
|
|
478
532
|
this.#started = true;
|
|
@@ -905,10 +959,25 @@ export class Peer {
|
|
|
905
959
|
// (UDP via session.remote, or TCP relay via session.hasTcpRoute).
|
|
906
960
|
// #sendMessengerPacket internally fans out to both transports and
|
|
907
961
|
// returns true if at least one accepted; throws only if zero did.
|
|
962
|
+
//
|
|
963
|
+
// CRUCIAL: also require the session to be PROVEN — we've decrypted a real
|
|
964
|
+
// packet from the peer within FRIEND_TIMEOUT_MS. `established` alone can be a
|
|
965
|
+
// half-open / key-desynced session (common JS<->native case): our transport
|
|
966
|
+
// ACCEPTS the encrypted packet (so #sendMessengerPacket returns success, no
|
|
967
|
+
// throw) but the peer can't decrypt it, so the message is silently swallowed
|
|
968
|
+
// — and because the direct send "succeeded", the express fallback below never
|
|
969
|
+
// runs. Observed: an offline message to a desynced iOS peer showed "sent"
|
|
970
|
+
// yet never reached the peer OR the express relay (0 rows). Gating on recent
|
|
971
|
+
// inbound routes such sends to express store-and-forward instead of losing
|
|
972
|
+
// them. A genuinely-healthy session proves itself within a ping RTT (~4-8s),
|
|
973
|
+
// so at most the first message of a cold conversation takes the express path.
|
|
974
|
+
const provenInbound = session?.lastPingRecvMs !== undefined &&
|
|
975
|
+
Date.now() - session.lastPingRecvMs < FRIEND_TIMEOUT_MS;
|
|
908
976
|
const liveSession = session?.established
|
|
909
977
|
&& session.sessionSharedKey
|
|
910
978
|
&& session.ourBaseNonce
|
|
911
|
-
&& (session.remote || session.hasTcpRoute)
|
|
979
|
+
&& (session.remote || session.hasTcpRoute)
|
|
980
|
+
&& provenInbound;
|
|
912
981
|
if (liveSession) {
|
|
913
982
|
try {
|
|
914
983
|
await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, packet);
|
|
@@ -1032,6 +1101,8 @@ export class Peer {
|
|
|
1032
1101
|
acceptFile(userid, fileNumber) { this.#fileTransfer.accept(userid, fileNumber); }
|
|
1033
1102
|
/** Cancel a transfer (isSending = true if we're the sender). */
|
|
1034
1103
|
cancelFile(userid, fileNumber, isSending = false) { this.#fileTransfer.cancel(userid, fileNumber, isSending); }
|
|
1104
|
+
/** Cancel an in-flight transfer by its content fileId (hex). Returns true if found. */
|
|
1105
|
+
cancelFileById(userid, fileId, isSending = false) { return this.#fileTransfer.cancelByFileId(userid, fileId, isSending); }
|
|
1035
1106
|
/** Incoming file offer: { friendId, fileNumber, fileId, name, size, kind }. */
|
|
1036
1107
|
onFile(cb) { this.#events.on("file-offer", cb); }
|
|
1037
1108
|
/** Transfer progress: { friendId, fileId, received, total, sending? }. */
|
|
@@ -1134,21 +1205,61 @@ export class Peer {
|
|
|
1134
1205
|
* starts with `tcp:` so downstream code can recognize the origin
|
|
1135
1206
|
* and avoid setting `session.remote` to a UDP-shaped value.
|
|
1136
1207
|
*/
|
|
1208
|
+
/** Record a friend's DHT pubkey: in-memory (survives session teardown) and
|
|
1209
|
+
* persisted on the friend record (survives restarts, so start() can park
|
|
1210
|
+
* relay routes under the right key before the friend announces again). */
|
|
1211
|
+
#learnFriendDhtKey(friendId, dhtPublicKey) {
|
|
1212
|
+
if (dhtPublicKey.length !== 32)
|
|
1213
|
+
return;
|
|
1214
|
+
this.#friendDhtKeys.set(friendId, new Uint8Array(dhtPublicKey));
|
|
1215
|
+
const friend = this.#friends.get(friendId);
|
|
1216
|
+
if (friend) {
|
|
1217
|
+
const b58 = carrierIdFromPublicKey(dhtPublicKey);
|
|
1218
|
+
if (friend.dhtPubkey !== b58) {
|
|
1219
|
+
this.#friends.set(friendId, { ...friend, dhtPubkey: b58 });
|
|
1220
|
+
this.#persistFriends();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/** Resolve a TCP-relay-pool key (the key the peer handshook the relay
|
|
1225
|
+
* with) to OUR friend id. For JS peers the two coincide; native peers
|
|
1226
|
+
* (iOS/Android) sit on relays under their DHT key — without this
|
|
1227
|
+
* mapping, every pool event (friendOnline/friendOffline/inbound data
|
|
1228
|
+
* bookkeeping) landed on a GHOST session keyed by the DHT key while the
|
|
1229
|
+
* real session (keyed by identity) never saw route state change. */
|
|
1230
|
+
#friendIdForPoolKey(poolKey) {
|
|
1231
|
+
const asId = carrierIdFromPublicKey(poolKey);
|
|
1232
|
+
if (this.#friends.has(asId))
|
|
1233
|
+
return asId;
|
|
1234
|
+
for (const [friendId, dhtPk] of this.#friendDhtKeys) {
|
|
1235
|
+
if (bytesEqual(dhtPk, poolKey))
|
|
1236
|
+
return friendId;
|
|
1237
|
+
}
|
|
1238
|
+
return asId;
|
|
1239
|
+
}
|
|
1137
1240
|
#handleTcpDatagram(friendKey, payload) {
|
|
1138
|
-
const
|
|
1139
|
-
//
|
|
1241
|
+
const poolId = carrierIdFromPublicKey(friendKey);
|
|
1242
|
+
// Route-state bookkeeping belongs on the REAL friend's session (mapped
|
|
1243
|
+
// via the DHT key for native peers), not a ghost keyed by the pool key.
|
|
1244
|
+
const friendId = this.#friendIdForPoolKey(friendKey);
|
|
1140
1245
|
let session = this.#friendSessions.get(friendId);
|
|
1141
1246
|
if (!session) {
|
|
1142
1247
|
session = this.#newSessionShell();
|
|
1143
|
-
session.friendRealPublicKey = new Uint8Array(friendKey);
|
|
1144
1248
|
this.#friendSessions.set(friendId, session);
|
|
1145
1249
|
}
|
|
1146
1250
|
session.hasTcpRoute = true;
|
|
1147
|
-
|
|
1148
|
-
|
|
1251
|
+
if (friendId !== poolId) {
|
|
1252
|
+
session.friendDhtPublicKey ??= new Uint8Array(friendKey);
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
session.friendRealPublicKey ??= new Uint8Array(friendKey);
|
|
1256
|
+
}
|
|
1257
|
+
// Feed through the regular dispatcher with synthetic remote. Keep the
|
|
1258
|
+
// POOL key in the remote tag: the crypto layer matched sessions by this
|
|
1259
|
+
// exact string during the (proven-working) handshake flow.
|
|
1149
1260
|
this.#onDatagram({
|
|
1150
1261
|
data: Buffer.from(payload),
|
|
1151
|
-
remote: { address: `tcp:${
|
|
1262
|
+
remote: { address: `tcp:${poolId}`, port: 0 }
|
|
1152
1263
|
});
|
|
1153
1264
|
}
|
|
1154
1265
|
/** True when the synthetic remote was constructed by #handleTcpDatagram. */
|
|
@@ -1248,6 +1359,15 @@ export class Peer {
|
|
|
1248
1359
|
this.#debugLog(`cookie response: no tcp relay accepted oob send for ${senderId}`);
|
|
1249
1360
|
}
|
|
1250
1361
|
}
|
|
1362
|
+
else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
|
|
1363
|
+
// The request arrived sourced from our own TUN address (or our own
|
|
1364
|
+
// socket) — an overlay routing artifact. Replying there loops into
|
|
1365
|
+
// ourselves and dies. Answer over the relay OOB path instead, which
|
|
1366
|
+
// the peer reliably receives. (A self PHYSICAL address with a
|
|
1367
|
+
// different port is a same-host peer and gets the normal UDP reply.)
|
|
1368
|
+
const sent = this.#tcpRelays?.sendOobToFriend(request.senderDhtPublicKey, response) ?? 0;
|
|
1369
|
+
this.#debugLog(`cookie response: source ${remote.address}:${remote.port} is an unroutable self-source — replied via tcp_oob instead (relays=${sent})`);
|
|
1370
|
+
}
|
|
1251
1371
|
else {
|
|
1252
1372
|
void this.#sendPacket(response, { host: remote.address, port: remote.port })
|
|
1253
1373
|
.then(() => {
|
|
@@ -1429,6 +1549,9 @@ export class Peer {
|
|
|
1429
1549
|
if (!state.friendDhtPublicKey)
|
|
1430
1550
|
state.friendDhtPublicKey = hs.senderDhtPublicKey;
|
|
1431
1551
|
}
|
|
1552
|
+
if (hs.senderDhtPublicKey && hs.senderDhtPublicKey.length === 32) {
|
|
1553
|
+
this.#learnFriendDhtKey(friendId, hs.senderDhtPublicKey);
|
|
1554
|
+
}
|
|
1432
1555
|
const wasEstablished = state.established === true;
|
|
1433
1556
|
// Detect a real session reset: the peer is offering NEW session
|
|
1434
1557
|
// keys (different ephemeral session pubkey from before). When
|
|
@@ -1477,14 +1600,25 @@ export class Peer {
|
|
|
1477
1600
|
// the current session is dead — fall through and accept the new one.
|
|
1478
1601
|
const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
|
|
1479
1602
|
const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
|
|
1480
|
-
if
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1603
|
+
// The peer has RE-KEYED if it's sending us crypto data we can't decrypt
|
|
1604
|
+
// more recently than the last packet we could — our "alive" signal above
|
|
1605
|
+
// is stale (it counts packets from just before the peer flapped). This is
|
|
1606
|
+
// the flapping iOS/native case: iOS drops, re-handshakes with a fresh
|
|
1607
|
+
// session key, then sends online messages we can't decrypt because we're
|
|
1608
|
+
// still holding the dead keys and rejecting its fix-up handshake for the
|
|
1609
|
+
// whole 32s timeout. Accept the fresh handshake NOW to resync. Safe: a
|
|
1610
|
+
// healthy session never produces undecryptable data, so peerReKeyed stays
|
|
1611
|
+
// false for it — this can't churn a live session (the reverted "half-open
|
|
1612
|
+
// breaker" churned because it accepted new keys with NO such evidence).
|
|
1613
|
+
const peerReKeyed = state.undecryptableRecvMs !== undefined &&
|
|
1614
|
+
state.undecryptableRecvMs > (state.lastPingRecvMs ?? 0);
|
|
1615
|
+
if (sinceEstablished > 1000 && currentSessionAlive && !peerReKeyed) {
|
|
1485
1616
|
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
|
|
1486
1617
|
return;
|
|
1487
1618
|
}
|
|
1619
|
+
if (peerReKeyed) {
|
|
1620
|
+
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (peer re-keyed: undecryptable data since last good packet)`);
|
|
1621
|
+
}
|
|
1488
1622
|
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
|
|
1489
1623
|
`lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
|
|
1490
1624
|
}
|
|
@@ -1493,6 +1627,10 @@ export class Peer {
|
|
|
1493
1627
|
state.sessionSharedKey = nacl.box.before(hs.sessionPublicKey, state.ourSessionKeyPair.secretKey);
|
|
1494
1628
|
state.established = true;
|
|
1495
1629
|
state.sessionEstablishedAtMs = Date.now();
|
|
1630
|
+
// Fresh session up — reset the higher-pubkey defer window for the
|
|
1631
|
+
// next outage (see #initiateSession).
|
|
1632
|
+
this.#initiateDeferSinceMs.delete(friendId);
|
|
1633
|
+
this.#initiateSkipLogged.delete(friendId);
|
|
1496
1634
|
if (this.#remoteIsTcp(remote)) {
|
|
1497
1635
|
state.hasTcpRoute = true;
|
|
1498
1636
|
// Don't set state.remote — there's no UDP endpoint for this peer.
|
|
@@ -1503,6 +1641,11 @@ export class Peer {
|
|
|
1503
1641
|
if (isNewSession) {
|
|
1504
1642
|
state.sendPacketNumber = 0;
|
|
1505
1643
|
state.receiveBufferStart = 0;
|
|
1644
|
+
// Packet numbers restart at 0 — parked retransmit payloads from
|
|
1645
|
+
// the previous session are meaningless (and would resend under
|
|
1646
|
+
// colliding numbers). Drop them.
|
|
1647
|
+
state.sendArray = undefined;
|
|
1648
|
+
state.sendBufferStartNum = undefined;
|
|
1506
1649
|
}
|
|
1507
1650
|
else {
|
|
1508
1651
|
state.sendPacketNumber ??= 0;
|
|
@@ -1668,6 +1811,13 @@ export class Peer {
|
|
|
1668
1811
|
}
|
|
1669
1812
|
state.lastRelayRecvMs = Date.now();
|
|
1670
1813
|
}
|
|
1814
|
+
else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
|
|
1815
|
+
// Sourced from our own TUN address / own socket (overlay routing
|
|
1816
|
+
// artifact): the payload is real — deliver it — but this is NOT a
|
|
1817
|
+
// usable bidirectional UDP path. Don't adopt it (replies loop into
|
|
1818
|
+
// our own interface) and don't mark UDP-confirmed, or the punch
|
|
1819
|
+
// machinery stops trying to establish a REAL direct path.
|
|
1820
|
+
}
|
|
1671
1821
|
else {
|
|
1672
1822
|
// Adopt the source as our send endpoint — via #adoptRemote, which
|
|
1673
1823
|
// refuses to downgrade off a locked physical-LAN path to a public /
|
|
@@ -1686,12 +1836,44 @@ export class Peer {
|
|
|
1686
1836
|
if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
|
|
1687
1837
|
continue;
|
|
1688
1838
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1839
|
+
// Track the receive nonce from the EXACT nonce this packet used
|
|
1840
|
+
// (openCryptoDataPacket reconstructs it with full carry/borrow).
|
|
1841
|
+
// The old two-byte overwrite dropped the carry into byte 21 when
|
|
1842
|
+
// loss spanned a 64k counter wrap, permanently desyncing the
|
|
1843
|
+
// session. Only move FORWARD — a late reordered packet must not
|
|
1844
|
+
// drag the tracked nonce backward.
|
|
1845
|
+
{
|
|
1846
|
+
const trackedLow = ((state.peerBaseNonce[state.peerBaseNonce.length - 2] << 8) | state.peerBaseNonce[state.peerBaseNonce.length - 1]) >>> 0;
|
|
1847
|
+
const fwd = ((opened.nonceLast2 - trackedLow) & 0xffff) < 0x8000;
|
|
1848
|
+
if (fwd) {
|
|
1849
|
+
state.peerBaseNonce = opened.usedNonce.slice();
|
|
1850
|
+
incrementNonce(state.peerBaseNonce);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1693
1853
|
const kind = opened.payload[0];
|
|
1694
1854
|
const inner = opened.payload.slice(1);
|
|
1855
|
+
// Advance our ack point ONLY for kinds that actually consume a
|
|
1856
|
+
// reliable packet number on the sender. Toxcore sends REQUEST(1)
|
|
1857
|
+
// and lossy (>=192) packets with num = send_array.buffer_end — the
|
|
1858
|
+
// NEXT UNUSED number, not stored in its send array. Counting those
|
|
1859
|
+
// advanced our receiveBufferStart to buffer_end+1, i.e. we then
|
|
1860
|
+
// acked one more packet than the peer ever sent — and toxcore's
|
|
1861
|
+
// handle_data_packet_core REJECTS any packet whose buffer_start is
|
|
1862
|
+
// out of its send window (clear_buffer_until -> return -1). Result:
|
|
1863
|
+
// from the peer's FIRST retransmit request onward, every packet we
|
|
1864
|
+
// sent was discarded whole (fresh and retransmitted alike) and the
|
|
1865
|
+
// peer went deaf until it re-keyed — the final piece of the iOS
|
|
1866
|
+
// session churn. The JS droppable bulk channel (bulkDataPacketId)
|
|
1867
|
+
// reuses the current number without consuming it, so it must not
|
|
1868
|
+
// advance the ack point either (also fixes reliable packets being
|
|
1869
|
+
// dropped as duplicates after a droppable burst under the same
|
|
1870
|
+
// number).
|
|
1871
|
+
const consumesReliableNumber = kind !== PACKET_ID_REQUEST &&
|
|
1872
|
+
!(kind !== undefined && kind >= 192) &&
|
|
1873
|
+
kind !== this.#opts.bulkDataPacketId;
|
|
1874
|
+
if (consumesReliableNumber) {
|
|
1875
|
+
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
1876
|
+
}
|
|
1695
1877
|
// Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
|
|
1696
1878
|
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
1697
1879
|
return;
|
|
@@ -1727,9 +1909,13 @@ export class Peer {
|
|
|
1727
1909
|
return;
|
|
1728
1910
|
}
|
|
1729
1911
|
if (kind === PACKET_ID_REQUEST) {
|
|
1730
|
-
// Lossless retransmission request
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1912
|
+
// Lossless retransmission request (toxcore handle_request_packet).
|
|
1913
|
+
// Bytes are 1-based deltas walking our send window from
|
|
1914
|
+
// sendBufferStartNum: a matching delta marks that packet number
|
|
1915
|
+
// REQUESTED (peer is missing it — resend); every walked number
|
|
1916
|
+
// that doesn't match is implicitly ACKED (peer has it — drop from
|
|
1917
|
+
// the buffer). 0 is a +255 filler.
|
|
1918
|
+
this.#handleRetransmitRequest(friendId, state, inner);
|
|
1733
1919
|
return;
|
|
1734
1920
|
}
|
|
1735
1921
|
if (kind === PACKET_ID_SHARE_RELAYS) {
|
|
@@ -1818,7 +2004,8 @@ export class Peer {
|
|
|
1818
2004
|
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
1819
2005
|
this.#events.emit("text", {
|
|
1820
2006
|
pubkey: friendId,
|
|
1821
|
-
text
|
|
2007
|
+
text,
|
|
2008
|
+
via: "online"
|
|
1822
2009
|
});
|
|
1823
2010
|
this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
|
|
1824
2011
|
return;
|
|
@@ -1850,6 +2037,25 @@ export class Peer {
|
|
|
1850
2037
|
// CRASH (TURN DNS); with that fixed, sessions stay converged on their own.
|
|
1851
2038
|
// Just drop the undecryptable packet — net_crypto's normal re-handshake
|
|
1852
2039
|
// path recovers a genuinely dead session.
|
|
2040
|
+
// If this came over the TCP relay (synthetic tcp:<friendId> remote) we KNOW
|
|
2041
|
+
// which friend sent it. Record the decrypt failure so the handshake path can
|
|
2042
|
+
// distinguish a re-keyed peer (desync — accept its fresh handshake now) from
|
|
2043
|
+
// a live one, instead of holding dead keys for the full timeout while every
|
|
2044
|
+
// online message is silently dropped.
|
|
2045
|
+
if (remote.address.startsWith("tcp:")) {
|
|
2046
|
+
// remote carries the POOL key id (native peers: DHT key) — map to
|
|
2047
|
+
// the real friend so the desync marker lands on the real session.
|
|
2048
|
+
let desyncFriendId = remote.address.slice(4);
|
|
2049
|
+
try {
|
|
2050
|
+
desyncFriendId = this.#friendIdForPoolKey(base58ToBytes(desyncFriendId));
|
|
2051
|
+
}
|
|
2052
|
+
catch { /* keep raw id */ }
|
|
2053
|
+
const ds = this.#friendSessions.get(desyncFriendId);
|
|
2054
|
+
if (ds?.established) {
|
|
2055
|
+
ds.undecryptableRecvMs = Date.now();
|
|
2056
|
+
this.#debugVerboseLog(`undecryptable crypto data from ${desyncFriendId} (peer likely re-keyed)`);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
1853
2059
|
this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
|
|
1854
2060
|
return;
|
|
1855
2061
|
}
|
|
@@ -1947,6 +2153,7 @@ export class Peer {
|
|
|
1947
2153
|
session.lastDhtPkNoReplay = noReplay;
|
|
1948
2154
|
session.friendRealPublicKey ??= senderPublicKey;
|
|
1949
2155
|
session.friendDhtPublicKey = friendDhtPublicKey;
|
|
2156
|
+
this.#learnFriendDhtKey(senderId, friendDhtPublicKey);
|
|
1950
2157
|
// A DHT-PK announce from this friend IS the proof they've accepted
|
|
1951
2158
|
// our friend request — only friends actively trying to reach us send
|
|
1952
2159
|
// these. Mark acceptedAt so subsequent peer.sendFriendRequest calls
|
|
@@ -2163,7 +2370,7 @@ export class Peer {
|
|
|
2163
2370
|
// Malformed pubkey, fall through.
|
|
2164
2371
|
}
|
|
2165
2372
|
}
|
|
2166
|
-
this.#events.emit("text", { pubkey: fromUserId, text });
|
|
2373
|
+
this.#events.emit("text", { pubkey: fromUserId, text, via: "offline" });
|
|
2167
2374
|
}
|
|
2168
2375
|
catch {
|
|
2169
2376
|
// Ignore invalid offline payloads.
|
|
@@ -2457,7 +2664,10 @@ export class Peer {
|
|
|
2457
2664
|
dataPublicKey: this.#announceDataKey.publicKey,
|
|
2458
2665
|
sendBack: c.sendBack,
|
|
2459
2666
|
allowDirectFallback: true,
|
|
2460
|
-
attempts: SELF_ANNOUNCE_ATTEMPTS
|
|
2667
|
+
attempts: SELF_ANNOUNCE_ATTEMPTS,
|
|
2668
|
+
// ping_id is source-bound: step2 must exit from the same hop
|
|
2669
|
+
// step1 did or the node rejects it and nothing ever stores.
|
|
2670
|
+
reuseRoute: true
|
|
2461
2671
|
})));
|
|
2462
2672
|
for (let j = 0; j < step1Hits.length; j++) {
|
|
2463
2673
|
const { c, resp1 } = step1Hits[j];
|
|
@@ -2472,6 +2682,7 @@ export class Peer {
|
|
|
2472
2682
|
catch { /* best-effort */ }
|
|
2473
2683
|
if (final.isStored === 2) {
|
|
2474
2684
|
storedNodes.push(c.node);
|
|
2685
|
+
this.#debugLog(`self announce STORED on ${c.node.host}:${c.node.port} (total ${storedNodes.length})`);
|
|
2475
2686
|
// Keep dhtHealth.selfAnnounceStoredOn live within the loop,
|
|
2476
2687
|
// not just at the end, so we see growth in real time.
|
|
2477
2688
|
this.#lastSelfAnnounceStoredCount = storedNodes.length;
|
|
@@ -2562,8 +2773,17 @@ export class Peer {
|
|
|
2562
2773
|
// sessions a long grace so a blackout doesn't churn them via re-handshake;
|
|
2563
2774
|
// unproven sessions still time out fast to self-heal a wedged handshake.
|
|
2564
2775
|
const proven = session.lastPingRecvMs !== undefined;
|
|
2565
|
-
|
|
2566
|
-
|
|
2776
|
+
// A relay-only session (synthetic tcp: remote, no UDP path) whose
|
|
2777
|
+
// relay route is gone has NO transport at all — nothing can arrive
|
|
2778
|
+
// and nothing we send leaves. The proven-session blackout grace
|
|
2779
|
+
// exists for UDP paths that black out (GFW) while both ends keep
|
|
2780
|
+
// keys; it must NOT keep a transportless relay session as a 180s
|
|
2781
|
+
// zombie (iOS flap: peer sees us vanish while we still show them
|
|
2782
|
+
// online and drop every send). Fast-track those to teardown.
|
|
2783
|
+
const relayOnly = !session.remote || session.remote.host.startsWith("tcp:");
|
|
2784
|
+
const pathless = relayOnly && !session.hasTcpRoute;
|
|
2785
|
+
const deadline = proven && !pathless ? PROVEN_SESSION_HARD_TIMEOUT_MS : FRIEND_TIMEOUT_MS;
|
|
2786
|
+
if (proven && !pathless && silentFor > FRIEND_TIMEOUT_MS && silentFor <= deadline) {
|
|
2567
2787
|
// Blackout grace: keep the proven session and keep probing (the
|
|
2568
2788
|
// keepalive + UDP re-punch + relay-keepalive below all still run). Do
|
|
2569
2789
|
// NOT delete or re-handshake — that's what desyncs and churns. The peer,
|
|
@@ -2585,6 +2805,11 @@ export class Peer {
|
|
|
2585
2805
|
const pk = base58ToBytes(friend.pubkey);
|
|
2586
2806
|
if (pk.length === 32)
|
|
2587
2807
|
this.#tcpRelays?.requestRoute(pk);
|
|
2808
|
+
// Native peers sit on relays under their DHT key — re-request
|
|
2809
|
+
// that too or the relay never re-links after this teardown.
|
|
2810
|
+
const dhtPk = this.#friendDhtKeys.get(friendId);
|
|
2811
|
+
if (dhtPk && dhtPk.length === 32)
|
|
2812
|
+
this.#tcpRelays?.requestRoute(dhtPk);
|
|
2588
2813
|
}
|
|
2589
2814
|
catch { /* skip malformed */ }
|
|
2590
2815
|
continue;
|
|
@@ -2772,30 +2997,46 @@ export class Peer {
|
|
|
2772
2997
|
// bridged them, cookie+handshake fired once, didn't complete, and
|
|
2773
2998
|
// nothing re-tried it. Counting the relay route lets the retry fire.
|
|
2774
2999
|
const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
3000
|
+
// Route parking runs for EVERY non-established friend — deliberately
|
|
3001
|
+
// NOT gated on haveEndpoint. A stale persisted remoteHost (e.g. a
|
|
3002
|
+
// bootstrap ip:port cached by an old bug) counts as an "endpoint" and
|
|
3003
|
+
// used to suppress this block entirely, so the friend was never
|
|
3004
|
+
// watched on any relay and could only ever be reached by spamming
|
|
3005
|
+
// cookies at the dead address (the iOS-simulator-forever-offline bug).
|
|
3006
|
+
// For a peer whose DHT self-announce never stored (WSL2, symmetric
|
|
3007
|
+
// NAT, restrictive firewall), the TCP relay is the ONLY bootstrap:
|
|
3008
|
+
// ask our relays to route to the friend's keys; when both ends share
|
|
3009
|
+
// a relay the pool fires `friendOnline` -> #initiateSession.
|
|
3010
|
+
// Re-asserted on a cooldown — cheap and idempotent.
|
|
3011
|
+
{
|
|
2787
3012
|
const lastRouteReq = this.#routeRequestCooldown.get(friendId) ?? 0;
|
|
2788
3013
|
if (this.#tcpRelays && now - lastRouteReq > 15_000) {
|
|
2789
3014
|
try {
|
|
2790
3015
|
const pk = base58ToBytes(friend.pubkey);
|
|
3016
|
+
// The relay routes CRYPTO_DATA by the key the peer CONNECTED to the
|
|
3017
|
+
// relay with. For a NATIVE peer (iOS/Android) that's its DHT key,
|
|
3018
|
+
// NOT its identity key — and the send path already routes by
|
|
3019
|
+
// friendDhtPublicKey. Requesting the route ONLY under the identity
|
|
3020
|
+
// key (as before) meant the relay never linked us to the peer: its
|
|
3021
|
+
// handshake arrives OOB (which needs no route), but every continuous
|
|
3022
|
+
// CRYPTO_DATA packet (chat + keepalives) was silently dropped, so the
|
|
3023
|
+
// session came up then went dead in 32s. Request the DHT key too so
|
|
3024
|
+
// the routed path actually establishes; keep the identity key for
|
|
3025
|
+
// toxcore peers where the two match.
|
|
3026
|
+
const dhtPk = this.#friendSessions.get(friendId)?.friendDhtPublicKey ?? this.#friendDhtKeys.get(friendId);
|
|
3027
|
+
if (dhtPk && dhtPk.length === 32) {
|
|
3028
|
+
this.#tcpRelays.requestRoute(dhtPk);
|
|
3029
|
+
}
|
|
2791
3030
|
if (pk.length === 32) {
|
|
2792
3031
|
this.#tcpRelays.requestRoute(pk);
|
|
2793
3032
|
this.#routeRequestCooldown.set(friendId, now);
|
|
2794
|
-
this.#
|
|
3033
|
+
this.#debugVerboseLog(`re-requesting relay route for unconnected friend ${friendId}${dhtPk ? " (+dht key)" : ""}`);
|
|
2795
3034
|
}
|
|
2796
3035
|
}
|
|
2797
3036
|
catch { /* skip malformed pubkey */ }
|
|
2798
3037
|
}
|
|
3038
|
+
}
|
|
3039
|
+
if (!haveEndpoint) {
|
|
2799
3040
|
// Re-SEND the friend-request to a friend we've never had accepted
|
|
2800
3041
|
// (still "requested"). The original can be lost — the friend was
|
|
2801
3042
|
// offline/restarting, a relay blipped, or onion had no return path.
|
|
@@ -2872,6 +3113,77 @@ export class Peer {
|
|
|
2872
3113
|
});
|
|
2873
3114
|
}
|
|
2874
3115
|
}
|
|
3116
|
+
/** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
|
|
3117
|
+
* (a) implicitly ACK every walked packet number that isn't requested,
|
|
3118
|
+
* (b) retransmit the requested ones from the send buffer. */
|
|
3119
|
+
#handleRetransmitRequest(friendId, session, data) {
|
|
3120
|
+
const start = session.sendBufferStartNum;
|
|
3121
|
+
const end = session.sendPacketNumber;
|
|
3122
|
+
if (start === undefined || end === undefined || !session.sendArray || session.sendArray.size === 0) {
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
let cursor = 0;
|
|
3126
|
+
let n = 1;
|
|
3127
|
+
const requested = [];
|
|
3128
|
+
for (let i = start; i !== end; i = (i + 1) >>> 0) {
|
|
3129
|
+
if (cursor >= data.length)
|
|
3130
|
+
break;
|
|
3131
|
+
if (n === data[cursor]) {
|
|
3132
|
+
requested.push(i);
|
|
3133
|
+
cursor++;
|
|
3134
|
+
n = 0;
|
|
3135
|
+
}
|
|
3136
|
+
else {
|
|
3137
|
+
// Peer has this one — implicit ack, free it.
|
|
3138
|
+
session.sendArray.delete(i);
|
|
3139
|
+
}
|
|
3140
|
+
if (n === 255) {
|
|
3141
|
+
n = 1;
|
|
3142
|
+
if (data[cursor] !== 0)
|
|
3143
|
+
return; // malformed filler
|
|
3144
|
+
cursor++;
|
|
3145
|
+
}
|
|
3146
|
+
else {
|
|
3147
|
+
n++;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
// Advance the window past the acked prefix.
|
|
3151
|
+
let s = start;
|
|
3152
|
+
while (s !== end && !session.sendArray.has(s))
|
|
3153
|
+
s = (s + 1) >>> 0;
|
|
3154
|
+
session.sendBufferStartNum = s;
|
|
3155
|
+
if (requested.length === 0)
|
|
3156
|
+
return;
|
|
3157
|
+
this.#debugLog(`retransmit request from ${friendId}: resending ${requested.length} packet(s) [${requested.slice(0, 8).join(",")}${requested.length > 8 ? ",…" : ""}]`);
|
|
3158
|
+
void (async () => {
|
|
3159
|
+
for (const num of requested) {
|
|
3160
|
+
const payload = session.sendArray?.get(num);
|
|
3161
|
+
if (!payload)
|
|
3162
|
+
continue;
|
|
3163
|
+
try {
|
|
3164
|
+
await this.#resendReliablePacket(friendId, session, num, payload);
|
|
3165
|
+
}
|
|
3166
|
+
catch { /* transport hiccup — peer will re-request */ }
|
|
3167
|
+
}
|
|
3168
|
+
})();
|
|
3169
|
+
}
|
|
3170
|
+
/** Re-send a parked reliable packet with its ORIGINAL packet number but
|
|
3171
|
+
* the CURRENT nonce counter (toxcore retransmissions do the same — the
|
|
3172
|
+
* receiver reconstructs the nonce from the wire bytes, while the packet
|
|
3173
|
+
* number slots it into the gap in their receive window). */
|
|
3174
|
+
async #resendReliablePacket(friendId, session, packetNumber, payload) {
|
|
3175
|
+
if (!session.established || !session.sessionSharedKey || !session.ourBaseNonce)
|
|
3176
|
+
return;
|
|
3177
|
+
const encrypted = createCryptoDataPacket({
|
|
3178
|
+
sessionSharedKey: session.sessionSharedKey,
|
|
3179
|
+
sentNonce: session.ourBaseNonce,
|
|
3180
|
+
bufferStart: session.receiveBufferStart ?? 0,
|
|
3181
|
+
packetNumber,
|
|
3182
|
+
payload
|
|
3183
|
+
});
|
|
3184
|
+
incrementNonce(session.ourBaseNonce);
|
|
3185
|
+
await this.#sendToFriend(friendId, encrypted, session, false, false);
|
|
3186
|
+
}
|
|
2875
3187
|
async #sendMessengerPacket(friendId, kind, data) {
|
|
2876
3188
|
const session = this.#friendSessions.get(friendId);
|
|
2877
3189
|
if (!session?.established || !session.sessionSharedKey || !session.ourBaseNonce) {
|
|
@@ -2890,7 +3202,35 @@ export class Peer {
|
|
|
2890
3202
|
payload
|
|
2891
3203
|
});
|
|
2892
3204
|
incrementNonce(session.ourBaseNonce);
|
|
2893
|
-
|
|
3205
|
+
// Reliable-sequence hygiene. Only packets we will actually DELIVER reliably
|
|
3206
|
+
// may consume the reliable packet-number sequence. The app's high-volume bulk
|
|
3207
|
+
// channel (bulkDataPacketId, e.g. decentlan IP=163) and toxcore lossy packets
|
|
3208
|
+
// (192-254) are DROPPED under backpressure — if they burn a reliable number
|
|
3209
|
+
// first, the drop punches a PERMANENT hole in the stream. A native (iOS/C)
|
|
3210
|
+
// peer does strict in-order net_crypto delivery and stalls EVERYTHING after a
|
|
3211
|
+
// hole (incl. chat 64), sending PACKET_ID_REQUEST for a retransmit we never do
|
|
3212
|
+
// (no retransmit queue) — silently wedging online chat with native peers.
|
|
3213
|
+
// File transfer is bulk but NEVER dropped (queued reliably), so it keeps its
|
|
3214
|
+
// number. So advance the reliable number for everything EXCEPT the droppable
|
|
3215
|
+
// bulk-IP channel and lossy packets; the crypto nonce still advances above so
|
|
3216
|
+
// decryption stays in sync regardless.
|
|
3217
|
+
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
|
|
3218
|
+
const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
|
|
3219
|
+
const isDroppable = (isBulkData && !isFileTransfer) || kind >= 192;
|
|
3220
|
+
if (!isDroppable) {
|
|
3221
|
+
session.sendPacketNumber = (packetNumber + 1) >>> 0;
|
|
3222
|
+
// Park the plaintext for retransmission (native peers stall their
|
|
3223
|
+
// whole lossless stream on a gap until we honor PACKET_ID_REQUEST).
|
|
3224
|
+
session.sendArray ??= new Map();
|
|
3225
|
+
session.sendBufferStartNum ??= packetNumber;
|
|
3226
|
+
session.sendArray.set(packetNumber, payload);
|
|
3227
|
+
// Bound the buffer: beyond this window the peer has either acked via
|
|
3228
|
+
// REQUEST packets or the session is beyond saving anyway.
|
|
3229
|
+
while (session.sendArray.size > 1024 && session.sendBufferStartNum !== session.sendPacketNumber) {
|
|
3230
|
+
session.sendArray.delete(session.sendBufferStartNum);
|
|
3231
|
+
session.sendBufferStartNum = (session.sendBufferStartNum + 1) >>> 0;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
2894
3234
|
session.lastPingSentMs = Date.now();
|
|
2895
3235
|
// Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
|
|
2896
3236
|
// traffic. Once UDP is established it should ride UDP exclusively and
|
|
@@ -2923,12 +3263,10 @@ export class Peer {
|
|
|
2923
3263
|
// happens to finish before reordering bites. Pinning file transfer to a
|
|
2924
3264
|
// single transport keeps the chunks in send order. (Reliability across a
|
|
2925
3265
|
// lossy path still wants a reorder buffer + retransmit — tracked separately.)
|
|
2926
|
-
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
|
|
2927
|
-
const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
|
|
2928
3266
|
// File transfer rides the relay RELIABLY (never dropped under backpressure):
|
|
2929
3267
|
// a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
|
|
2930
3268
|
// chunk corrupts the file (no retransmit). So bulk IP data stays droppable
|
|
2931
|
-
// while file transfer is queued.
|
|
3269
|
+
// while file transfer is queued. (isBulkData / isFileTransfer computed above.)
|
|
2932
3270
|
await this.#sendToFriend(friendId, encrypted, session, isBulkData, isFileTransfer);
|
|
2933
3271
|
}
|
|
2934
3272
|
/**
|
|
@@ -3132,6 +3470,11 @@ export class Peer {
|
|
|
3132
3470
|
if (!friend) {
|
|
3133
3471
|
return;
|
|
3134
3472
|
}
|
|
3473
|
+
// An unroutable self-source is never the friend's endpoint (see
|
|
3474
|
+
// #adoptRemote) — don't let it poison the in-memory record either.
|
|
3475
|
+
if (this.#isUnroutableSelfSource(host, port)) {
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3135
3478
|
// Important: do NOT persist this candidate to disk. Speculative endpoint
|
|
3136
3479
|
// hints (from DHT-PK extras, sendnodes-derived knownNodes, etc.) are
|
|
3137
3480
|
// frequently stale relay addresses that aren't actually the friend's
|
|
@@ -3164,6 +3507,7 @@ export class Peer {
|
|
|
3164
3507
|
}
|
|
3165
3508
|
if (dhtPublicKey) {
|
|
3166
3509
|
session.friendDhtPublicKey = dhtPublicKey;
|
|
3510
|
+
this.#learnFriendDhtKey(friendId, dhtPublicKey);
|
|
3167
3511
|
}
|
|
3168
3512
|
}
|
|
3169
3513
|
/** Single choke point for setting session.remote (the direct-UDP send
|
|
@@ -3174,7 +3518,26 @@ export class Peer {
|
|
|
3174
3518
|
* is just a string compare and is safe to call from the per-packet hot path.
|
|
3175
3519
|
* Replacing the per-packet getPhysicalLanSubnets() call here was the fix for
|
|
3176
3520
|
* the CCTV CPU regression. */
|
|
3521
|
+
/** True when replying to host:port would never reach a peer: the host is
|
|
3522
|
+
* one of our own TUN/overlay addresses (replies re-enter the overlay
|
|
3523
|
+
* router and loop), or it's literally our own UDP socket. A self
|
|
3524
|
+
* PHYSICAL address with a DIFFERENT port is routable — that's a
|
|
3525
|
+
* same-host peer (e.g. the iOS simulator running on this Mac). */
|
|
3526
|
+
#isUnroutableSelfSource(host, port) {
|
|
3527
|
+
if (isOwnVirtualAddress(host))
|
|
3528
|
+
return true;
|
|
3529
|
+
if (!isOwnAddress(host))
|
|
3530
|
+
return false;
|
|
3531
|
+
const ourPort = this.#udp?.localPort();
|
|
3532
|
+
return ourPort !== undefined && port === ourPort;
|
|
3533
|
+
}
|
|
3177
3534
|
#adoptRemote(session, host, port) {
|
|
3535
|
+
// NEVER adopt an unroutable self-source as a friend's remote — replies
|
|
3536
|
+
// would loop into our own interface and die, the peer never hears back
|
|
3537
|
+
// and re-handshakes forever.
|
|
3538
|
+
if (this.#isUnroutableSelfSource(host, port)) {
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3178
3541
|
if (session.lanRemoteHost &&
|
|
3179
3542
|
session.remote?.host === session.lanRemoteHost &&
|
|
3180
3543
|
host !== session.lanRemoteHost) {
|
|
@@ -3683,13 +4046,33 @@ export class Peer {
|
|
|
3683
4046
|
}
|
|
3684
4047
|
}
|
|
3685
4048
|
if (pkCmp < 0) {
|
|
3686
|
-
// We're the responder side — wait for peer's COOKIE_REQUEST
|
|
3687
|
-
//
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
4049
|
+
// We're the responder side — wait for peer's COOKIE_REQUEST...
|
|
4050
|
+
// but NOT forever. A higher-pubkey peer that still holds a proven
|
|
4051
|
+
// session from before OUR restart never re-initiates (its side looks
|
|
4052
|
+
// healthy; only its data — encrypted with keys we no longer have —
|
|
4053
|
+
// arrives here as an endless undecryptable flood). Deferring
|
|
4054
|
+
// unconditionally is a PERMANENT deadlock: observed live as a dozen
|
|
4055
|
+
// fleet peers "online at the relay, spraying undecryptable data,
|
|
4056
|
+
// never reconnecting" after a mac-dev restart. Toxcore's tie-break
|
|
4057
|
+
// assumes both sides know the session is dead; here only WE do. So:
|
|
4058
|
+
// defer for a grace window; if the peer hasn't established by then,
|
|
4059
|
+
// initiate anyway — the duplicate-handshake fast path and the
|
|
4060
|
+
// re-handshake acceptance rules handle the both-initiate races the
|
|
4061
|
+
// defer was protecting against.
|
|
4062
|
+
const firstDefer = this.#initiateDeferSinceMs.get(friendId);
|
|
4063
|
+
const now = Date.now();
|
|
4064
|
+
if (firstDefer === undefined) {
|
|
4065
|
+
this.#initiateDeferSinceMs.set(friendId, now);
|
|
4066
|
+
}
|
|
4067
|
+
if (firstDefer === undefined || now - firstDefer < 30_000) {
|
|
4068
|
+
// Log once per friend to avoid noise from repeated probe calls.
|
|
4069
|
+
if (!this.#initiateSkipLogged.has(friendId)) {
|
|
4070
|
+
this.#initiateSkipLogged.add(friendId);
|
|
4071
|
+
this.#debugLog(`initiate session: deferring to higher-pubkey peer ${friendId}`);
|
|
4072
|
+
}
|
|
4073
|
+
return false;
|
|
3691
4074
|
}
|
|
3692
|
-
|
|
4075
|
+
this.#debugLog(`initiate session: higher-pubkey peer ${friendId} hasn't established in ${Math.round((now - firstDefer) / 1000)}s — breaking defer, initiating`);
|
|
3693
4076
|
}
|
|
3694
4077
|
// Prefer DHT key learned from DHTPK updates; fallback to real key.
|
|
3695
4078
|
const friendDhtPk = session?.friendDhtPublicKey ?? friendRealPk;
|
|
@@ -4227,26 +4610,22 @@ export class Peer {
|
|
|
4227
4610
|
if (ourUdpPort && ourUdpPort > 0) {
|
|
4228
4611
|
const ourDhtPk = this.#keyPair?.publicKey;
|
|
4229
4612
|
if (ourDhtPk && ourDhtPk.length === 32) {
|
|
4230
|
-
// Enumerate non-loopback IPv4 addresses on this host
|
|
4231
|
-
//
|
|
4232
|
-
//
|
|
4613
|
+
// Enumerate non-loopback IPv4 addresses on this host — PHYSICAL
|
|
4614
|
+
// interfaces only (getPhysicalLanAddresses applies VIRTUAL_IFACE_RE).
|
|
4615
|
+
// The old raw networkInterfaces() walk also advertised our own TUN
|
|
4616
|
+
// address (agentnet0's 10.86.x.y): native peers then sent their UDP
|
|
4617
|
+
// cookie/handshake/session packets to our VIRTUAL ip — into the TUN
|
|
4618
|
+
// maze — and we replied to that same poisoned source, so neither
|
|
4619
|
+
// side's packets arrived and the peer saw us flap online/offline
|
|
4620
|
+
// forever (the iOS-simulator churn).
|
|
4233
4621
|
try {
|
|
4234
|
-
const ifaces = (await import("os")).networkInterfaces();
|
|
4235
4622
|
const seenIps = new Set();
|
|
4236
|
-
for (const
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
for (const addr of iface) {
|
|
4240
|
-
if (addr.family !== "IPv4")
|
|
4241
|
-
continue;
|
|
4242
|
-
if (addr.internal)
|
|
4623
|
+
for (const address of getPhysicalLanAddresses()) {
|
|
4624
|
+
{
|
|
4625
|
+
if (seenIps.has(address))
|
|
4243
4626
|
continue;
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
if (seenIps.has(addr.address))
|
|
4247
|
-
continue;
|
|
4248
|
-
seenIps.add(addr.address);
|
|
4249
|
-
const parts = addr.address
|
|
4627
|
+
seenIps.add(address);
|
|
4628
|
+
const parts = address
|
|
4250
4629
|
.split(".")
|
|
4251
4630
|
.map((p) => Number.parseInt(p, 10));
|
|
4252
4631
|
if (parts.length !== 4 || parts.some((n) => !(n >= 0 && n <= 255)))
|
|
@@ -4427,32 +4806,53 @@ export class Peer {
|
|
|
4427
4806
|
dataPublicKey: opts.dataPublicKey,
|
|
4428
4807
|
sendBack: opts.sendBack
|
|
4429
4808
|
});
|
|
4430
|
-
const
|
|
4431
|
-
|
|
4809
|
+
const nodeId = `${opts.node.host}:${opts.node.port}`;
|
|
4810
|
+
const pinnedRoute = opts.reuseRoute ? this.#announceRouteUsed.get(nodeId) : undefined;
|
|
4811
|
+
const exchange = async (forcedPath) => {
|
|
4432
4812
|
const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
|
|
4433
4813
|
requesterSecretKey: opts.senderSecretKey,
|
|
4434
4814
|
nodePublicKey: opts.nodePublicKey
|
|
4435
4815
|
});
|
|
4436
|
-
await this.#sendThroughOnionPath(request, opts.node,
|
|
4816
|
+
const routeUsed = await this.#sendThroughOnionPath(request, opts.node, 0, forcedPath);
|
|
4437
4817
|
const response = await waiter;
|
|
4438
4818
|
if (response) {
|
|
4439
|
-
this.#recordNodeSuccess(
|
|
4819
|
+
this.#recordNodeSuccess(nodeId);
|
|
4820
|
+
this.#announceRouteUsed.set(nodeId, routeUsed);
|
|
4440
4821
|
return response;
|
|
4441
4822
|
}
|
|
4442
|
-
this.#recordNodeFailure(
|
|
4823
|
+
this.#recordNodeFailure(nodeId);
|
|
4824
|
+
return undefined;
|
|
4825
|
+
};
|
|
4826
|
+
if (pinnedRoute) {
|
|
4827
|
+
// The pinned route is the only one whose source the node's ping_id can
|
|
4828
|
+
// match — retry it rather than wandering to other paths.
|
|
4829
|
+
const pinnedAttempts = Math.max(1, opts.attempts ?? 3);
|
|
4830
|
+
for (let attempt = 0; attempt < pinnedAttempts; attempt++) {
|
|
4831
|
+
const response = await exchange(pinnedRoute);
|
|
4832
|
+
if (response)
|
|
4833
|
+
return response;
|
|
4834
|
+
}
|
|
4835
|
+
return undefined;
|
|
4443
4836
|
}
|
|
4444
|
-
|
|
4837
|
+
const attempts = opts.attempts ?? 3;
|
|
4838
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
4445
4839
|
const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
|
|
4446
4840
|
requesterSecretKey: opts.senderSecretKey,
|
|
4447
4841
|
nodePublicKey: opts.nodePublicKey
|
|
4448
4842
|
});
|
|
4449
|
-
await this.#
|
|
4843
|
+
const routeUsed = await this.#sendThroughOnionPath(request, opts.node, attempt);
|
|
4450
4844
|
const response = await waiter;
|
|
4451
4845
|
if (response) {
|
|
4452
|
-
this.#recordNodeSuccess(
|
|
4846
|
+
this.#recordNodeSuccess(nodeId);
|
|
4847
|
+
this.#announceRouteUsed.set(nodeId, routeUsed);
|
|
4453
4848
|
return response;
|
|
4454
4849
|
}
|
|
4455
|
-
this.#recordNodeFailure(
|
|
4850
|
+
this.#recordNodeFailure(nodeId);
|
|
4851
|
+
}
|
|
4852
|
+
if (opts.allowDirectFallback) {
|
|
4853
|
+
const response = await exchange("direct");
|
|
4854
|
+
if (response)
|
|
4855
|
+
return response;
|
|
4456
4856
|
}
|
|
4457
4857
|
return undefined;
|
|
4458
4858
|
}
|
|
@@ -4511,12 +4911,12 @@ export class Peer {
|
|
|
4511
4911
|
const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
|
|
4512
4912
|
await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
|
|
4513
4913
|
}
|
|
4514
|
-
async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0) {
|
|
4515
|
-
const path = this.#selectOnionPath(nodeD, pathOffset);
|
|
4914
|
+
async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
|
|
4915
|
+
const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
|
|
4516
4916
|
if (!path) {
|
|
4517
4917
|
this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
|
|
4518
4918
|
await this.#sendPacket(payloadForNodeD, nodeD);
|
|
4519
|
-
return;
|
|
4919
|
+
return "direct";
|
|
4520
4920
|
}
|
|
4521
4921
|
this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
|
|
4522
4922
|
const packet = createOnionRequest0({
|
|
@@ -4532,6 +4932,7 @@ export class Peer {
|
|
|
4532
4932
|
payloadForNodeD
|
|
4533
4933
|
});
|
|
4534
4934
|
await this.#sendPacket(packet, path.nodeA.node);
|
|
4935
|
+
return path;
|
|
4535
4936
|
}
|
|
4536
4937
|
#selectOnionPath(nodeD, pathOffset = 0) {
|
|
4537
4938
|
const nodeDId = `${nodeD.host}:${nodeD.port}`;
|
|
@@ -4752,6 +5153,14 @@ export class Peer {
|
|
|
4752
5153
|
remotePort: undefined,
|
|
4753
5154
|
status: record.status === "online" ? "offline" : record.status
|
|
4754
5155
|
};
|
|
5156
|
+
// Scrub a remoteHost on one of our own VIRTUAL/TUN interfaces (older
|
|
5157
|
+
// builds could cache the self-poisoned TUN source) — sends there
|
|
5158
|
+
// loop through the overlay and die. Self PHYSICAL addresses are kept:
|
|
5159
|
+
// a same-host peer (iOS simulator) legitimately lives there.
|
|
5160
|
+
if (trusted.remoteHost && isOwnVirtualAddress(trusted.remoteHost)) {
|
|
5161
|
+
trusted.remoteHost = undefined;
|
|
5162
|
+
trusted.remotePort = undefined;
|
|
5163
|
+
}
|
|
4755
5164
|
this.#friends.set(record.pubkey, trusted);
|
|
4756
5165
|
}
|
|
4757
5166
|
this.#debugLog(`loaded ${this.#friends.size} persisted friends`);
|
|
@@ -5114,6 +5523,8 @@ function isCgnatAddress(host) {
|
|
|
5114
5523
|
let _lanIfaceCacheMs = 0;
|
|
5115
5524
|
let _lanAddrsCache = [];
|
|
5116
5525
|
let _lanSubnetsCache = [];
|
|
5526
|
+
let _allOwnAddrsCache = new Set();
|
|
5527
|
+
let _ownVirtualAddrsCache = new Set();
|
|
5117
5528
|
function refreshLanIfaceCache() {
|
|
5118
5529
|
const now = Date.now();
|
|
5119
5530
|
if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
|
|
@@ -5121,12 +5532,22 @@ function refreshLanIfaceCache() {
|
|
|
5121
5532
|
_lanIfaceCacheMs = now;
|
|
5122
5533
|
const addrs = [];
|
|
5123
5534
|
const subnets = [];
|
|
5535
|
+
const allOwn = new Set();
|
|
5536
|
+
const ownVirtual = new Set();
|
|
5124
5537
|
try {
|
|
5125
5538
|
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
5126
|
-
if (!list
|
|
5539
|
+
if (!list)
|
|
5127
5540
|
continue;
|
|
5541
|
+
const virtual = VIRTUAL_IFACE_RE.test(name);
|
|
5128
5542
|
for (const info of list) {
|
|
5129
|
-
if (info.family !== "IPv4"
|
|
5543
|
+
if (info.family !== "IPv4")
|
|
5544
|
+
continue;
|
|
5545
|
+
// ALL own addresses — including virtual/TUN interfaces — feed the
|
|
5546
|
+
// self-source checks (see isOwnAddress/isOwnVirtualAddress).
|
|
5547
|
+
allOwn.add(info.address);
|
|
5548
|
+
if (virtual)
|
|
5549
|
+
ownVirtual.add(info.address);
|
|
5550
|
+
if (virtual || info.internal || isCgnatAddress(info.address))
|
|
5130
5551
|
continue;
|
|
5131
5552
|
addrs.push(info.address);
|
|
5132
5553
|
const addr = ipv4ToInt(info.address);
|
|
@@ -5142,6 +5563,23 @@ function refreshLanIfaceCache() {
|
|
|
5142
5563
|
}
|
|
5143
5564
|
_lanAddrsCache = addrs;
|
|
5144
5565
|
_lanSubnetsCache = subnets;
|
|
5566
|
+
_allOwnAddrsCache = allOwn;
|
|
5567
|
+
_ownVirtualAddrsCache = ownVirtual;
|
|
5568
|
+
}
|
|
5569
|
+
/** True when host is one of THIS machine's own IPv4 addresses (any
|
|
5570
|
+
* interface, physical or virtual/TUN). Cached (see refreshLanIfaceCache). */
|
|
5571
|
+
function isOwnAddress(host) {
|
|
5572
|
+
refreshLanIfaceCache();
|
|
5573
|
+
return _allOwnAddrsCache.has(host);
|
|
5574
|
+
}
|
|
5575
|
+
/** True when host belongs to one of our own VIRTUAL/TUN interfaces (e.g.
|
|
5576
|
+
* agentnet0's 10.86.x.y). Packets sent there re-enter the overlay router
|
|
5577
|
+
* and loop — never a usable peer endpoint. A self PHYSICAL address with a
|
|
5578
|
+
* DIFFERENT port is fine: that's a same-host peer (e.g. the iOS simulator
|
|
5579
|
+
* on this Mac), and local delivery works. */
|
|
5580
|
+
function isOwnVirtualAddress(host) {
|
|
5581
|
+
refreshLanIfaceCache();
|
|
5582
|
+
return _ownVirtualAddrsCache.has(host);
|
|
5145
5583
|
}
|
|
5146
5584
|
function getPhysicalLanAddresses() {
|
|
5147
5585
|
refreshLanIfaceCache();
|
package/dist/store/friends.d.ts
CHANGED
|
@@ -14,4 +14,9 @@ export type FriendRecord = {
|
|
|
14
14
|
* has been pending. */
|
|
15
15
|
requestedAt?: number;
|
|
16
16
|
acceptedAt?: number;
|
|
17
|
+
/** Base58 DHT pubkey the friend last announced (native peers handshake
|
|
18
|
+
* relays with this key, NOT their identity key). Persisted so a restart
|
|
19
|
+
* can immediately park relay ROUTING_REQUESTs under the right key
|
|
20
|
+
* instead of waiting for the friend's next DHT-PK announce. */
|
|
21
|
+
dhtPubkey?: string;
|
|
17
22
|
};
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export type NetworkNode = {
|
|
|
11
11
|
* relays this way in DHT-PK extras.
|
|
12
12
|
*/
|
|
13
13
|
isTcp?: boolean;
|
|
14
|
+
/** Express relays only: transport scheme. Defaults to HTTPS. Set `false` to
|
|
15
|
+
* reach an express node over plain HTTP — the express payload is already
|
|
16
|
+
* end-to-end encrypted (nacl.secretbox), so HTTP keeps CONTENT private from
|
|
17
|
+
* on-path observers; it does expose the userIDs in the request path. Lets a
|
|
18
|
+
* relay run on a raw IP with no TLS cert / domain / ICP filing, exactly like
|
|
19
|
+
* a bootstrap node. */
|
|
20
|
+
tls?: boolean;
|
|
14
21
|
/** Decoded 32-byte form of `pk`, cached lazily on first use so the hot
|
|
15
22
|
* DHT-distance sort (#closestKnownNodes, run on every get_nodes we answer)
|
|
16
23
|
* doesn't base58-decode the whole table on every call. */
|
|
@@ -87,6 +94,10 @@ export type FriendInfoEvent = {
|
|
|
87
94
|
export type TextMessage = {
|
|
88
95
|
pubkey: string;
|
|
89
96
|
text: string;
|
|
97
|
+
/** Delivery path: "online" = live net_crypto session (direct/relay), "offline"
|
|
98
|
+
* = express store-and-forward. Lets the UI color the two differently so a user
|
|
99
|
+
* can see when online delivery is failing and only offline messages land. */
|
|
100
|
+
via?: "online" | "offline";
|
|
90
101
|
};
|
|
91
102
|
/**
|
|
92
103
|
* An application-defined custom packet received from a friend, over the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.81",
|
|
4
4
|
"description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -68,7 +68,8 @@
|
|
|
68
68
|
"demo:tox-dht-crypto": "pnpm run build && node scripts/tox-dht-crypto-demo.mjs",
|
|
69
69
|
"test:compat": "pnpm run build && node scripts/compat-selftest.mjs",
|
|
70
70
|
"smoke:join": "node scripts/smoke-join.mjs",
|
|
71
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
71
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
72
|
+
"test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs"
|
|
72
73
|
},
|
|
73
74
|
"dependencies": {
|
|
74
75
|
"flatbuffers": "^25.9.23",
|