@decentnetwork/peer 0.1.152 → 0.1.155
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 +4 -3
- package/dist/compat/net-crypto.js +7 -6
- package/dist/crypto/shared-key.d.ts +4 -0
- package/dist/crypto/shared-key.js +45 -0
- package/dist/peer.js +163 -39
- package/package.json +1 -1
package/dist/compat/express.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ByteBuffer, Encoding } from "flatbuffers";
|
|
|
2
2
|
import nacl from "tweetnacl";
|
|
3
3
|
import { base58ToBytes, bytesToBase58 } from "../utils/base58.js";
|
|
4
4
|
import { randomBytes } from "../utils/bytes.js";
|
|
5
|
+
import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
|
|
5
6
|
const EXPRESS_MAGIC = 0xca6ee595;
|
|
6
7
|
const NONCE_SIZE = 24;
|
|
7
8
|
const HTTP_TIMEOUT_MS = 15000;
|
|
@@ -34,7 +35,7 @@ export class LegacyExpressClient {
|
|
|
34
35
|
host: node.host,
|
|
35
36
|
port: node.port,
|
|
36
37
|
tls: node.tls !== false, // default HTTPS; opt into HTTP with tls:false
|
|
37
|
-
sharedKey:
|
|
38
|
+
sharedKey: sharedKeyFor(expressPk, this.#selfKeyPair.secretKey)
|
|
38
39
|
};
|
|
39
40
|
});
|
|
40
41
|
}
|
|
@@ -49,7 +50,7 @@ export class LegacyExpressClient {
|
|
|
49
50
|
if (friendPk.length !== 32) {
|
|
50
51
|
throw new Error("friend user id must decode to 32-byte public key");
|
|
51
52
|
}
|
|
52
|
-
const friendSharedKey =
|
|
53
|
+
const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
|
|
53
54
|
const friendEncrypted = encrypt(friendSharedKey, carrierPacket);
|
|
54
55
|
await this.#postEncrypted(friendUserId, friendEncrypted);
|
|
55
56
|
}
|
|
@@ -112,7 +113,7 @@ export class LegacyExpressClient {
|
|
|
112
113
|
if (friendPk.length !== 32) {
|
|
113
114
|
continue;
|
|
114
115
|
}
|
|
115
|
-
const friendSharedKey =
|
|
116
|
+
const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
|
|
116
117
|
const packet = decrypt(friendSharedKey, msg.payload);
|
|
117
118
|
if (!packet) {
|
|
118
119
|
continue;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import nacl from "tweetnacl";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { concatBytes, randomBytes } from "../utils/bytes.js";
|
|
4
|
+
import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
|
|
4
5
|
export const NET_PACKET_COOKIE_REQUEST = 0x18;
|
|
5
6
|
export const NET_PACKET_COOKIE_RESPONSE = 0x19;
|
|
6
7
|
export const NET_PACKET_CRYPTO_HS = 0x1a;
|
|
@@ -44,7 +45,7 @@ export function createCookieRequest(opts) {
|
|
|
44
45
|
throw new Error("cookie request plaintext size mismatch");
|
|
45
46
|
}
|
|
46
47
|
const nonce = randomBytes(NONCE_SIZE);
|
|
47
|
-
const sharedKey =
|
|
48
|
+
const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
|
|
48
49
|
const cipher = nacl.secretbox(plain, nonce, sharedKey);
|
|
49
50
|
return concatBytes([
|
|
50
51
|
Uint8Array.of(NET_PACKET_COOKIE_REQUEST),
|
|
@@ -60,7 +61,7 @@ export function openCookieRequest(packet, opts) {
|
|
|
60
61
|
const senderDhtPublicKey = packet.slice(1, 1 + KEY_SIZE);
|
|
61
62
|
const nonce = packet.slice(1 + KEY_SIZE, 1 + KEY_SIZE + NONCE_SIZE);
|
|
62
63
|
const cipher = packet.slice(1 + KEY_SIZE + NONCE_SIZE);
|
|
63
|
-
const sharedKey =
|
|
64
|
+
const sharedKey = sharedKeyFor(senderDhtPublicKey, opts.receiverDhtSecretKey);
|
|
64
65
|
const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
|
|
65
66
|
if (!plain || plain.length !== COOKIE_REQUEST_PLAIN_LENGTH) {
|
|
66
67
|
return undefined;
|
|
@@ -79,7 +80,7 @@ export function createCookieResponse(opts) {
|
|
|
79
80
|
});
|
|
80
81
|
const plain = concatBytes([cookie, writeUint64Le(opts.request.echo)]);
|
|
81
82
|
const nonce = randomBytes(NONCE_SIZE);
|
|
82
|
-
const sharedKey =
|
|
83
|
+
const sharedKey = sharedKeyFor(opts.request.senderDhtPublicKey, opts.receiverDhtSecretKey);
|
|
83
84
|
const cipher = nacl.secretbox(plain, nonce, sharedKey);
|
|
84
85
|
return concatBytes([Uint8Array.of(NET_PACKET_COOKIE_RESPONSE), nonce, cipher]);
|
|
85
86
|
}
|
|
@@ -89,7 +90,7 @@ export function openCookieResponse(packet, opts) {
|
|
|
89
90
|
}
|
|
90
91
|
const nonce = packet.slice(1, 1 + NONCE_SIZE);
|
|
91
92
|
const cipher = packet.slice(1 + NONCE_SIZE);
|
|
92
|
-
const sharedKey =
|
|
93
|
+
const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
|
|
93
94
|
const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
|
|
94
95
|
if (!plain || plain.length !== COOKIE_RESPONSE_PLAIN_LENGTH) {
|
|
95
96
|
return undefined;
|
|
@@ -135,7 +136,7 @@ export function createCryptoHandshake(opts) {
|
|
|
135
136
|
sha512(opts.recipientCookie),
|
|
136
137
|
ownCookie
|
|
137
138
|
]);
|
|
138
|
-
const sharedKey =
|
|
139
|
+
const sharedKey = sharedKeyFor(opts.receiverRealPublicKey, opts.senderRealSecretKey);
|
|
139
140
|
const innerCipher = nacl.secretbox(innerPlain, innerNonce, sharedKey);
|
|
140
141
|
return concatBytes([Uint8Array.of(NET_PACKET_CRYPTO_HS), opts.recipientCookie, innerNonce, innerCipher]);
|
|
141
142
|
}
|
|
@@ -152,7 +153,7 @@ export function openCryptoHandshake(packet, opts) {
|
|
|
152
153
|
const senderDhtPublicKey = cookieParsed.dhtPublicKey;
|
|
153
154
|
const nonce = packet.slice(1 + COOKIE_LENGTH, 1 + COOKIE_LENGTH + NONCE_SIZE);
|
|
154
155
|
const cipher = packet.slice(1 + COOKIE_LENGTH + NONCE_SIZE);
|
|
155
|
-
const sharedKey =
|
|
156
|
+
const sharedKey = sharedKeyFor(senderRealPublicKey, opts.receiverRealSecretKey);
|
|
156
157
|
const inner = nacl.secretbox.open(cipher, nonce, sharedKey);
|
|
157
158
|
if (!inner || inner.length !== HANDSHAKE_INNER_LENGTH) {
|
|
158
159
|
return undefined;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import { bytesToHex } from "../utils/bytes.js";
|
|
3
|
+
/**
|
|
4
|
+
* X25519 shared-secret derivation, memoised.
|
|
5
|
+
*
|
|
6
|
+
* `nacl.box.before(theirPk, ourSk)` is a scalar multiplication — the single
|
|
7
|
+
* most expensive thing tweetnacl does, and it is pure JS. Every packet that
|
|
8
|
+
* derived its key inline paid for one.
|
|
9
|
+
*
|
|
10
|
+
* That cost is invisible until a node has friends. Measured with a 53-friend
|
|
11
|
+
* list against a peer that could reach none of them (the ordinary case: a
|
|
12
|
+
* laptop whose friends are asleep), the connection loop's cookie requests put
|
|
13
|
+
*
|
|
14
|
+
* 60.8% tweetnacl M (bignum multiply)
|
|
15
|
+
* 11.4% crypto_scalarmult
|
|
16
|
+
*
|
|
17
|
+
* of the process on one function — 72% of the CPU, for keys that never change.
|
|
18
|
+
* A browser tab showed the same shape at 100% of a core for 25 hours.
|
|
19
|
+
*
|
|
20
|
+
* The result depends only on (theirPk, ourSk), so it is cached. Keyed on the
|
|
21
|
+
* public key, with the secret compared by IDENTITY rather than content: a peer
|
|
22
|
+
* passes the same Uint8Array every time, and a different keypair recomputes
|
|
23
|
+
* rather than risking a stale hit. Nothing secret is used as a map key.
|
|
24
|
+
*/
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
const MAX_ENTRIES = 1024;
|
|
27
|
+
export function sharedKey(theirPublicKey, ourSecretKey) {
|
|
28
|
+
const id = bytesToHex(theirPublicKey);
|
|
29
|
+
const hit = cache.get(id);
|
|
30
|
+
if (hit && hit.sk === ourSecretKey)
|
|
31
|
+
return hit.key;
|
|
32
|
+
const key = nacl.box.before(theirPublicKey, ourSecretKey);
|
|
33
|
+
if (cache.size >= MAX_ENTRIES) {
|
|
34
|
+
const oldest = cache.keys().next().value;
|
|
35
|
+
if (oldest !== undefined)
|
|
36
|
+
cache.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
cache.set(id, { sk: ourSecretKey, key });
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
/** Drop everything — for tests, and for a key rotation that reuses the same
|
|
42
|
+
* Uint8Array instance. */
|
|
43
|
+
export function clearSharedKeyCache() {
|
|
44
|
+
cache.clear();
|
|
45
|
+
}
|
package/dist/peer.js
CHANGED
|
@@ -89,6 +89,15 @@ const NODE_BLACKLIST_MAX_TTL_MS = readEnvInt("DECENT_NODE_BLACKLIST_MAX_TTL_MS",
|
|
|
89
89
|
const FRIEND_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ANNOUNCE_ATTEMPTS", 1);
|
|
90
90
|
const JOIN_ANNOUNCE_TIMEOUT_MS = readEnvInt("DECENT_JOIN_ANNOUNCE_TIMEOUT_MS", 12000);
|
|
91
91
|
const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS", 20000);
|
|
92
|
+
// Slack on top of a run's own deadline before the watchdog abandons it. The
|
|
93
|
+
// abandoned run is left to finish or hang on its own; what matters is that the
|
|
94
|
+
// NEXT tick is allowed to start.
|
|
95
|
+
const SELF_ANNOUNCE_WATCHDOG_MARGIN_MS = readEnvInt("DECENT_SELF_ANNOUNCE_WATCHDOG_MARGIN_MS", 15000);
|
|
96
|
+
// How long a stored announce entry stays useful to someone looking us up.
|
|
97
|
+
// toxcore expires announce entries on roughly this timescale, so a node that
|
|
98
|
+
// acknowledged a store longer ago than this can no longer be counted on.
|
|
99
|
+
const ANNOUNCE_ENTRY_TTL_MS = readEnvInt("DECENT_ANNOUNCE_ENTRY_TTL_MS", 300_000);
|
|
100
|
+
const FAULT_ANNOUNCE_HANG_RUN = readEnvInt("DECENT_FAULT_ANNOUNCE_HANG", 0);
|
|
92
101
|
// Classic-DHT maintenance cadence. Every tick we get_nodes toward our own key
|
|
93
102
|
// (so neighbours store our address and native peers can find our UDP endpoint)
|
|
94
103
|
// and toward each not-yet-UDP friend's key (so we find theirs and punch).
|
|
@@ -475,6 +484,11 @@ export class Peer {
|
|
|
475
484
|
// 25s forever for stale persisted entries.
|
|
476
485
|
#dhtPkConsecutiveFailures = new Map();
|
|
477
486
|
#lastSelfAnnounceStoredCount = -1;
|
|
487
|
+
// nodeId -> when that node last acknowledged STORING our announce. What
|
|
488
|
+
// callers actually want to know is "can anyone look me up right now", and
|
|
489
|
+
// that is a property of live storage across rounds, not of the round in
|
|
490
|
+
// flight. Counted against ANNOUNCE_ENTRY_TTL_MS on read.
|
|
491
|
+
#selfAnnounceStoredAt = new Map();
|
|
478
492
|
// Diagnostics for the TCP-relay onion path (announce/discovery over TCP).
|
|
479
493
|
// Surfaced in dhtHealth so `agentnet diag` shows whether it's active without
|
|
480
494
|
// needing verbose logs: sent = onion requests handed to relays, recv = onion
|
|
@@ -536,6 +550,13 @@ export class Peer {
|
|
|
536
550
|
#profileRetryTimers = new Map();
|
|
537
551
|
#greetingSentTo = new Set();
|
|
538
552
|
#selfAnnouncePromise;
|
|
553
|
+
#selfAnnounceRunCount = 0;
|
|
554
|
+
// Bumped when the watchdog abandons a run. The abandoned run is still alive
|
|
555
|
+
// and would otherwise keep writing #announceRouteUsed underneath the round
|
|
556
|
+
// that replaced it — and step2 rejects a ping_id whose route changed, so
|
|
557
|
+
// that interference would cost stores. It cannot be killed, but it can be
|
|
558
|
+
// told to stop at its next wave boundary.
|
|
559
|
+
#selfAnnounceEpoch = 0;
|
|
539
560
|
#selfAnnouncePauseDepth = 0;
|
|
540
561
|
#started = false;
|
|
541
562
|
constructor(opts) {
|
|
@@ -3633,6 +3654,17 @@ export class Peer {
|
|
|
3633
3654
|
return [];
|
|
3634
3655
|
}
|
|
3635
3656
|
this.#lastSelfAnnounceMs = now;
|
|
3657
|
+
// Fault injection, off unless DECENT_FAULT_ANNOUNCE_HANG names a run
|
|
3658
|
+
// index. Reproduces the browser wedge — a run that never settles — so the
|
|
3659
|
+
// watchdog in #runSelfAnnounce can be tested for real instead of only
|
|
3660
|
+
// reasoned about. Same spirit as DECENT_ANNOUNCE_DEBUG: costs one integer
|
|
3661
|
+
// compare when unset.
|
|
3662
|
+
const myEpoch = this.#selfAnnounceEpoch;
|
|
3663
|
+
this.#selfAnnounceRunCount += 1;
|
|
3664
|
+
if (FAULT_ANNOUNCE_HANG_RUN > 0 && this.#selfAnnounceRunCount === FAULT_ANNOUNCE_HANG_RUN) {
|
|
3665
|
+
this.#debugLog(`FAULT: hanging self-announce run #${this.#selfAnnounceRunCount} forever`);
|
|
3666
|
+
await new Promise(() => { });
|
|
3667
|
+
}
|
|
3636
3668
|
const storedNodes = [];
|
|
3637
3669
|
// An onion announce only STORES on the nodes whose key is closest to OURS,
|
|
3638
3670
|
// and toxcore reaches them by following the "here are closer nodes" lists
|
|
@@ -3671,15 +3703,27 @@ export class Peer {
|
|
|
3671
3703
|
queue.sort((x, y) => xorCloser(selfPk, x.nodePk, y.nodePk));
|
|
3672
3704
|
};
|
|
3673
3705
|
enqueueNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes);
|
|
3674
|
-
//
|
|
3675
|
-
//
|
|
3676
|
-
|
|
3706
|
+
// No reset here. Zeroing at the START of every round is why dhtHealth
|
|
3707
|
+
// read 0 for the first seconds of each round, and why a round cut short by
|
|
3708
|
+
// the deadline published 0 even though ten nodes were still holding our
|
|
3709
|
+
// announce from 10s earlier. Measured before this change: a healthy peer
|
|
3710
|
+
// reported 0 / 10 / 0 / 0 / 10 across a 135s poll. Anyone sampling at the
|
|
3711
|
+
// wrong moment — a user reading the UI, or me reading it and reporting it
|
|
3712
|
+
// as a finding — concluded the peer was unfindable when it was not.
|
|
3713
|
+
// "Never ran" is still distinguishable: #lastSelfAnnounceStoredCount
|
|
3714
|
+
// stays -1 until the first round records something.
|
|
3715
|
+
if (this.#lastSelfAnnounceStoredCount < 0)
|
|
3716
|
+
this.#lastSelfAnnounceStoredCount = 0;
|
|
3677
3717
|
const STORE_TARGET = 4;
|
|
3678
3718
|
let waves = 0;
|
|
3679
3719
|
while (queue.length > 0 && storedNodes.length < STORE_TARGET && waves < 16) {
|
|
3680
3720
|
if (Date.now() >= deadlineMs) {
|
|
3681
3721
|
this.#debugLog("self announce stopped at deadline");
|
|
3682
|
-
this.#
|
|
3722
|
+
this.#publishSelfAnnounceStoredCount();
|
|
3723
|
+
return storedNodes;
|
|
3724
|
+
}
|
|
3725
|
+
if (this.#selfAnnounceEpoch !== myEpoch) {
|
|
3726
|
+
this.#debugLog("self announce abandoned by watchdog — stopping at wave boundary");
|
|
3683
3727
|
return storedNodes;
|
|
3684
3728
|
}
|
|
3685
3729
|
waves += 1;
|
|
@@ -3784,9 +3828,17 @@ export class Peer {
|
|
|
3784
3828
|
if (final.isStored === 2) {
|
|
3785
3829
|
storedNodes.push(c.node);
|
|
3786
3830
|
this.#debugLog(`self announce STORED on ${c.node.host}:${c.node.port} (total ${storedNodes.length})`);
|
|
3787
|
-
//
|
|
3788
|
-
//
|
|
3789
|
-
|
|
3831
|
+
// Publish the running count only once it EXCEEDS the last completed
|
|
3832
|
+
// round's. Each round starts from an empty storedNodes, so writing it
|
|
3833
|
+
// through unconditionally made dhtHealth read 0 for the first second
|
|
3834
|
+
// or so of every round — and anyone who polled in that window saw
|
|
3835
|
+
// "stored on 0 nodes" and concluded the peer was unfindable. That
|
|
3836
|
+
// reading is what made the Air look permanently broken, and I
|
|
3837
|
+
// reported it as a finding once before checking whether it settled.
|
|
3838
|
+
// Measured on this Mac: the same peer alternates 0 / 9 / 10 / 0
|
|
3839
|
+
// across a two-minute poll while every round stores on ten nodes.
|
|
3840
|
+
this.#selfAnnounceStoredAt.set(`${c.node.host}:${c.node.port}`, Date.now());
|
|
3841
|
+
this.#publishSelfAnnounceStoredCount();
|
|
3790
3842
|
}
|
|
3791
3843
|
const discovered = parsePackedNodes(final.nodes);
|
|
3792
3844
|
if (discovered.length > 0) {
|
|
@@ -3797,12 +3849,22 @@ export class Peer {
|
|
|
3797
3849
|
}
|
|
3798
3850
|
}
|
|
3799
3851
|
}
|
|
3800
|
-
// Track
|
|
3801
|
-
//
|
|
3802
|
-
//
|
|
3803
|
-
|
|
3852
|
+
// Track live acknowledged storage so dhtHealth() can still surface "DHT
|
|
3853
|
+
// discovery layer alive but our announce isn't landing anywhere" — that
|
|
3854
|
+
// now shows up as the count DECAYING to 0 as entries age out, instead of
|
|
3855
|
+
// flickering to 0 once per round.
|
|
3856
|
+
this.#publishSelfAnnounceStoredCount();
|
|
3804
3857
|
return storedNodes;
|
|
3805
3858
|
}
|
|
3859
|
+
/** Count nodes whose acknowledged store is still within its TTL. */
|
|
3860
|
+
#publishSelfAnnounceStoredCount() {
|
|
3861
|
+
const cutoff = Date.now() - ANNOUNCE_ENTRY_TTL_MS;
|
|
3862
|
+
for (const [id, at] of this.#selfAnnounceStoredAt) {
|
|
3863
|
+
if (at < cutoff)
|
|
3864
|
+
this.#selfAnnounceStoredAt.delete(id);
|
|
3865
|
+
}
|
|
3866
|
+
this.#lastSelfAnnounceStoredCount = this.#selfAnnounceStoredAt.size;
|
|
3867
|
+
}
|
|
3806
3868
|
#ensureSelfAnnounceLoop() {
|
|
3807
3869
|
if (this.#selfAnnounceTimer || SELF_ANNOUNCE_INTERVAL_MS <= 0) {
|
|
3808
3870
|
return;
|
|
@@ -6960,7 +7022,41 @@ export class Peer {
|
|
|
6960
7022
|
const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
|
|
6961
7023
|
await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
|
|
6962
7024
|
}
|
|
7025
|
+
/** Hand an onion request to the connected relays, which stand in for hop A. */
|
|
7026
|
+
#sendOnionOverRelays(nodeB, nodeC, nodeD, payloadForNodeD) {
|
|
7027
|
+
if (!this.#tcpRelays || this.#tcpRelays.connectedCount() === 0)
|
|
7028
|
+
return;
|
|
7029
|
+
try {
|
|
7030
|
+
const tcpPacket = createOnionRequest0Tcp({
|
|
7031
|
+
nodeBHost: nodeB.host,
|
|
7032
|
+
nodeBPort: nodeB.port,
|
|
7033
|
+
nodeBPublicKey: nodeB.publicKey,
|
|
7034
|
+
nodeCHost: nodeC.host,
|
|
7035
|
+
nodeCPort: nodeC.port,
|
|
7036
|
+
nodeCPublicKey: nodeC.publicKey,
|
|
7037
|
+
nodeDHost: nodeD.host,
|
|
7038
|
+
nodeDPort: nodeD.port,
|
|
7039
|
+
payloadForNodeD
|
|
7040
|
+
});
|
|
7041
|
+
const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
|
|
7042
|
+
if (sent > 0) {
|
|
7043
|
+
this.#diagTcpOnionSent += 1;
|
|
7044
|
+
this.#debugVerboseLog(`tcp onion request sent via ${sent} relay(s) to ${nodeD.host}:${nodeD.port}`);
|
|
7045
|
+
}
|
|
7046
|
+
}
|
|
7047
|
+
catch {
|
|
7048
|
+
/* malformed hop key — nothing to send */
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
6963
7051
|
async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
|
|
7052
|
+
// Does this host have real UDP? The browser shim binds and drops, and its
|
|
7053
|
+
// socket reports port 0. Building the UDP onion request there is six
|
|
7054
|
+
// scalar multiplications — three ephemeral keypairs and three
|
|
7055
|
+
// Diffie-Hellmans, in pure JS — for a packet that goes nowhere. Measured:
|
|
7056
|
+
// onion construction was 79% of all crypto time, split almost exactly
|
|
7057
|
+
// between the UDP variant and its TCP twin, and a browser tab held a core
|
|
7058
|
+
// at 100% for 25 hours doing it. Decide BEFORE constructing anything.
|
|
7059
|
+
const udpUsable = (this.#udp?.localPort() ?? 0) > 0;
|
|
6964
7060
|
const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
|
|
6965
7061
|
if (!path) {
|
|
6966
7062
|
// No 3-hop UDP path. "Direct" then meant a plain UDP send — which on a
|
|
@@ -6971,37 +7067,29 @@ export class Peer {
|
|
|
6971
7067
|
// A RELAYED onion request needs only two hops: the relay is node A. So
|
|
6972
7068
|
// try that before giving up, and only fall through to the UDP send when
|
|
6973
7069
|
// there are no relays or not even two usable hops.
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
nodeDPort: nodeD.port,
|
|
6987
|
-
payloadForNodeD
|
|
6988
|
-
});
|
|
6989
|
-
const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
|
|
6990
|
-
if (sent > 0) {
|
|
6991
|
-
this.#diagTcpOnionSent += 1;
|
|
6992
|
-
this.#debugLog(`no 3-hop path for ${nodeD.host}:${nodeD.port} — relayed 2-hop onion via ${sent} relay(s) (B=${hops.nodeB.host} C=${hops.nodeC.host})`);
|
|
6993
|
-
return "direct";
|
|
6994
|
-
}
|
|
6995
|
-
}
|
|
6996
|
-
catch {
|
|
6997
|
-
/* malformed hop key — fall through to the UDP attempt */
|
|
6998
|
-
}
|
|
6999
|
-
}
|
|
7070
|
+
const hops = this.#tcpRelays && this.#tcpRelays.connectedCount() > 0
|
|
7071
|
+
? this.#selectTcpOnionHops(nodeD, pathOffset)
|
|
7072
|
+
: undefined;
|
|
7073
|
+
if (hops) {
|
|
7074
|
+
this.#sendOnionOverRelays(hops.nodeB, hops.nodeC, nodeD, payloadForNodeD);
|
|
7075
|
+
return "direct";
|
|
7076
|
+
}
|
|
7077
|
+
if (!udpUsable) {
|
|
7078
|
+
// Nothing can carry it: no relay hops and no UDP. Say so instead of
|
|
7079
|
+
// handing the packet to a socket that drops it silently.
|
|
7080
|
+
this.#debugLog(`no route for ${nodeD.host}:${nodeD.port} — no relay hops and no UDP`);
|
|
7081
|
+
return "direct";
|
|
7000
7082
|
}
|
|
7001
7083
|
this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
|
|
7002
7084
|
await this.#sendPacket(payloadForNodeD, nodeD);
|
|
7003
7085
|
return "direct";
|
|
7004
7086
|
}
|
|
7087
|
+
if (!udpUsable) {
|
|
7088
|
+
// No UDP: go straight to the relay form, which is the only one that can
|
|
7089
|
+
// actually leave this host.
|
|
7090
|
+
this.#sendOnionOverRelays(path.nodeB, path.nodeC, nodeD, payloadForNodeD);
|
|
7091
|
+
return path;
|
|
7092
|
+
}
|
|
7005
7093
|
this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
|
|
7006
7094
|
const packet = createOnionRequest0({
|
|
7007
7095
|
nodeAPublicKey: path.nodeA.publicKey,
|
|
@@ -7269,12 +7357,48 @@ export class Peer {
|
|
|
7269
7357
|
if (this.#selfAnnouncePromise) {
|
|
7270
7358
|
await this.#selfAnnouncePromise.catch(() => { });
|
|
7271
7359
|
}
|
|
7272
|
-
|
|
7360
|
+
// Hard deadline around the WHOLE run, not just the per-node waiter.
|
|
7361
|
+
//
|
|
7362
|
+
// #ensureSelfAnnounceLoop skips a tick while #selfAnnouncePromise is set,
|
|
7363
|
+
// so one run that never settles kills self-announce FOREVER — silently,
|
|
7364
|
+
// and with dhtHealth still reporting the last good numbers. Measured on
|
|
7365
|
+
// app.beagle.chat 2026-08-31: lastSelfAnnounceMs frozen 1731s, stored
|
|
7366
|
+
// stuck at 11, nobody able to look us up. lastSelfAnnounceMs is stamped
|
|
7367
|
+
// on ENTRY, so a frozen value proves the function was never re-entered —
|
|
7368
|
+
// the loop was latched, not failing.
|
|
7369
|
+
//
|
|
7370
|
+
// The hang is in the transport: #sendAnnounceAndWait awaits
|
|
7371
|
+
// #sendThroughOnionPath BEFORE it awaits the timeout-guarded waiter, and
|
|
7372
|
+
// in a browser that send goes over a relay WebSocket. A backgrounded tab
|
|
7373
|
+
// can leave that socket neither open nor errored, so the await never
|
|
7374
|
+
// settles and Promise.allSettled underneath waits forever. Native peers
|
|
7375
|
+
// send over UDP and never hit it, which is why this was browser-only.
|
|
7376
|
+
//
|
|
7377
|
+
// Timing out the specific await would fix today's hang; this fixes the
|
|
7378
|
+
// class. A self-healing loop must not depend on every transport path
|
|
7379
|
+
// being timeout-correct to stay alive.
|
|
7380
|
+
const budgetMs = Math.max(0, deadlineMs - Date.now()) + SELF_ANNOUNCE_WATCHDOG_MARGIN_MS;
|
|
7381
|
+
let watchdog;
|
|
7382
|
+
const guarded = Promise.race([
|
|
7383
|
+
this.#announceSelfBestEffort(force, deadlineMs),
|
|
7384
|
+
new Promise((resolve) => {
|
|
7385
|
+
watchdog = setTimeout(() => {
|
|
7386
|
+
this.#debugLog(`self announce watchdog fired after ${Math.round(budgetMs / 1000)}s — ` +
|
|
7387
|
+
`abandoning the run so the loop can retry`);
|
|
7388
|
+
this.#selfAnnounceEpoch += 1;
|
|
7389
|
+
resolve([]);
|
|
7390
|
+
}, budgetMs);
|
|
7391
|
+
watchdog.unref?.();
|
|
7392
|
+
}),
|
|
7393
|
+
]);
|
|
7394
|
+
this.#selfAnnouncePromise = guarded;
|
|
7273
7395
|
try {
|
|
7274
|
-
const result = await
|
|
7396
|
+
const result = await guarded;
|
|
7275
7397
|
return result ?? [];
|
|
7276
7398
|
}
|
|
7277
7399
|
finally {
|
|
7400
|
+
if (watchdog)
|
|
7401
|
+
clearTimeout(watchdog);
|
|
7278
7402
|
this.#selfAnnouncePromise = undefined;
|
|
7279
7403
|
}
|
|
7280
7404
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.155",
|
|
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",
|