@decentnetwork/peer 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/peer.js +294 -42
- package/package.json +1 -1
package/dist/peer.js
CHANGED
|
@@ -17,6 +17,15 @@ import { base58ToBytes } from "./utils/base58.js";
|
|
|
17
17
|
import { UdpTransport } from "./transport/udp.js";
|
|
18
18
|
import { bytesToHex, concatBytes, randomBytes } from "./utils/bytes.js";
|
|
19
19
|
import { buildBindingRequest, decodeStun, decodeXorMappedAddress, findAttr, STUN_ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_SUCCESS } from "./stun.js";
|
|
20
|
+
import { TurnClient } from "./turn.js";
|
|
21
|
+
import { createSocket as createDgramSocket } from "dgram";
|
|
22
|
+
// Dedicated TURN relay servers — fixed public relays used as a stable
|
|
23
|
+
// fallback path when direct UDP hole-punch fails or flaps (symmetric NAT,
|
|
24
|
+
// NAT remaps, lossy direct path). A relay endpoint never NAT-flaps, so the
|
|
25
|
+
// path stays put even when the peers' NATs churn. Static long-term creds.
|
|
26
|
+
const TURN_RELAY_SERVERS = [
|
|
27
|
+
{ host: "tokyo.fi.chat", port: 3478, username: "allcom", password: "allcompass" }
|
|
28
|
+
];
|
|
20
29
|
const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
|
|
21
30
|
const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
|
|
22
31
|
// How many in-flight announce step1 requests to fan out at once. Toxcore
|
|
@@ -128,6 +137,16 @@ export class Peer {
|
|
|
128
137
|
#events = new EventEmitter();
|
|
129
138
|
#keyPair;
|
|
130
139
|
#udp = new UdpTransport();
|
|
140
|
+
// TURN relay (stable fallback path). One node-level allocation on its own
|
|
141
|
+
// dedicated UDP socket — kept separate from #udp so net_crypto/DHT and the
|
|
142
|
+
// TURN control protocol never share a socket (no demux). Inbound relayed
|
|
143
|
+
// data is injected into #onDatagram as if it arrived directly from the
|
|
144
|
+
// peer's relay address; outbound to a peer's relay address goes through
|
|
145
|
+
// #turnClient.sendTo. Lazily allocated on first need.
|
|
146
|
+
#turnClient;
|
|
147
|
+
#turnSocket;
|
|
148
|
+
#ourRelayAddr;
|
|
149
|
+
#turnAllocating;
|
|
131
150
|
#bootstrap;
|
|
132
151
|
#dht = new LegacyDhtClient();
|
|
133
152
|
#knownNodes;
|
|
@@ -169,6 +188,10 @@ export class Peer {
|
|
|
169
188
|
// session entry exists yet so the connection loop does not flood DHT-PK
|
|
170
189
|
// requests when route discovery keeps failing.
|
|
171
190
|
#dhtPkSendCooldown = new Map();
|
|
191
|
+
// Per-friend cooldown for re-asserting the TCP-relay route toward an
|
|
192
|
+
// unconnected friend (the "accepted friend that never connects" wedge —
|
|
193
|
+
// requestRoute only fired once at startup and was never retried).
|
|
194
|
+
#routeRequestCooldown = new Map();
|
|
172
195
|
// Per-friend consecutive "no routes available" count for DHT-PK so the
|
|
173
196
|
// backoff grows if a friend stays unreachable, instead of retrying every
|
|
174
197
|
// 25s forever for stale persisted entries.
|
|
@@ -398,6 +421,17 @@ export class Peer {
|
|
|
398
421
|
}
|
|
399
422
|
this.#udp.off("datagram", this.#onDatagram);
|
|
400
423
|
await this.#udp.stop();
|
|
424
|
+
// Release the TURN allocation + its dedicated socket.
|
|
425
|
+
try {
|
|
426
|
+
this.#turnClient?.close();
|
|
427
|
+
this.#turnSocket?.close();
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// best-effort
|
|
431
|
+
}
|
|
432
|
+
this.#turnClient = undefined;
|
|
433
|
+
this.#turnSocket = undefined;
|
|
434
|
+
this.#ourRelayAddr = undefined;
|
|
401
435
|
this.#started = false;
|
|
402
436
|
}
|
|
403
437
|
pubkey() {
|
|
@@ -893,7 +927,7 @@ export class Peer {
|
|
|
893
927
|
#remoteIsTcp(remote) {
|
|
894
928
|
return remote.address.startsWith("tcp:");
|
|
895
929
|
}
|
|
896
|
-
#onDatagram = ({ data, remote }) => {
|
|
930
|
+
#onDatagram = ({ data, remote, viaRelay }) => {
|
|
897
931
|
if (!this.#keyPair) {
|
|
898
932
|
return;
|
|
899
933
|
}
|
|
@@ -1191,13 +1225,26 @@ export class Peer {
|
|
|
1191
1225
|
isNewSession &&
|
|
1192
1226
|
state.sessionEstablishedAtMs !== undefined) {
|
|
1193
1227
|
const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
|
|
1194
|
-
if
|
|
1228
|
+
// Only reject the fresh handshake if our CURRENT session is
|
|
1229
|
+
// actually alive — i.e. carrying data. A wedged "established"
|
|
1230
|
+
// session that's receiving nothing must NOT block a re-handshake,
|
|
1231
|
+
// or a peer that dropped + restarted can never reconnect: it keeps
|
|
1232
|
+
// sending fresh handshakes and we keep rejecting them against a
|
|
1233
|
+
// corpse. (This is the all-exits-outage bug: mac held a dead
|
|
1234
|
+
// "established" session for cn and ignored every reconnect
|
|
1235
|
+
// attempt.) If we haven't received a packet within FRIEND_TIMEOUT_MS
|
|
1236
|
+
// the current session is dead — fall through and accept the new one.
|
|
1237
|
+
const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
|
|
1238
|
+
const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
|
|
1239
|
+
if (sinceEstablished > 1000 && currentSessionAlive) {
|
|
1195
1240
|
// Verbose only — peers retransmit handshakes aggressively, so
|
|
1196
1241
|
// this line fires dozens of times per minute on a stuck pair
|
|
1197
1242
|
// and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
|
|
1198
|
-
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on established session, ${sinceEstablished}ms after establish)`);
|
|
1243
|
+
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
|
|
1199
1244
|
return;
|
|
1200
1245
|
}
|
|
1246
|
+
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
|
|
1247
|
+
`lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
|
|
1201
1248
|
}
|
|
1202
1249
|
state.peerSessionPublicKey = hs.sessionPublicKey;
|
|
1203
1250
|
state.peerBaseNonce = hs.baseNonce.slice();
|
|
@@ -1329,6 +1376,15 @@ export class Peer {
|
|
|
1329
1376
|
if (this.#remoteIsTcp(remote)) {
|
|
1330
1377
|
state.hasTcpRoute = true;
|
|
1331
1378
|
}
|
|
1379
|
+
else if (viaRelay) {
|
|
1380
|
+
// Arrived over the TURN relay. Track relay freshness separately;
|
|
1381
|
+
// do NOT set state.remote (that's the direct endpoint). The relay
|
|
1382
|
+
// address it came from is already in state.relayRemote.
|
|
1383
|
+
if (!state.lastRelayRecvMs) {
|
|
1384
|
+
this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
|
|
1385
|
+
}
|
|
1386
|
+
state.lastRelayRecvMs = Date.now();
|
|
1387
|
+
}
|
|
1332
1388
|
else {
|
|
1333
1389
|
state.remote = { host: remote.address, port: remote.port };
|
|
1334
1390
|
if (!state.lastUdpRecvMs) {
|
|
@@ -2122,10 +2178,33 @@ export class Peer {
|
|
|
2122
2178
|
const session = this.#friendSessions.get(friendId);
|
|
2123
2179
|
// Established session: keepalive + timeout monitoring
|
|
2124
2180
|
if (session?.established) {
|
|
2125
|
-
|
|
2126
|
-
|
|
2181
|
+
// Liveness: time out a session that hasn't received ANY packet
|
|
2182
|
+
// within FRIEND_TIMEOUT_MS — measured from the last received ping
|
|
2183
|
+
// OR, if we've never received one, from when the session was
|
|
2184
|
+
// established. The previous `lastPingRecvMs &&` guard skipped the
|
|
2185
|
+
// check entirely when lastPingRecvMs was undefined, so a session
|
|
2186
|
+
// that established but never carried a single packet (the path
|
|
2187
|
+
// died at/just-after handshake) lived forever reporting
|
|
2188
|
+
// transport=both while IP forwarding was 100% loss, and never tore
|
|
2189
|
+
// down to re-handshake. Tearing it down here drops it to the
|
|
2190
|
+
// non-established branch below, which re-initiates a fresh
|
|
2191
|
+
// connection — turning a permanent wedge into a ~timeout self-heal.
|
|
2192
|
+
const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
|
|
2193
|
+
if (now - lastAlive > FRIEND_TIMEOUT_MS) {
|
|
2194
|
+
this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
|
|
2195
|
+
`lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
|
|
2127
2196
|
this.#friendSessions.delete(friendId);
|
|
2128
2197
|
this.#setFriendOffline(friendId);
|
|
2198
|
+
// Re-assert the relay route so the friend-connection bootstrap
|
|
2199
|
+
// (CONNECTION_NOTIFICATION -> #initiateSession) can re-fire on
|
|
2200
|
+
// re-establishment. requestRoute is idempotent, so this is a
|
|
2201
|
+
// no-op if the route is still live.
|
|
2202
|
+
try {
|
|
2203
|
+
const pk = base58ToBytes(friend.pubkey);
|
|
2204
|
+
if (pk.length === 32)
|
|
2205
|
+
this.#tcpRelays?.requestRoute(pk);
|
|
2206
|
+
}
|
|
2207
|
+
catch { /* skip malformed */ }
|
|
2129
2208
|
continue;
|
|
2130
2209
|
}
|
|
2131
2210
|
if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
|
|
@@ -2163,14 +2242,33 @@ export class Peer {
|
|
|
2163
2242
|
// before the punch is proven, so using it here would stop the
|
|
2164
2243
|
// offer/punch retry prematurely and deadlock a half-punched pair.
|
|
2165
2244
|
// Treat the UDP path as needing re-punch once it's been quiet for
|
|
2166
|
-
// >
|
|
2167
|
-
//
|
|
2168
|
-
// re-
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
|
|
2245
|
+
// >4s — matched to #sendToFriend's 4s freshness window, so the
|
|
2246
|
+
// moment bulk data starts bridging over the relay we're already
|
|
2247
|
+
// re-offering + re-punching to restore the direct path (e.g. after
|
|
2248
|
+
// a NAT port remap). A healthy path refreshes lastUdpRecvMs every
|
|
2249
|
+
// ~1s via keepalive/data, so this never fires while UDP works.
|
|
2250
|
+
// Relay keepalive — runs even when the direct path is perfectly
|
|
2251
|
+
// healthy, so the TURN relay stays ready as an instant fallback.
|
|
2252
|
+
// Without this the relay's permission (and the candidate exchange)
|
|
2253
|
+
// silently lapse after ~5min and a later direct-path failure falls
|
|
2254
|
+
// all the way back to the slow tcp-relay. Re-send our offer (so the
|
|
2255
|
+
// peer refreshes ITS permission for our relay) and re-permit the
|
|
2256
|
+
// peer's relay on our own allocation. Cheap, every 2min.
|
|
2257
|
+
if (session.established && session.hasTcpRoute) {
|
|
2258
|
+
const RELAY_KEEPALIVE_MS = 120_000;
|
|
2259
|
+
if (now - (session.lastRelayKeepaliveMs ?? 0) > RELAY_KEEPALIVE_MS) {
|
|
2260
|
+
session.lastRelayKeepaliveMs = now;
|
|
2261
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
2262
|
+
if (session.relayRemote && this.#turnClient) {
|
|
2263
|
+
const rr = session.relayRemote;
|
|
2264
|
+
void this.#turnClient
|
|
2265
|
+
.createPermission({ family: 4, address: rr.host, port: rr.port })
|
|
2266
|
+
.then(() => { session.relayPermittedAtMs = Date.now(); })
|
|
2267
|
+
.catch(() => undefined);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 4_000;
|
|
2174
2272
|
if (!udpConfirmed && session.hasTcpRoute) {
|
|
2175
2273
|
// Aggressive re-punch cadence: the relay path is lossy, so a few
|
|
2176
2274
|
// offers may drop before one lands and both sides punch in the
|
|
@@ -2280,11 +2378,42 @@ export class Peer {
|
|
|
2280
2378
|
});
|
|
2281
2379
|
}
|
|
2282
2380
|
}
|
|
2283
|
-
// No active session: try to establish one if we know the friend's
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2286
|
-
|
|
2381
|
+
// No active session: try to establish one if we know the friend's
|
|
2382
|
+
// endpoint. A friend reachable over the TCP relay counts — the cookie
|
|
2383
|
+
// request + handshake go over the relay OOB (the log shows
|
|
2384
|
+
// `cookie_sent udp=0 tcp=1`). Without including hasTcpRoute here, a
|
|
2385
|
+
// relay-only friend whose handshake didn't complete on the first try
|
|
2386
|
+
// (peer's handshake-back was lost) was NEVER retried — the cookie
|
|
2387
|
+
// retry below was gated on a UDP endpoint — so the session stayed
|
|
2388
|
+
// stuck forever at "friend_online but never established". That is the
|
|
2389
|
+
// post-restart reconnection wedge that took down cn/callpass: relay
|
|
2390
|
+
// bridged them, cookie+handshake fired once, didn't complete, and
|
|
2391
|
+
// nothing re-tried it. Counting the relay route lets the retry fire.
|
|
2392
|
+
const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
|
|
2287
2393
|
if (!haveEndpoint) {
|
|
2394
|
+
// No UDP endpoint AND no relay route yet. For a peer whose DHT
|
|
2395
|
+
// self-announce never stored (WSL2, symmetric NAT, restrictive
|
|
2396
|
+
// firewall), neither onion discovery nor UDP punch can ever find
|
|
2397
|
+
// them — the ONLY bootstrap is the TCP relay: ask our relays to
|
|
2398
|
+
// route to the friend's pubkey, and when they're also connected to
|
|
2399
|
+
// a shared relay the pool fires `friendOnline` -> #initiateSession.
|
|
2400
|
+
// requestRoute was only issued once at start(); if the friend or a
|
|
2401
|
+
// relay wasn't connected in that instant it was never retried, so an
|
|
2402
|
+
// accepted friend could sit at "requested" forever (the WSL-client-
|
|
2403
|
+
// never-gets-an-IP bug). Re-assert it here on a cooldown — cheap and
|
|
2404
|
+
// idempotent — so it eventually catches once both ends share a relay.
|
|
2405
|
+
const lastRouteReq = this.#routeRequestCooldown.get(friendId) ?? 0;
|
|
2406
|
+
if (this.#tcpRelays && now - lastRouteReq > 15_000) {
|
|
2407
|
+
try {
|
|
2408
|
+
const pk = base58ToBytes(friend.pubkey);
|
|
2409
|
+
if (pk.length === 32) {
|
|
2410
|
+
this.#tcpRelays.requestRoute(pk);
|
|
2411
|
+
this.#routeRequestCooldown.set(friendId, now);
|
|
2412
|
+
this.#debugLog(`re-requesting relay route for unconnected friend ${friendId}`);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
catch { /* skip malformed pubkey */ }
|
|
2416
|
+
}
|
|
2288
2417
|
const dhtPk = session?.friendDhtPublicKey;
|
|
2289
2418
|
if (dhtPk) {
|
|
2290
2419
|
const found = await this.#discoverAndCacheFriendEndpoint(friendId, dhtPk).catch(() => false);
|
|
@@ -2391,7 +2520,22 @@ export class Peer {
|
|
|
2391
2520
|
let tcpOk = false;
|
|
2392
2521
|
let firstError;
|
|
2393
2522
|
const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
|
|
2394
|
-
|
|
2523
|
+
// "Fresh" = we've received UDP from this peer within the last few
|
|
2524
|
+
// seconds, so the direct endpoint is currently live. When the peer's
|
|
2525
|
+
// NAT remaps its port (observed periodically on residential/cloud
|
|
2526
|
+
// NATs) the old endpoint goes dark and lastUdpRecvMs goes stale.
|
|
2527
|
+
const udpFresh = !!realUdpRemote &&
|
|
2528
|
+
s?.lastUdpRecvMs !== undefined &&
|
|
2529
|
+
Date.now() - s.lastUdpRecvMs < 4_000;
|
|
2530
|
+
// Bulk IP data rides the direct path ONLY while it's fresh. When it
|
|
2531
|
+
// goes stale (likely a NAT remap mid-stream), we do NOT keep firing
|
|
2532
|
+
// into the dead endpoint and we do NOT duplicate onto UDP — we bridge
|
|
2533
|
+
// over the relay until the re-punch restores UDP (which refreshes
|
|
2534
|
+
// lastUdpRecvMs). This converts a multi-second blackout into a few
|
|
2535
|
+
// seconds of higher relay latency. Control packets always probe UDP
|
|
2536
|
+
// (to keep the NAT mapping warm) so we still try UDP for them.
|
|
2537
|
+
const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
|
|
2538
|
+
if (tryUdp && s?.remote) {
|
|
2395
2539
|
try {
|
|
2396
2540
|
await this.#sendPacket(packet, s.remote);
|
|
2397
2541
|
udpOk = true;
|
|
@@ -2400,28 +2544,24 @@ export class Peer {
|
|
|
2400
2544
|
firstError = error;
|
|
2401
2545
|
}
|
|
2402
2546
|
}
|
|
2403
|
-
//
|
|
2404
|
-
//
|
|
2405
|
-
|
|
2406
|
-
// Bulk IP-forwarding data: send UDP-ONLY once the direct path has ever
|
|
2407
|
-
// been confirmed. Never spill onto the relay (its backlog is the
|
|
2408
|
-
// source of the 20s-late duplicates). A transient UDP gap just drops a
|
|
2409
|
-
// few packets; the re-punch loop (every 3s when UDP is quiet >7s)
|
|
2410
|
-
// restores the path, and control traffic on the relay keeps the
|
|
2411
|
-
// session alive meanwhile.
|
|
2412
|
-
if (isBulkData && udpOk && realUdpRemote && udpEverConfirmed) {
|
|
2547
|
+
// Fresh direct path → send over UDP only: no relay fan-out, no
|
|
2548
|
+
// duplicates, no relay backlog. Applies to both data and control.
|
|
2549
|
+
if (udpFresh && udpOk) {
|
|
2413
2550
|
return;
|
|
2414
2551
|
}
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2552
|
+
// Tier 2: TURN relay (tokyo) — a fast (~280ms) stable fallback that
|
|
2553
|
+
// never NAT-flaps. Used when the direct path isn't fresh (remap, loss,
|
|
2554
|
+
// symmetric NAT). For bulk data, once the relay path is *confirmed*
|
|
2555
|
+
// (we've received over it recently) it carries data on its own — no
|
|
2556
|
+
// tcp-relay fan-out, no duplicates. Until confirmed, we still also try
|
|
2557
|
+
// tcp below so a packet isn't lost while the relay path warms up.
|
|
2558
|
+
let relayOk = false;
|
|
2559
|
+
if (s?.relayRemote && s.relayPermitted) {
|
|
2560
|
+
relayOk = this.#sendViaRelay(packet, s.relayRemote);
|
|
2561
|
+
const relayConfirmed = s.lastRelayRecvMs !== undefined && Date.now() - s.lastRelayRecvMs < 8_000;
|
|
2562
|
+
if (isBulkData && relayOk && relayConfirmed) {
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2425
2565
|
}
|
|
2426
2566
|
if (this.#tcpRelays) {
|
|
2427
2567
|
// The TCP relay routes by the friend's DHT pubkey (= the pubkey
|
|
@@ -2443,7 +2583,7 @@ export class Peer {
|
|
|
2443
2583
|
}
|
|
2444
2584
|
}
|
|
2445
2585
|
}
|
|
2446
|
-
if (!udpOk && !tcpOk) {
|
|
2586
|
+
if (!udpOk && !relayOk && !tcpOk) {
|
|
2447
2587
|
throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
|
|
2448
2588
|
}
|
|
2449
2589
|
}
|
|
@@ -2570,7 +2710,12 @@ export class Peer {
|
|
|
2570
2710
|
* Returns undefined if no bootnode answers within the timeout.
|
|
2571
2711
|
*/
|
|
2572
2712
|
async #gatherOwnSrflx() {
|
|
2573
|
-
|
|
2713
|
+
// Short cache: when our NAT remaps the socket's external port, we must
|
|
2714
|
+
// re-learn it quickly so the re-punch offers the *new* endpoint. A
|
|
2715
|
+
// long cache would keep us advertising a dead port for its full
|
|
2716
|
+
// lifetime, stalling recovery. Gather is only called during
|
|
2717
|
+
// (re)establishment/re-punch, so a short cache costs little.
|
|
2718
|
+
const CACHE_MS = 5_000;
|
|
2574
2719
|
const now = Date.now();
|
|
2575
2720
|
if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
|
|
2576
2721
|
return this.#srflxCache.addr;
|
|
@@ -2623,6 +2768,74 @@ export class Peer {
|
|
|
2623
2768
|
* endpoint discovery. Both peers do this symmetrically; on receipt each
|
|
2624
2769
|
* feeds the other's endpoint into endpointCandidates and punches.
|
|
2625
2770
|
*/
|
|
2771
|
+
/**
|
|
2772
|
+
* Lazily allocate our node-level TURN relay on its own dedicated UDP
|
|
2773
|
+
* socket. Returns our public relay address (on the TURN server) which we
|
|
2774
|
+
* advertise to peers so they can reach us via the relay even when direct
|
|
2775
|
+
* UDP can't be punched. Relayed data is injected into #onDatagram tagged
|
|
2776
|
+
* `viaRelay` so it updates the relay freshness, not the direct endpoint.
|
|
2777
|
+
*/
|
|
2778
|
+
async #ensureTurnRelay() {
|
|
2779
|
+
if (this.#ourRelayAddr)
|
|
2780
|
+
return this.#ourRelayAddr;
|
|
2781
|
+
if (this.#turnAllocating)
|
|
2782
|
+
return this.#turnAllocating;
|
|
2783
|
+
this.#turnAllocating = (async () => {
|
|
2784
|
+
for (const srv of TURN_RELAY_SERVERS) {
|
|
2785
|
+
try {
|
|
2786
|
+
const sock = createDgramSocket("udp4");
|
|
2787
|
+
await new Promise((resolve, reject) => {
|
|
2788
|
+
sock.once("error", reject);
|
|
2789
|
+
sock.bind(0, () => {
|
|
2790
|
+
sock.off("error", reject);
|
|
2791
|
+
resolve();
|
|
2792
|
+
});
|
|
2793
|
+
});
|
|
2794
|
+
const client = new TurnClient({
|
|
2795
|
+
sock,
|
|
2796
|
+
creds: { host: srv.host, port: srv.port, realm: "", username: srv.username, password: srv.password }
|
|
2797
|
+
});
|
|
2798
|
+
const alloc = await client.allocate();
|
|
2799
|
+
client.onData((peer, data) => {
|
|
2800
|
+
// Re-inject relayed payload as if it arrived directly from the
|
|
2801
|
+
// peer's relay address. viaRelay keeps it off the direct path.
|
|
2802
|
+
this.#onDatagram({
|
|
2803
|
+
data: Buffer.from(data),
|
|
2804
|
+
remote: { address: peer.address, port: peer.port },
|
|
2805
|
+
viaRelay: true
|
|
2806
|
+
});
|
|
2807
|
+
});
|
|
2808
|
+
this.#turnSocket = sock;
|
|
2809
|
+
this.#turnClient = client;
|
|
2810
|
+
this.#ourRelayAddr = { host: alloc.relayedAddress.address, port: alloc.relayedAddress.port };
|
|
2811
|
+
this.#debugLog(`turn relay allocated on ${srv.host}: ${this.#ourRelayAddr.host}:${this.#ourRelayAddr.port}`);
|
|
2812
|
+
return this.#ourRelayAddr;
|
|
2813
|
+
}
|
|
2814
|
+
catch (error) {
|
|
2815
|
+
this.#debugLog(`turn relay allocate failed on ${srv.host}: ${error.message}`);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
return undefined;
|
|
2819
|
+
})();
|
|
2820
|
+
const result = await this.#turnAllocating;
|
|
2821
|
+
this.#turnAllocating = undefined;
|
|
2822
|
+
return result;
|
|
2823
|
+
}
|
|
2824
|
+
/** Wrap + send a packet to a peer's TURN relay address via our own
|
|
2825
|
+
* allocation. Mirrors #sendPacket's carrier-magic framing so the peer's
|
|
2826
|
+
* #onDatagram processes it identically to a direct datagram. */
|
|
2827
|
+
#sendViaRelay(packet, relay) {
|
|
2828
|
+
if (!this.#turnClient)
|
|
2829
|
+
return false;
|
|
2830
|
+
const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
|
|
2831
|
+
try {
|
|
2832
|
+
this.#turnClient.sendTo({ family: 4, address: relay.host, port: relay.port }, wrapped);
|
|
2833
|
+
return true;
|
|
2834
|
+
}
|
|
2835
|
+
catch {
|
|
2836
|
+
return false;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2626
2839
|
async #sendUdpEndpointOffer(friendId) {
|
|
2627
2840
|
const session = this.#friendSessions.get(friendId);
|
|
2628
2841
|
if (!session?.established)
|
|
@@ -2633,13 +2846,25 @@ export class Peer {
|
|
|
2633
2846
|
const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
|
|
2634
2847
|
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
|
|
2635
2848
|
return;
|
|
2636
|
-
|
|
2849
|
+
// Also advertise our TURN relay address (bytes 6-11) as a second
|
|
2850
|
+
// candidate. Best-effort: if the relay can't be allocated we just send
|
|
2851
|
+
// the 6-byte srflx-only offer (older peers ignore the extra bytes).
|
|
2852
|
+
const relay = await this.#ensureTurnRelay().catch(() => undefined);
|
|
2853
|
+
const relayOctets = relay ? relay.host.split(".").map((s) => parseInt(s, 10)) : undefined;
|
|
2854
|
+
const relayValid = relayOctets && relayOctets.length === 4 && !relayOctets.some((o) => Number.isNaN(o));
|
|
2855
|
+
const payload = new Uint8Array(relayValid ? 12 : 6);
|
|
2637
2856
|
payload.set(octets, 0);
|
|
2638
2857
|
payload[4] = (srflx.port >> 8) & 0xff;
|
|
2639
2858
|
payload[5] = srflx.port & 0xff;
|
|
2859
|
+
if (relayValid && relay) {
|
|
2860
|
+
payload.set(relayOctets, 6);
|
|
2861
|
+
payload[10] = (relay.port >> 8) & 0xff;
|
|
2862
|
+
payload[11] = relay.port & 0xff;
|
|
2863
|
+
}
|
|
2640
2864
|
try {
|
|
2641
2865
|
await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
|
|
2642
|
-
this.#debugLog(`udp-endpoint offer sent to ${friendId}:
|
|
2866
|
+
this.#debugLog(`udp-endpoint offer sent to ${friendId}: direct=${srflx.host}:${srflx.port}` +
|
|
2867
|
+
(relayValid && relay ? ` relay=${relay.host}:${relay.port}` : ""));
|
|
2643
2868
|
}
|
|
2644
2869
|
catch {
|
|
2645
2870
|
// best-effort — the retry loop will try again
|
|
@@ -2663,6 +2888,33 @@ export class Peer {
|
|
|
2663
2888
|
const session = this.#friendSessions.get(friendId);
|
|
2664
2889
|
if (!session)
|
|
2665
2890
|
return;
|
|
2891
|
+
// If the offer carries a TURN relay candidate (bytes 6-11), remember it
|
|
2892
|
+
// and permit it on our own allocation so data can flow peer→relay→us.
|
|
2893
|
+
if (payload.length >= 12) {
|
|
2894
|
+
const relayHost = `${payload[6]}.${payload[7]}.${payload[8]}.${payload[9]}`;
|
|
2895
|
+
const relayPort = ((payload[10] << 8) | payload[11]) >>> 0;
|
|
2896
|
+
if (relayPort !== 0) {
|
|
2897
|
+
const changed = session.relayRemote?.host !== relayHost || session.relayRemote?.port !== relayPort;
|
|
2898
|
+
session.relayRemote = { host: relayHost, port: relayPort };
|
|
2899
|
+
if (changed)
|
|
2900
|
+
session.relayPermitted = false;
|
|
2901
|
+
// (Re)create the permission on first sight, on address change, or
|
|
2902
|
+
// when the existing one is aging toward coturn's ~5min expiry.
|
|
2903
|
+
const permitAgeMs = Date.now() - (session.relayPermittedAtMs ?? 0);
|
|
2904
|
+
if (!session.relayPermitted || permitAgeMs > 90_000) {
|
|
2905
|
+
void this.#ensureTurnRelay()
|
|
2906
|
+
.then(() => this.#turnClient?.createPermission({ family: 4, address: relayHost, port: relayPort }))
|
|
2907
|
+
.then(() => {
|
|
2908
|
+
const first = !session.relayPermitted;
|
|
2909
|
+
session.relayPermitted = true;
|
|
2910
|
+
session.relayPermittedAtMs = Date.now();
|
|
2911
|
+
if (first)
|
|
2912
|
+
this.#debugLog(`turn permission created for ${friendId} relay ${relayHost}:${relayPort}`);
|
|
2913
|
+
})
|
|
2914
|
+
.catch(() => undefined);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2666
2918
|
// Don't punch toward ourselves.
|
|
2667
2919
|
if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
|
|
2668
2920
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
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",
|