@decentnetwork/peer 0.1.112 → 0.1.114
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/filetransfer.d.ts +8 -1
- package/dist/compat/filetransfer.js +53 -1
- package/dist/peer.js +239 -10
- package/package.json +1 -1
|
@@ -43,13 +43,20 @@ export type FtEmit = (event: string, payload: Record<string, unknown>) => void;
|
|
|
43
43
|
export type FtIsLanPath = (friendId: string) => boolean;
|
|
44
44
|
export type FtPathKind = "lan" | "udp-direct" | "relay";
|
|
45
45
|
export type FtPathKindFn = (friendId: string) => FtPathKind;
|
|
46
|
+
/** Fired every watchdog tick while the zero-delivery breaker is engaged: the
|
|
47
|
+
* current bulk path is delivering NOTHING (no ack progress). The embedder
|
|
48
|
+
* should fail the transfer over to its reliable path (e.g. pin file traffic
|
|
49
|
+
* to the TCP relay) — inbound-UDP freshness does NOT prove our outbound UDP
|
|
50
|
+
* reaches the peer, and a one-way path looks exactly like this. */
|
|
51
|
+
export type FtDeadPathFn = (friendId: string) => void;
|
|
46
52
|
export declare class FileTransferManager {
|
|
47
53
|
#private;
|
|
48
54
|
private readonly send;
|
|
49
55
|
private readonly emit;
|
|
50
56
|
private readonly isLanPath;
|
|
51
57
|
private readonly pathKind;
|
|
52
|
-
|
|
58
|
+
private readonly onDeadPath;
|
|
59
|
+
constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath, pathKind?: FtPathKindFn, onDeadPath?: FtDeadPathFn);
|
|
53
60
|
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
54
61
|
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
55
62
|
setResumeDir(dir: string): void;
|
|
@@ -136,6 +136,17 @@ const LAN_BDP_RTT_FLOOR_MS = 50;
|
|
|
136
136
|
const LAN_CWND_INIT_CHUNKS = Math.ceil((LAN_PACE_INIT_BPS * (LAN_BDP_RTT_FLOOR_MS / 1000) * 2) / MAX_FILE_DATA_SIZE);
|
|
137
137
|
const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
|
|
138
138
|
const WATCHDOG_MAX_MS = 8000; // cap the RTT-adaptive stall patience (must exceed a slow path's RTT so acks aren't mistaken for a stall)
|
|
139
|
+
// Zero-delivery circuit breaker: after this many consecutive stalls with NO ack
|
|
140
|
+
// progress, stop go-back-N re-blasting the window and drop to a single probe
|
|
141
|
+
// chunk per patience tick. File chunks are RELIABLE on the relay (never dropped
|
|
142
|
+
// under backpressure) while IP/ping packets are droppable, so re-blasting a
|
|
143
|
+
// window into a path that is delivering nothing monopolizes the shared relay
|
|
144
|
+
// socket and starves ping/IP for the whole SEND_GIVEUP_MS minute (observed
|
|
145
|
+
// mac→lili: 0 acks in 60 s, ping 100% loss while sending, recovers on giveup).
|
|
146
|
+
// A healthy path clears `stalls` on every ack advance and never reaches this;
|
|
147
|
+
// a genuinely lost hole is probed with exactly the missing chunk, which is
|
|
148
|
+
// strictly more precise than re-sending the whole window anyway.
|
|
149
|
+
const STALL_PROBE_THRESHOLD = 3;
|
|
139
150
|
const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
|
|
140
151
|
const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
|
|
141
152
|
const REACK_MS = 200; // receiver keep-alive re-ack cadence
|
|
@@ -210,16 +221,18 @@ export class FileTransferManager {
|
|
|
210
221
|
emit;
|
|
211
222
|
isLanPath;
|
|
212
223
|
pathKind;
|
|
224
|
+
onDeadPath;
|
|
213
225
|
#sending = new Map();
|
|
214
226
|
#receiving = new Map();
|
|
215
227
|
// When set, incoming transfers persist their contiguous prefix here so a
|
|
216
228
|
// dropped/restarted transfer resumes instead of restarting (断点续传).
|
|
217
229
|
#resumeDir;
|
|
218
|
-
constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay")) {
|
|
230
|
+
constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay"), onDeadPath = () => { }) {
|
|
219
231
|
this.send = send;
|
|
220
232
|
this.emit = emit;
|
|
221
233
|
this.isLanPath = isLanPath;
|
|
222
234
|
this.pathKind = pathKind;
|
|
235
|
+
this.onDeadPath = onDeadPath;
|
|
223
236
|
}
|
|
224
237
|
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
225
238
|
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
@@ -767,6 +780,12 @@ export class FileTransferManager {
|
|
|
767
780
|
st.acked = Math.min(ackedOffset, st.size);
|
|
768
781
|
st.lastAckAdvanceMs = nowT;
|
|
769
782
|
st.stalls = 0;
|
|
783
|
+
// Once a transfer hit the zero-delivery breaker, keep the embedder's
|
|
784
|
+
// dead-path failover armed for its remainder — recovery happened ON the
|
|
785
|
+
// failover path, so letting the pin lapse would swing DATA back onto the
|
|
786
|
+
// path that was delivering nothing.
|
|
787
|
+
if (st.deadPathTripped)
|
|
788
|
+
this.onDeadPath(friendId);
|
|
770
789
|
// Resume: if the receiver's ack jumps ahead of where we've sent (it had a
|
|
771
790
|
// persisted partial), skip the send cursor past the bytes it already has
|
|
772
791
|
// instead of re-transmitting them.
|
|
@@ -1051,6 +1070,26 @@ export class FileTransferManager {
|
|
|
1051
1070
|
st.parityHighBlock = ackBlock - 1;
|
|
1052
1071
|
st.lastLossMs = nowMs(); // keep FEC ON so the re-emit actually fires
|
|
1053
1072
|
}
|
|
1073
|
+
// Zero-delivery probe: exactly one chunk at the ack frontier, no window
|
|
1074
|
+
// re-blast, no parity. Keeps a dead/black-holed path's cost near zero
|
|
1075
|
+
// (~1.4 KB per patience tick) so the shared relay socket still carries
|
|
1076
|
+
// ping/IP, while still being the precise retransmit for a lost hole.
|
|
1077
|
+
async #sendProbeChunk(friendId, st) {
|
|
1078
|
+
const off = st.acked;
|
|
1079
|
+
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
1080
|
+
if (end <= off)
|
|
1081
|
+
return;
|
|
1082
|
+
if (st.nextSend < end)
|
|
1083
|
+
st.nextSend = end; // pump resumes AFTER the probed chunk on recovery
|
|
1084
|
+
try {
|
|
1085
|
+
await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* transport down — the watchdog re-probes on its next tick */ }
|
|
1088
|
+
if (process.env.DECENT_DEBUG || process.env.DECENT_DEBUG_VERBOSE) {
|
|
1089
|
+
console.error(`[file-ft] dead-path-probe friend=${friendId.slice(0, 8)} num=${st.fileNumber} ` +
|
|
1090
|
+
`stalls=${st.stalls} acked=${st.acked}/${st.size}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1054
1093
|
// Single send loop. Awaits each send so net_crypto / the UDP socket applies
|
|
1055
1094
|
// backpressure (fire-and-forget blasted the whole window into the macOS UDP
|
|
1056
1095
|
// buffer at once → systematic overflow/drops the retransmit couldn't dig out
|
|
@@ -1094,6 +1133,19 @@ export class FileTransferManager {
|
|
|
1094
1133
|
st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
|
|
1095
1134
|
st.lastAckAdvanceMs = nowMs();
|
|
1096
1135
|
st.lastResendMs = nowMs();
|
|
1136
|
+
if (st.stalls >= STALL_PROBE_THRESHOLD) {
|
|
1137
|
+
// Path is delivering nothing — probe with one chunk instead of
|
|
1138
|
+
// re-blasting the window, so the shared relay keeps carrying
|
|
1139
|
+
// ping/IP. Any ack advance resets `stalls` and resumes the pump.
|
|
1140
|
+
// Also tell the embedder to fail DATA over to its reliable path:
|
|
1141
|
+
// the classic cause here is one-way UDP (their packets keep our
|
|
1142
|
+
// udpFresh alive while ours are black-holed), which no amount of
|
|
1143
|
+
// probing on the same path can fix.
|
|
1144
|
+
st.deadPathTripped = true;
|
|
1145
|
+
this.onDeadPath(friendId);
|
|
1146
|
+
void this.#sendProbeChunk(friendId, st);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1097
1149
|
this.#rewindRetransmit(st); // go-back-N + re-emit tail parity
|
|
1098
1150
|
void this.#pump(friendId, st);
|
|
1099
1151
|
}, WATCHDOG_MS);
|
package/dist/peer.js
CHANGED
|
@@ -160,6 +160,24 @@ const LAN_SWEEP_EXTRA_HOSTS = (process.env.DECENT_LAN_SWEEP_EXTRA_HOSTS ?? "")
|
|
|
160
160
|
.split(",")
|
|
161
161
|
.map((s) => s.trim())
|
|
162
162
|
.filter((s) => s.length > 0);
|
|
163
|
+
// Same-host probe: on cookie-request RETRIES, also send the request to our
|
|
164
|
+
// OWN physical IPv4 addresses (plus loopback) at the native default ports.
|
|
165
|
+
// This is the iOS-Simulator-on-this-Mac case: the sim shares the host's
|
|
166
|
+
// IP, so when the host's DHCP address changes BOTH sides keep retrying the
|
|
167
|
+
// dead stored endpoint forever, and DHT/onion refresh can't rescue a
|
|
168
|
+
// same-host peer (router hairpin). Unlike the full /24 sweep (default OFF
|
|
169
|
+
// — its burst saturated the NAT table and killed onion announces), these
|
|
170
|
+
// few unicast packets to ourselves never leave the machine, so this is
|
|
171
|
+
// safe to keep always-on. DECENT_LAN_SELF_PROBE=0 disables.
|
|
172
|
+
const LAN_SELF_PROBE_ENABLED = readEnvInt("DECENT_LAN_SELF_PROBE", 1) !== 0;
|
|
173
|
+
// toxcore NET_PACKET_LAN_DISCOVERY: [0x21][sender DHT pk (32)] — plaintext.
|
|
174
|
+
// A native node that receives one DHT-bootstraps back to the source, i.e. it
|
|
175
|
+
// sends us a getnodes/ping carrying its CURRENT (per-run ephemeral) DHT pk in
|
|
176
|
+
// the clear. That round-trip is the only way to re-learn a native friend's
|
|
177
|
+
// DHT key without onion announces: cookie requests encrypted to a stale DHT
|
|
178
|
+
// key are silently undecryptable (observed live: iPhone answered mini but
|
|
179
|
+
// ignored 780+ cookie retries from mac-dev — same endpoint, stale key).
|
|
180
|
+
const NET_PACKET_LAN_DISCOVERY = 0x21;
|
|
163
181
|
const LAN_DISCOVERY_PORTS = (process.env.DECENT_LAN_DISCOVERY_PORTS ?? "33445,33446,33447,33448,33449")
|
|
164
182
|
.split(",")
|
|
165
183
|
.map((s) => Number.parseInt(s.trim(), 10))
|
|
@@ -243,6 +261,21 @@ export class Peer {
|
|
|
243
261
|
Date.now() - session.lastUdpRecvMs < 4_000)
|
|
244
262
|
return "udp-direct";
|
|
245
263
|
return "relay";
|
|
264
|
+
}, (friendId) => {
|
|
265
|
+
// Zero-delivery breaker tripped: the bulk path is delivering nothing.
|
|
266
|
+
// The classic cause is one-way UDP — the peer's inbound packets keep
|
|
267
|
+
// udpFresh alive while our outbound UDP is black-holed (their ACCEPT
|
|
268
|
+
// arrived over the TCP relay, which proves the SESSION, not the UDP
|
|
269
|
+
// path). Pin this friend's file traffic to the reliable relay so the
|
|
270
|
+
// probes — and the resumed pump once acks return — actually arrive.
|
|
271
|
+
// Rolling window: re-armed every tick while dead; expires after the
|
|
272
|
+
// transfer finishes or gives up, so a later transfer re-tries UDP.
|
|
273
|
+
const session = this.#friendSessions.get(friendId);
|
|
274
|
+
const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
|
|
275
|
+
if (!lan) {
|
|
276
|
+
this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 30_000);
|
|
277
|
+
this.#debugLog(`file dead-path failover: pinning ${friendId} file traffic to relay for 30s`);
|
|
278
|
+
}
|
|
246
279
|
});
|
|
247
280
|
#keyPair;
|
|
248
281
|
#udp = new UdpTransport();
|
|
@@ -303,6 +336,18 @@ export class Peer {
|
|
|
303
336
|
#selfAnnounceTimer;
|
|
304
337
|
#friendConnectionTimer;
|
|
305
338
|
#lanDiscoveryTimer;
|
|
339
|
+
/** Recent LAN-discovery probe targets ("host:port" → friend we probed for).
|
|
340
|
+
* When a DHT packet later arrives from one of these sources, its sender pk
|
|
341
|
+
* is that friend's CURRENT DHT key — see #refreshFriendDhtKeyFromDht. */
|
|
342
|
+
#lanProbeTargets = new Map();
|
|
343
|
+
/** Exclusive turn-taking for OWN-HOST probe targets. Own-host targets
|
|
344
|
+
* (our IPs + loopback at native ports) are shared by every stranded
|
|
345
|
+
* friend, so concurrent probes overwrite each other's #lanProbeTargets
|
|
346
|
+
* mapping and the reply gets credited to whoever probed last (observed
|
|
347
|
+
* live: the simulator's key landed on an unrelated friend). One friend
|
|
348
|
+
* holds the slot at a time; per-friend candidate probes are unaffected. */
|
|
349
|
+
#ownHostProbeFriendId;
|
|
350
|
+
#ownHostProbeUntilMs = 0;
|
|
306
351
|
#dhtMaintenanceTimer;
|
|
307
352
|
// Per-friend last DHT-PK send time, keyed by friendId, used even when no
|
|
308
353
|
// session entry exists yet so the connection loop does not flood DHT-PK
|
|
@@ -621,6 +666,13 @@ export class Peer {
|
|
|
621
666
|
}
|
|
622
667
|
}
|
|
623
668
|
}
|
|
669
|
+
// Run the friend-connection loop from start(), not only after a
|
|
670
|
+
// successful joinNetwork(): when every bootstrap is unreachable
|
|
671
|
+
// (offline LAN, walled network), persisted same-LAN/same-host friends
|
|
672
|
+
// must still get their cookie retries + rescue probes — otherwise a
|
|
673
|
+
// daemon with dead bootstraps never reconnects to peers sitting right
|
|
674
|
+
// next to it. Idempotent with the joinNetwork() call.
|
|
675
|
+
this.#ensureFriendConnectionLoop();
|
|
624
676
|
this.#started = true;
|
|
625
677
|
}
|
|
626
678
|
/** Lazy session shell used when we want to attach state before any handshake. */
|
|
@@ -1690,6 +1742,22 @@ export class Peer {
|
|
|
1690
1742
|
catch { /* best-effort */ }
|
|
1691
1743
|
}).catch(() => { });
|
|
1692
1744
|
}
|
|
1745
|
+
// toxcore LAN discovery (0x21, plaintext [id][sender dht pk]): answer like
|
|
1746
|
+
// native DHT_bootstrap does — DHT-ping the announcer. Our ping reveals OUR
|
|
1747
|
+
// current DHT pk + live endpoint to them, and their ping/response feeds
|
|
1748
|
+
// #refreshFriendDhtKeyFromDht on their side. This is the reply half of the
|
|
1749
|
+
// stranded-friend rescue probes (see #initiateSession).
|
|
1750
|
+
if (packet[0] === NET_PACKET_LAN_DISCOVERY &&
|
|
1751
|
+
packet.length === 33 &&
|
|
1752
|
+
this.#keyPair &&
|
|
1753
|
+
!this.#remoteIsTcp(remote)) {
|
|
1754
|
+
const announcedPk = packet.slice(1, 33);
|
|
1755
|
+
if (!Buffer.from(announcedPk).equals(Buffer.from(this.#keyPair.publicKey))) {
|
|
1756
|
+
const announcerId = carrierIdFromPublicKey(announcedPk);
|
|
1757
|
+
void this.#sendDhtPing({ host: remote.address, port: remote.port, pk: announcerId, isTcp: false }).catch(() => undefined);
|
|
1758
|
+
}
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1693
1761
|
// Classic toxcore DHT RPC (ping / get_nodes / send_nodes). Answering these
|
|
1694
1762
|
// is what makes us *findable* over UDP: a native iOS/Android peer searches
|
|
1695
1763
|
// the DHT for our key, a neighbour that holds our address returns it, then
|
|
@@ -4466,7 +4534,13 @@ export class Peer {
|
|
|
4466
4534
|
}
|
|
4467
4535
|
if (session.lanRemoteHost &&
|
|
4468
4536
|
session.remote?.host === session.lanRemoteHost &&
|
|
4469
|
-
host !== session.lanRemoteHost
|
|
4537
|
+
host !== session.lanRemoteHost &&
|
|
4538
|
+
// Same-host exception: a peer on THIS machine (iOS Simulator) moves
|
|
4539
|
+
// with us when our DHCP address changes — the old LAN lock target is
|
|
4540
|
+
// dead and the peer now answers from our own current address (or
|
|
4541
|
+
// loopback). Refusing that move pins the session to the dead IP
|
|
4542
|
+
// forever. isOwnAddress is cached; no syscall on the hot path.
|
|
4543
|
+
!(host === "127.0.0.1" || isOwnAddress(host))) {
|
|
4470
4544
|
return; // stay locked on the physical-LAN path
|
|
4471
4545
|
}
|
|
4472
4546
|
session.remote = { host, port };
|
|
@@ -5077,12 +5151,88 @@ export class Peer {
|
|
|
5077
5151
|
// Keep best-effort behavior.
|
|
5078
5152
|
}
|
|
5079
5153
|
}
|
|
5154
|
+
// Rescue probes (retries only). Two stale-state failure modes keep a
|
|
5155
|
+
// native friend unreachable forever even though it is alive nearby:
|
|
5156
|
+
// 1. Stale ENDPOINT, same host: an iOS Simulator shares this
|
|
5157
|
+
// machine's IP, so after a DHCP change both sides hold the dead
|
|
5158
|
+
// old IP and hairpin blocks every refresh path — but the sim is
|
|
5159
|
+
// always reachable at our current own IP.
|
|
5160
|
+
// 2. Stale DHT KEY, live endpoint: a native's DHT keypair rotates
|
|
5161
|
+
// every app restart; cookie requests encrypted to the old key
|
|
5162
|
+
// are silently dropped (iPhone ignored 780+ retries from
|
|
5163
|
+
// mac-dev while answering mini from the same endpoint).
|
|
5164
|
+
// So on each retry, (a) fan the cookie request out to our own
|
|
5165
|
+
// addresses at the native ports (fixes 1 when the key is current),
|
|
5166
|
+
// and (b) send plaintext LAN-discovery probes to every candidate and
|
|
5167
|
+
// own-host target — a native answers with a DHT packet that carries
|
|
5168
|
+
// its CURRENT DHT key, which #refreshFriendDhtKeyFromDht picks up
|
|
5169
|
+
// (fixes 2, and 1+2 combined). All local/LAN unicast, a handful of
|
|
5170
|
+
// packets per retry cycle.
|
|
5171
|
+
let selfSent = 0;
|
|
5172
|
+
if (LAN_SELF_PROBE_ENABLED && (this.#cookieRetryCount.get(friendId) ?? 0) >= 1) {
|
|
5173
|
+
const ourLocalPort = this.#udp.localPort();
|
|
5174
|
+
const lanDiscovery = new Uint8Array(33);
|
|
5175
|
+
lanDiscovery[0] = NET_PACKET_LAN_DISCOVERY;
|
|
5176
|
+
lanDiscovery.set(this.#keyPair.publicKey, 1);
|
|
5177
|
+
const probeNow = Date.now();
|
|
5178
|
+
// Prune stale probe bookkeeping so the map stays bounded.
|
|
5179
|
+
for (const [k, v] of this.#lanProbeTargets) {
|
|
5180
|
+
if (probeNow - v.sentMs > 300_000)
|
|
5181
|
+
this.#lanProbeTargets.delete(k);
|
|
5182
|
+
}
|
|
5183
|
+
const probe = async (host, port, alsoCookie) => {
|
|
5184
|
+
const key = `${host}:${port}`;
|
|
5185
|
+
this.#lanProbeTargets.set(key, { friendId, sentMs: probeNow });
|
|
5186
|
+
try {
|
|
5187
|
+
await this.#sendPacket(lanDiscovery, { host, port });
|
|
5188
|
+
if (alsoCookie) {
|
|
5189
|
+
await this.#sendPacket(packet, { host, port });
|
|
5190
|
+
selfSent += 1;
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
catch {
|
|
5194
|
+
// best-effort
|
|
5195
|
+
}
|
|
5196
|
+
};
|
|
5197
|
+
const tried = new Set();
|
|
5198
|
+
// (b) known candidates — refresh a rotated DHT key at a live endpoint.
|
|
5199
|
+
// Per-friend targets: no mapping ambiguity, always allowed.
|
|
5200
|
+
for (const candidate of connectCandidates) {
|
|
5201
|
+
const key = `${candidate.host}:${candidate.port}`;
|
|
5202
|
+
if (tried.has(key))
|
|
5203
|
+
continue;
|
|
5204
|
+
tried.add(key);
|
|
5205
|
+
await probe(candidate.host, candidate.port, false); // cookie already sent above
|
|
5206
|
+
}
|
|
5207
|
+
// (a)+(b) own-host targets — find a same-host peer after an IP change.
|
|
5208
|
+
// SHARED targets: only the current slot holder probes them, so the
|
|
5209
|
+
// reply maps to exactly one friend. Slot rotates by expiry; the
|
|
5210
|
+
// cookie backoff staggers contenders so every stranded friend
|
|
5211
|
+
// eventually gets a turn.
|
|
5212
|
+
if (this.#ownHostProbeFriendId === friendId || probeNow >= this.#ownHostProbeUntilMs) {
|
|
5213
|
+
this.#ownHostProbeFriendId = friendId;
|
|
5214
|
+
this.#ownHostProbeUntilMs = probeNow + 10_000;
|
|
5215
|
+
for (const host of [...getLocalIpv4Addresses(), "127.0.0.1"]) {
|
|
5216
|
+
if (isOwnVirtualAddress(host))
|
|
5217
|
+
continue; // never probe into the overlay
|
|
5218
|
+
for (const port of LAN_SWEEP_PORTS) {
|
|
5219
|
+
if (port === ourLocalPort)
|
|
5220
|
+
continue; // our own socket
|
|
5221
|
+
const key = `${host}:${port}`;
|
|
5222
|
+
if (tried.has(key))
|
|
5223
|
+
continue;
|
|
5224
|
+
tried.add(key);
|
|
5225
|
+
await probe(host, port, true);
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
}
|
|
5080
5230
|
// Also send via TCP relay if available, in parallel. Whichever
|
|
5081
5231
|
// arrives at the friend first triggers their cookie response.
|
|
5082
5232
|
if (tcpAvailable && this.#tcpRelays) {
|
|
5083
5233
|
tcpSent = this.#tcpRelays.sendToFriend(friendRealPk, packet);
|
|
5084
5234
|
}
|
|
5085
|
-
if (sent === 0 && tcpSent === 0) {
|
|
5235
|
+
if (sent === 0 && tcpSent === 0 && selfSent === 0) {
|
|
5086
5236
|
throw new Error("no cookie request packet was sent");
|
|
5087
5237
|
}
|
|
5088
5238
|
// Each unmatched attempt grows the per-friend backoff. Resets when a
|
|
@@ -5092,21 +5242,30 @@ export class Peer {
|
|
|
5092
5242
|
const primaryDesc = connectCandidates.length > 0
|
|
5093
5243
|
? `${connectCandidates[0].host}:${connectCandidates[0].port}`
|
|
5094
5244
|
: `tcp-relay`;
|
|
5095
|
-
const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}`;
|
|
5245
|
+
const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}|self=${selfSent}`;
|
|
5096
5246
|
if (this.#lastCookieSentKey.get(friendId) !== cookieKey) {
|
|
5097
|
-
this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc}`);
|
|
5247
|
+
this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc}`);
|
|
5098
5248
|
this.#lastCookieSentKey.set(friendId, cookieKey);
|
|
5099
5249
|
}
|
|
5100
5250
|
else {
|
|
5101
|
-
this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
|
|
5251
|
+
this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
|
|
5102
5252
|
}
|
|
5103
5253
|
return true;
|
|
5104
5254
|
}
|
|
5105
5255
|
catch (error) {
|
|
5106
5256
|
this.#debugLog(`cookie request send failed for ${friendId}: ${error.message}`);
|
|
5257
|
+
// A failed send is still a failed ATTEMPT: count it so (a) backoff
|
|
5258
|
+
// grows instead of hammering a dead address at base cadence forever,
|
|
5259
|
+
// and (b) the rescue probes (gated on retryCount >= 1) still fire for
|
|
5260
|
+
// a friend whose sole candidate errors at the syscall level — before
|
|
5261
|
+
// this, such a friend never probed and could never be rescued.
|
|
5262
|
+
this.#cookieRetryCount.set(friendId, (this.#cookieRetryCount.get(friendId) ?? 0) + 1);
|
|
5107
5263
|
session.pendingEcho = undefined;
|
|
5108
5264
|
session.pendingCookiePeerDhtPublicKey = undefined;
|
|
5109
|
-
|
|
5265
|
+
// Keep cookieRequestSentMs (set at attempt start): it is the loop's
|
|
5266
|
+
// `lastAttempt`. Clearing it made lastAttempt 0, so the cooldown gate
|
|
5267
|
+
// always passed and a friend whose sends error retried every 250ms
|
|
5268
|
+
// tick forever — the "cookie request send failed" log flood.
|
|
5110
5269
|
return false;
|
|
5111
5270
|
}
|
|
5112
5271
|
}
|
|
@@ -5147,9 +5306,9 @@ export class Peer {
|
|
|
5147
5306
|
// default so this is invisible for real-device tests, opt-in for
|
|
5148
5307
|
// loopback or other known-fixed targets via env var.
|
|
5149
5308
|
for (const host of LAN_SWEEP_EXTRA_HOSTS) {
|
|
5150
|
-
if (ourLocalIps.includes(host))
|
|
5151
|
-
continue;
|
|
5152
5309
|
for (const port of LAN_SWEEP_PORTS) {
|
|
5310
|
+
// Skip only our own socket — a same-host peer (iOS Simulator) lives
|
|
5311
|
+
// at our own IP on a different port, so own-IP hosts must be probed.
|
|
5153
5312
|
if (port === ourLocalPort && ourLocalIps.includes(host))
|
|
5154
5313
|
continue;
|
|
5155
5314
|
try {
|
|
@@ -5170,9 +5329,9 @@ export class Peer {
|
|
|
5170
5329
|
continue;
|
|
5171
5330
|
for (let addr = (network + 1) >>> 0; addr < broadcast; addr = (addr + 1) >>> 0) {
|
|
5172
5331
|
const host = `${(addr >>> 24) & 0xff}.${(addr >>> 16) & 0xff}.${(addr >>> 8) & 0xff}.${addr & 0xff}`;
|
|
5173
|
-
if (ourLocalIps.includes(host))
|
|
5174
|
-
continue;
|
|
5175
5332
|
for (const port of LAN_SWEEP_PORTS) {
|
|
5333
|
+
// Skip only our own socket, not our whole IP — the iOS Simulator
|
|
5334
|
+
// shares this host's address on its own port.
|
|
5176
5335
|
if (ourLocalPort === port && ourLocalIps.includes(host))
|
|
5177
5336
|
continue;
|
|
5178
5337
|
try {
|
|
@@ -5246,6 +5405,10 @@ export class Peer {
|
|
|
5246
5405
|
// keeps mutual reachability alive across the mesh.
|
|
5247
5406
|
if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
|
|
5248
5407
|
this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
|
|
5408
|
+
// A DHT packet carries the sender's CURRENT DHT pk in the clear — if
|
|
5409
|
+
// this source answers one of our LAN-discovery rescue probes, harvest
|
|
5410
|
+
// the key: it un-strands friends whose ephemeral DHT key rotated.
|
|
5411
|
+
this.#refreshFriendDhtKeyFromDht(decoded.senderPublicKey, senderId, remote.address, remote.port);
|
|
5249
5412
|
}
|
|
5250
5413
|
if (decoded.type === NET_PACKET_PING_REQUEST) {
|
|
5251
5414
|
const ping = parsePingPlain(decoded.plain);
|
|
@@ -5317,6 +5480,72 @@ export class Peer {
|
|
|
5317
5480
|
return;
|
|
5318
5481
|
}
|
|
5319
5482
|
}
|
|
5483
|
+
/** A DHT packet arrived from `host:port` with `senderDhtPk` in the clear.
|
|
5484
|
+
* If that source matches a LAN-discovery rescue probe we sent (or an
|
|
5485
|
+
* endpoint candidate of a stranded friend), adopt the pk as the friend's
|
|
5486
|
+
* CURRENT DHT key. Natives rotate their DHT keypair every app restart, so
|
|
5487
|
+
* a peer that only holds the old key sends cookie requests the friend
|
|
5488
|
+
* cannot decrypt — correct endpoint, silent drop, stuck forever.
|
|
5489
|
+
*
|
|
5490
|
+
* Safety: only unestablished sessions are touched (a live session's key
|
|
5491
|
+
* is by definition correct), and if the adopted key turns out to belong
|
|
5492
|
+
* to a different node, the crypto handshake cannot complete against the
|
|
5493
|
+
* friend's REAL key — the rescue just fails and normal retries continue. */
|
|
5494
|
+
#refreshFriendDhtKeyFromDht(senderDhtPk, senderId, host, port) {
|
|
5495
|
+
const key = `${host}:${port}`;
|
|
5496
|
+
let friendId;
|
|
5497
|
+
const probed = this.#lanProbeTargets.get(key);
|
|
5498
|
+
if (probed && Date.now() - probed.sentMs < 300_000) {
|
|
5499
|
+
friendId = probed.friendId;
|
|
5500
|
+
}
|
|
5501
|
+
else {
|
|
5502
|
+
// No probe bookkeeping — still match a stranded friend's known
|
|
5503
|
+
// endpoint candidates (the friend may DHT-ping us spontaneously).
|
|
5504
|
+
for (const [fid, session] of this.#friendSessions) {
|
|
5505
|
+
if (session.established)
|
|
5506
|
+
continue;
|
|
5507
|
+
if (session.endpointCandidates?.some((c) => c.host === host && c.port === port)) {
|
|
5508
|
+
friendId = fid;
|
|
5509
|
+
break;
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
if (!friendId)
|
|
5514
|
+
return;
|
|
5515
|
+
// The sender pk currently maps to a DIFFERENT friend. If that friend's
|
|
5516
|
+
// session is ESTABLISHED, the key genuinely belongs to them — don't
|
|
5517
|
+
// cross-wire. If it is NOT established, the mapping itself may be a
|
|
5518
|
+
// mis-credited earlier probe reply (shared own-host targets), and
|
|
5519
|
+
// refusing here would permanently lock the true owner out — steal it
|
|
5520
|
+
// and scrub the other friend's stale copy so routing stops confusing
|
|
5521
|
+
// the two. A wrong steal self-corrects: the real-key handshake cannot
|
|
5522
|
+
// complete against the wrong node.
|
|
5523
|
+
const byDht = this.#friendByDhtPk(senderId);
|
|
5524
|
+
if (byDht && byDht.friendId !== friendId) {
|
|
5525
|
+
const otherSession = this.#friendSessions.get(byDht.friendId);
|
|
5526
|
+
if (otherSession?.established)
|
|
5527
|
+
return;
|
|
5528
|
+
this.#debugLog(`dht_key_steal ${senderId} from=${byDht.friendId} to=${friendId} (other unestablished)`);
|
|
5529
|
+
if (otherSession?.friendDhtPublicKey && Buffer.from(otherSession.friendDhtPublicKey).equals(Buffer.from(senderDhtPk))) {
|
|
5530
|
+
otherSession.friendDhtPublicKey = undefined;
|
|
5531
|
+
}
|
|
5532
|
+
this.#friendDhtKeys.delete(byDht.friendId);
|
|
5533
|
+
}
|
|
5534
|
+
if (senderId === friendId)
|
|
5535
|
+
return; // already keyed by real pk — nothing to refresh
|
|
5536
|
+
const session = this.#friendSessions.get(friendId);
|
|
5537
|
+
if (!session || session.established)
|
|
5538
|
+
return;
|
|
5539
|
+
const current = session.friendDhtPublicKey;
|
|
5540
|
+
if (current && Buffer.from(current).equals(Buffer.from(senderDhtPk)))
|
|
5541
|
+
return;
|
|
5542
|
+
this.#debugLog(`dht_key_refreshed friend=${friendId} via=${key} old=${current ? carrierIdFromPublicKey(current) : "none"} new=${senderId}`);
|
|
5543
|
+
this.#cacheFriendRemote(friendId, host, port, undefined, senderDhtPk);
|
|
5544
|
+
// Drop the backoff so the next cookie request (now decryptable) goes out
|
|
5545
|
+
// promptly instead of waiting out minutes of accumulated failures.
|
|
5546
|
+
this.#cookieRetryCount.delete(friendId);
|
|
5547
|
+
void this.#initiateSession(friendId).catch(() => undefined);
|
|
5548
|
+
}
|
|
5320
5549
|
async #sendDhtGetNodes(node, targetPublicKey) {
|
|
5321
5550
|
if (!this.#keyPair || !node.pk || node.isTcp)
|
|
5322
5551
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.114",
|
|
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",
|