@decentnetwork/peer 0.1.17 → 0.1.19
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/ice-lite.d.ts +70 -0
- package/dist/ice-lite.js +306 -0
- package/dist/peer.js +471 -18
- package/dist/stun.d.ts +117 -0
- package/dist/stun.js +304 -0
- package/dist/transport/udp.d.ts +29 -0
- package/dist/transport/udp.js +64 -0
- package/dist/turn-creds.d.ts +38 -0
- package/dist/turn-creds.js +49 -0
- package/dist/turn.d.ts +56 -0
- package/dist/turn.js +275 -0
- package/package.json +1 -1
package/dist/peer.js
CHANGED
|
@@ -16,6 +16,16 @@ import { loadOrCreateKeyPair } from "./crypto/keypair.js";
|
|
|
16
16
|
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
|
+
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
|
+
];
|
|
19
29
|
const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
|
|
20
30
|
const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
|
|
21
31
|
// How many in-flight announce step1 requests to fan out at once. Toxcore
|
|
@@ -115,11 +125,28 @@ const PACKET_ID_USERSTATUS = 50; // friend's user status (1 byte enum)
|
|
|
115
125
|
const PACKET_ID_TYPING = 51; // typing indicator (1 byte bool)
|
|
116
126
|
const PACKET_ID_MESSAGE = 64; // text message (Carrier FlatBuffers payload)
|
|
117
127
|
const PACKET_ID_ACTION = 65; // /me action message
|
|
128
|
+
// Custom lossless range (toxcore reserves 160-191 for custom lossless
|
|
129
|
+
// packets). We use 160 to carry a peer's server-reflexive UDP endpoint
|
|
130
|
+
// so the other side can hole-punch directly — the job onion-announce was
|
|
131
|
+
// supposed to do but can't against the live bootnodes. Peers that don't
|
|
132
|
+
// understand this ID simply fall through and ignore it. Payload is 6
|
|
133
|
+
// bytes: 4-byte IPv4 + 2-byte big-endian port.
|
|
134
|
+
const PACKET_ID_UDP_ENDPOINT = 160;
|
|
118
135
|
export class Peer {
|
|
119
136
|
#opts;
|
|
120
137
|
#events = new EventEmitter();
|
|
121
138
|
#keyPair;
|
|
122
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;
|
|
123
150
|
#bootstrap;
|
|
124
151
|
#dht = new LegacyDhtClient();
|
|
125
152
|
#knownNodes;
|
|
@@ -192,6 +219,11 @@ export class Peer {
|
|
|
192
219
|
// #initiateSession every few seconds for every friend; without this
|
|
193
220
|
// dedupe, lower-pubkey side floods the log with "deferring to peer".
|
|
194
221
|
#initiateSkipLogged = new Set();
|
|
222
|
+
// Cached server-reflexive (public) UDP endpoint of our #udp socket,
|
|
223
|
+
// learned via STUN. Cone-NAT mappings are stable for the socket
|
|
224
|
+
// lifetime, so we only re-probe every ~60s. Used to tell friends where
|
|
225
|
+
// to hole-punch us (PACKET_ID_UDP_ENDPOINT).
|
|
226
|
+
#srflxCache;
|
|
195
227
|
// Per-friend tracking sets so we send our nickname/status/greeting once
|
|
196
228
|
// per session rather than on every PACKET_ID_ONLINE arrival.
|
|
197
229
|
#profileSentTo = new Set();
|
|
@@ -385,6 +417,17 @@ export class Peer {
|
|
|
385
417
|
}
|
|
386
418
|
this.#udp.off("datagram", this.#onDatagram);
|
|
387
419
|
await this.#udp.stop();
|
|
420
|
+
// Release the TURN allocation + its dedicated socket.
|
|
421
|
+
try {
|
|
422
|
+
this.#turnClient?.close();
|
|
423
|
+
this.#turnSocket?.close();
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// best-effort
|
|
427
|
+
}
|
|
428
|
+
this.#turnClient = undefined;
|
|
429
|
+
this.#turnSocket = undefined;
|
|
430
|
+
this.#ourRelayAddr = undefined;
|
|
388
431
|
this.#started = false;
|
|
389
432
|
}
|
|
390
433
|
pubkey() {
|
|
@@ -880,7 +923,7 @@ export class Peer {
|
|
|
880
923
|
#remoteIsTcp(remote) {
|
|
881
924
|
return remote.address.startsWith("tcp:");
|
|
882
925
|
}
|
|
883
|
-
#onDatagram = ({ data, remote }) => {
|
|
926
|
+
#onDatagram = ({ data, remote, viaRelay }) => {
|
|
884
927
|
if (!this.#keyPair) {
|
|
885
928
|
return;
|
|
886
929
|
}
|
|
@@ -1178,13 +1221,26 @@ export class Peer {
|
|
|
1178
1221
|
isNewSession &&
|
|
1179
1222
|
state.sessionEstablishedAtMs !== undefined) {
|
|
1180
1223
|
const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
|
|
1181
|
-
if
|
|
1224
|
+
// Only reject the fresh handshake if our CURRENT session is
|
|
1225
|
+
// actually alive — i.e. carrying data. A wedged "established"
|
|
1226
|
+
// session that's receiving nothing must NOT block a re-handshake,
|
|
1227
|
+
// or a peer that dropped + restarted can never reconnect: it keeps
|
|
1228
|
+
// sending fresh handshakes and we keep rejecting them against a
|
|
1229
|
+
// corpse. (This is the all-exits-outage bug: mac held a dead
|
|
1230
|
+
// "established" session for cn and ignored every reconnect
|
|
1231
|
+
// attempt.) If we haven't received a packet within FRIEND_TIMEOUT_MS
|
|
1232
|
+
// the current session is dead — fall through and accept the new one.
|
|
1233
|
+
const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
|
|
1234
|
+
const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
|
|
1235
|
+
if (sinceEstablished > 1000 && currentSessionAlive) {
|
|
1182
1236
|
// Verbose only — peers retransmit handshakes aggressively, so
|
|
1183
1237
|
// this line fires dozens of times per minute on a stuck pair
|
|
1184
1238
|
// and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
|
|
1185
|
-
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on established session, ${sinceEstablished}ms after establish)`);
|
|
1239
|
+
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
|
|
1186
1240
|
return;
|
|
1187
1241
|
}
|
|
1242
|
+
this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
|
|
1243
|
+
`lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
|
|
1188
1244
|
}
|
|
1189
1245
|
state.peerSessionPublicKey = hs.sessionPublicKey;
|
|
1190
1246
|
state.peerBaseNonce = hs.baseNonce.slice();
|
|
@@ -1212,6 +1268,15 @@ export class Peer {
|
|
|
1212
1268
|
this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
|
|
1213
1269
|
if (!wasEstablished) {
|
|
1214
1270
|
this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
|
|
1271
|
+
// If we only have a TCP-relay path, immediately tell the peer our
|
|
1272
|
+
// public UDP endpoint so they can hole-punch — don't wait for the
|
|
1273
|
+
// 15s UDP-retry loop, which can miss a flapping relay session's
|
|
1274
|
+
// short-lived establishment window. Both sides do this on every
|
|
1275
|
+
// (re)establishment, so a brief tcp-relay window is enough to
|
|
1276
|
+
// bootstrap the direct UDP path. See docs/UDP-DIRECT-PLAN.md.
|
|
1277
|
+
if (state.hasTcpRoute && !state.remote) {
|
|
1278
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
1279
|
+
}
|
|
1215
1280
|
}
|
|
1216
1281
|
const friend = this.#friends.get(friendId);
|
|
1217
1282
|
if (friend) {
|
|
@@ -1307,8 +1372,21 @@ export class Peer {
|
|
|
1307
1372
|
if (this.#remoteIsTcp(remote)) {
|
|
1308
1373
|
state.hasTcpRoute = true;
|
|
1309
1374
|
}
|
|
1375
|
+
else if (viaRelay) {
|
|
1376
|
+
// Arrived over the TURN relay. Track relay freshness separately;
|
|
1377
|
+
// do NOT set state.remote (that's the direct endpoint). The relay
|
|
1378
|
+
// address it came from is already in state.relayRemote.
|
|
1379
|
+
if (!state.lastRelayRecvMs) {
|
|
1380
|
+
this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
|
|
1381
|
+
}
|
|
1382
|
+
state.lastRelayRecvMs = Date.now();
|
|
1383
|
+
}
|
|
1310
1384
|
else {
|
|
1311
1385
|
state.remote = { host: remote.address, port: remote.port };
|
|
1386
|
+
if (!state.lastUdpRecvMs) {
|
|
1387
|
+
this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
|
|
1388
|
+
}
|
|
1389
|
+
state.lastUdpRecvMs = Date.now();
|
|
1312
1390
|
}
|
|
1313
1391
|
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
1314
1392
|
const kind = opened.payload[0];
|
|
@@ -1339,6 +1417,11 @@ export class Peer {
|
|
|
1339
1417
|
this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
|
|
1340
1418
|
return;
|
|
1341
1419
|
}
|
|
1420
|
+
if (kind === PACKET_ID_UDP_ENDPOINT) {
|
|
1421
|
+
// Peer told us their public UDP endpoint — hole-punch to it.
|
|
1422
|
+
this.#handleUdpEndpointOffer(friendId, inner);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1342
1425
|
if (kind === PACKET_ID_REQUEST) {
|
|
1343
1426
|
// Lossless retransmission request from peer. Full reliable stream
|
|
1344
1427
|
// not yet implemented in JS port, so just acknowledge by logging.
|
|
@@ -2091,10 +2174,33 @@ export class Peer {
|
|
|
2091
2174
|
const session = this.#friendSessions.get(friendId);
|
|
2092
2175
|
// Established session: keepalive + timeout monitoring
|
|
2093
2176
|
if (session?.established) {
|
|
2094
|
-
|
|
2095
|
-
|
|
2177
|
+
// Liveness: time out a session that hasn't received ANY packet
|
|
2178
|
+
// within FRIEND_TIMEOUT_MS — measured from the last received ping
|
|
2179
|
+
// OR, if we've never received one, from when the session was
|
|
2180
|
+
// established. The previous `lastPingRecvMs &&` guard skipped the
|
|
2181
|
+
// check entirely when lastPingRecvMs was undefined, so a session
|
|
2182
|
+
// that established but never carried a single packet (the path
|
|
2183
|
+
// died at/just-after handshake) lived forever reporting
|
|
2184
|
+
// transport=both while IP forwarding was 100% loss, and never tore
|
|
2185
|
+
// down to re-handshake. Tearing it down here drops it to the
|
|
2186
|
+
// non-established branch below, which re-initiates a fresh
|
|
2187
|
+
// connection — turning a permanent wedge into a ~timeout self-heal.
|
|
2188
|
+
const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
|
|
2189
|
+
if (now - lastAlive > FRIEND_TIMEOUT_MS) {
|
|
2190
|
+
this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
|
|
2191
|
+
`lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
|
|
2096
2192
|
this.#friendSessions.delete(friendId);
|
|
2097
2193
|
this.#setFriendOffline(friendId);
|
|
2194
|
+
// Re-assert the relay route so the friend-connection bootstrap
|
|
2195
|
+
// (CONNECTION_NOTIFICATION -> #initiateSession) can re-fire on
|
|
2196
|
+
// re-establishment. requestRoute is idempotent, so this is a
|
|
2197
|
+
// no-op if the route is still live.
|
|
2198
|
+
try {
|
|
2199
|
+
const pk = base58ToBytes(friend.pubkey);
|
|
2200
|
+
if (pk.length === 32)
|
|
2201
|
+
this.#tcpRelays?.requestRoute(pk);
|
|
2202
|
+
}
|
|
2203
|
+
catch { /* skip malformed */ }
|
|
2098
2204
|
continue;
|
|
2099
2205
|
}
|
|
2100
2206
|
if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
|
|
@@ -2125,14 +2231,55 @@ export class Peer {
|
|
|
2125
2231
|
// with the real endpoint and #sendMessengerPacket picks UDP
|
|
2126
2232
|
// over TCP from then on. Throttled to UDP_RETRY_INTERVAL_MS so
|
|
2127
2233
|
// the loop's 250ms tick doesn't flood DHT lookups.
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2234
|
+
// Gate on whether direct UDP is *confirmed* (we've actually
|
|
2235
|
+
// received a packet over a real UDP endpoint recently), NOT on
|
|
2236
|
+
// whether session.remote happens to hold a UDP-shaped value —
|
|
2237
|
+
// #handleUdpEndpointOffer sets session.remote optimistically
|
|
2238
|
+
// before the punch is proven, so using it here would stop the
|
|
2239
|
+
// offer/punch retry prematurely and deadlock a half-punched pair.
|
|
2240
|
+
// Treat the UDP path as needing re-punch once it's been quiet for
|
|
2241
|
+
// >4s — matched to #sendToFriend's 4s freshness window, so the
|
|
2242
|
+
// moment bulk data starts bridging over the relay we're already
|
|
2243
|
+
// re-offering + re-punching to restore the direct path (e.g. after
|
|
2244
|
+
// a NAT port remap). A healthy path refreshes lastUdpRecvMs every
|
|
2245
|
+
// ~1s via keepalive/data, so this never fires while UDP works.
|
|
2246
|
+
// Relay keepalive — runs even when the direct path is perfectly
|
|
2247
|
+
// healthy, so the TURN relay stays ready as an instant fallback.
|
|
2248
|
+
// Without this the relay's permission (and the candidate exchange)
|
|
2249
|
+
// silently lapse after ~5min and a later direct-path failure falls
|
|
2250
|
+
// all the way back to the slow tcp-relay. Re-send our offer (so the
|
|
2251
|
+
// peer refreshes ITS permission for our relay) and re-permit the
|
|
2252
|
+
// peer's relay on our own allocation. Cheap, every 2min.
|
|
2253
|
+
if (session.established && session.hasTcpRoute) {
|
|
2254
|
+
const RELAY_KEEPALIVE_MS = 120_000;
|
|
2255
|
+
if (now - (session.lastRelayKeepaliveMs ?? 0) > RELAY_KEEPALIVE_MS) {
|
|
2256
|
+
session.lastRelayKeepaliveMs = now;
|
|
2257
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
2258
|
+
if (session.relayRemote && this.#turnClient) {
|
|
2259
|
+
const rr = session.relayRemote;
|
|
2260
|
+
void this.#turnClient
|
|
2261
|
+
.createPermission({ family: 4, address: rr.host, port: rr.port })
|
|
2262
|
+
.then(() => { session.relayPermittedAtMs = Date.now(); })
|
|
2263
|
+
.catch(() => undefined);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 4_000;
|
|
2268
|
+
if (!udpConfirmed && session.hasTcpRoute) {
|
|
2269
|
+
// Aggressive re-punch cadence: the relay path is lossy, so a few
|
|
2270
|
+
// offers may drop before one lands and both sides punch in the
|
|
2271
|
+
// same window. 3s keeps recovery snappy without flooding.
|
|
2272
|
+
const UDP_RETRY_INTERVAL_MS = 3_000;
|
|
2133
2273
|
const lastTry = session.lastUdpRetryMs ?? 0;
|
|
2134
2274
|
if (now - lastTry > UDP_RETRY_INTERVAL_MS) {
|
|
2135
2275
|
session.lastUdpRetryMs = now;
|
|
2276
|
+
// Out-of-band UDP endpoint exchange (works where the broken
|
|
2277
|
+
// onion-announce DHT discovery below does not): tell the peer
|
|
2278
|
+
// our STUN-learned public endpoint over the messenger channel.
|
|
2279
|
+
// When they do the same, both sides feed each other's endpoint
|
|
2280
|
+
// into endpointCandidates and hole-punch. Cone↔cone gets a
|
|
2281
|
+
// direct ~one-RTT UDP path; see docs/UDP-DIRECT-PLAN.md.
|
|
2282
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
2136
2283
|
// Recompute friend's real public key for the DHT-PK push.
|
|
2137
2284
|
// Same fallback chain as the cooldown branch below.
|
|
2138
2285
|
let friendRealPk = session.friendRealPublicKey;
|
|
@@ -2227,10 +2374,18 @@ export class Peer {
|
|
|
2227
2374
|
});
|
|
2228
2375
|
}
|
|
2229
2376
|
}
|
|
2230
|
-
// No active session: try to establish one if we know the friend's
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2233
|
-
|
|
2377
|
+
// No active session: try to establish one if we know the friend's
|
|
2378
|
+
// endpoint. A friend reachable over the TCP relay counts — the cookie
|
|
2379
|
+
// request + handshake go over the relay OOB (the log shows
|
|
2380
|
+
// `cookie_sent udp=0 tcp=1`). Without including hasTcpRoute here, a
|
|
2381
|
+
// relay-only friend whose handshake didn't complete on the first try
|
|
2382
|
+
// (peer's handshake-back was lost) was NEVER retried — the cookie
|
|
2383
|
+
// retry below was gated on a UDP endpoint — so the session stayed
|
|
2384
|
+
// stuck forever at "friend_online but never established". That is the
|
|
2385
|
+
// post-restart reconnection wedge that took down cn/callpass: relay
|
|
2386
|
+
// bridged them, cookie+handshake fired once, didn't complete, and
|
|
2387
|
+
// nothing re-tried it. Counting the relay route lets the retry fire.
|
|
2388
|
+
const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
|
|
2234
2389
|
if (!haveEndpoint) {
|
|
2235
2390
|
const dhtPk = session?.friendDhtPublicKey;
|
|
2236
2391
|
if (dhtPk) {
|
|
@@ -2312,7 +2467,17 @@ export class Peer {
|
|
|
2312
2467
|
incrementNonce(session.ourBaseNonce);
|
|
2313
2468
|
session.sendPacketNumber = (packetNumber + 1) >>> 0;
|
|
2314
2469
|
session.lastPingSentMs = Date.now();
|
|
2315
|
-
|
|
2470
|
+
// Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
|
|
2471
|
+
// traffic. Once UDP is established it should ride UDP exclusively and
|
|
2472
|
+
// NEVER spill onto the TCP relay during a UDP hiccup — the relay's
|
|
2473
|
+
// China↔US queue backs up 20s+ and every spilled packet returns as a
|
|
2474
|
+
// hugely-delayed duplicate. Control packets (keepalives, endpoint
|
|
2475
|
+
// offers, profile) keep the relay fallback so the session and the
|
|
2476
|
+
// re-punch survive a UDP outage. Brief UDP gaps just drop a few data
|
|
2477
|
+
// packets (the proxied TCP stream retransmits) and the re-punch loop
|
|
2478
|
+
// restores the direct path within a few seconds.
|
|
2479
|
+
const isBulkData = kind === PACKET_ID_MESSAGE;
|
|
2480
|
+
await this.#sendToFriend(friendId, encrypted, session, isBulkData);
|
|
2316
2481
|
}
|
|
2317
2482
|
/**
|
|
2318
2483
|
* Send `packet` to `friendId` over whichever transport(s) are
|
|
@@ -2322,12 +2487,28 @@ export class Peer {
|
|
|
2322
2487
|
* receiving end (toxcore does the same when both transports are up).
|
|
2323
2488
|
* Throws only if zero transports succeeded.
|
|
2324
2489
|
*/
|
|
2325
|
-
async #sendToFriend(friendId, packet, session) {
|
|
2490
|
+
async #sendToFriend(friendId, packet, session, isBulkData = false) {
|
|
2326
2491
|
const s = session ?? this.#friendSessions.get(friendId);
|
|
2327
2492
|
let udpOk = false;
|
|
2328
2493
|
let tcpOk = false;
|
|
2329
2494
|
let firstError;
|
|
2330
|
-
|
|
2495
|
+
const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
|
|
2496
|
+
// "Fresh" = we've received UDP from this peer within the last few
|
|
2497
|
+
// seconds, so the direct endpoint is currently live. When the peer's
|
|
2498
|
+
// NAT remaps its port (observed periodically on residential/cloud
|
|
2499
|
+
// NATs) the old endpoint goes dark and lastUdpRecvMs goes stale.
|
|
2500
|
+
const udpFresh = !!realUdpRemote &&
|
|
2501
|
+
s?.lastUdpRecvMs !== undefined &&
|
|
2502
|
+
Date.now() - s.lastUdpRecvMs < 4_000;
|
|
2503
|
+
// Bulk IP data rides the direct path ONLY while it's fresh. When it
|
|
2504
|
+
// goes stale (likely a NAT remap mid-stream), we do NOT keep firing
|
|
2505
|
+
// into the dead endpoint and we do NOT duplicate onto UDP — we bridge
|
|
2506
|
+
// over the relay until the re-punch restores UDP (which refreshes
|
|
2507
|
+
// lastUdpRecvMs). This converts a multi-second blackout into a few
|
|
2508
|
+
// seconds of higher relay latency. Control packets always probe UDP
|
|
2509
|
+
// (to keep the NAT mapping warm) so we still try UDP for them.
|
|
2510
|
+
const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
|
|
2511
|
+
if (tryUdp && s?.remote) {
|
|
2331
2512
|
try {
|
|
2332
2513
|
await this.#sendPacket(packet, s.remote);
|
|
2333
2514
|
udpOk = true;
|
|
@@ -2336,6 +2517,25 @@ export class Peer {
|
|
|
2336
2517
|
firstError = error;
|
|
2337
2518
|
}
|
|
2338
2519
|
}
|
|
2520
|
+
// Fresh direct path → send over UDP only: no relay fan-out, no
|
|
2521
|
+
// duplicates, no relay backlog. Applies to both data and control.
|
|
2522
|
+
if (udpFresh && udpOk) {
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
// Tier 2: TURN relay (tokyo) — a fast (~280ms) stable fallback that
|
|
2526
|
+
// never NAT-flaps. Used when the direct path isn't fresh (remap, loss,
|
|
2527
|
+
// symmetric NAT). For bulk data, once the relay path is *confirmed*
|
|
2528
|
+
// (we've received over it recently) it carries data on its own — no
|
|
2529
|
+
// tcp-relay fan-out, no duplicates. Until confirmed, we still also try
|
|
2530
|
+
// tcp below so a packet isn't lost while the relay path warms up.
|
|
2531
|
+
let relayOk = false;
|
|
2532
|
+
if (s?.relayRemote && s.relayPermitted) {
|
|
2533
|
+
relayOk = this.#sendViaRelay(packet, s.relayRemote);
|
|
2534
|
+
const relayConfirmed = s.lastRelayRecvMs !== undefined && Date.now() - s.lastRelayRecvMs < 8_000;
|
|
2535
|
+
if (isBulkData && relayOk && relayConfirmed) {
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2339
2539
|
if (this.#tcpRelays) {
|
|
2340
2540
|
// The TCP relay routes by the friend's DHT pubkey (= the pubkey
|
|
2341
2541
|
// they handshook the relay with), not by the friend's real
|
|
@@ -2356,7 +2556,7 @@ export class Peer {
|
|
|
2356
2556
|
}
|
|
2357
2557
|
}
|
|
2358
2558
|
}
|
|
2359
|
-
if (!udpOk && !tcpOk) {
|
|
2559
|
+
if (!udpOk && !relayOk && !tcpOk) {
|
|
2360
2560
|
throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
|
|
2361
2561
|
}
|
|
2362
2562
|
}
|
|
@@ -2473,6 +2673,259 @@ export class Peer {
|
|
|
2473
2673
|
.sort((a, b) => b.updatedMs - a.updatedMs)
|
|
2474
2674
|
.slice(0, 12);
|
|
2475
2675
|
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Learn our own server-reflexive (public) UDP endpoint by sending a
|
|
2678
|
+
* STUN binding-request to a bootnode on its STUN port (3478) over the
|
|
2679
|
+
* SAME #udp socket net_crypto uses — so the reflexive port matches
|
|
2680
|
+
* where our crypto data will actually arrive. Cached briefly because
|
|
2681
|
+
* a cone NAT's mapping is stable for the socket's lifetime.
|
|
2682
|
+
*
|
|
2683
|
+
* Returns undefined if no bootnode answers within the timeout.
|
|
2684
|
+
*/
|
|
2685
|
+
async #gatherOwnSrflx() {
|
|
2686
|
+
// Short cache: when our NAT remaps the socket's external port, we must
|
|
2687
|
+
// re-learn it quickly so the re-punch offers the *new* endpoint. A
|
|
2688
|
+
// long cache would keep us advertising a dead port for its full
|
|
2689
|
+
// lifetime, stalling recovery. Gather is only called during
|
|
2690
|
+
// (re)establishment/re-punch, so a short cache costs little.
|
|
2691
|
+
const CACHE_MS = 5_000;
|
|
2692
|
+
const now = Date.now();
|
|
2693
|
+
if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
|
|
2694
|
+
return this.#srflxCache.addr;
|
|
2695
|
+
}
|
|
2696
|
+
const bootnodes = this.#opts.bootstrapNodes;
|
|
2697
|
+
if (!bootnodes || bootnodes.length === 0)
|
|
2698
|
+
return undefined;
|
|
2699
|
+
// Try up to 3 bootnodes; first answer wins.
|
|
2700
|
+
for (const bn of bootnodes.slice(0, 3)) {
|
|
2701
|
+
const req = buildBindingRequest();
|
|
2702
|
+
const txnHex = Buffer.from(req.slice(8, 20)).toString("hex");
|
|
2703
|
+
const addr = await new Promise((resolve) => {
|
|
2704
|
+
const interceptor = (data, rinfo) => {
|
|
2705
|
+
if (rinfo.address !== bn.host || rinfo.port !== 3478)
|
|
2706
|
+
return false;
|
|
2707
|
+
const msg = decodeStun(data);
|
|
2708
|
+
if (!msg || msg.type !== STUN_BINDING_SUCCESS)
|
|
2709
|
+
return false;
|
|
2710
|
+
if (Buffer.from(msg.transactionId).toString("hex") !== txnHex)
|
|
2711
|
+
return false;
|
|
2712
|
+
const xma = findAttr(msg, STUN_ATTR_XOR_MAPPED_ADDRESS);
|
|
2713
|
+
const parsed = xma ? decodeXorMappedAddress(xma, msg.transactionId) : undefined;
|
|
2714
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2715
|
+
clearTimeout(timer);
|
|
2716
|
+
resolve(parsed ? { host: parsed.address, port: parsed.port } : undefined);
|
|
2717
|
+
return true; // consume — don't let it reach net_crypto/DHT dispatch
|
|
2718
|
+
};
|
|
2719
|
+
const timer = setTimeout(() => {
|
|
2720
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2721
|
+
resolve(undefined);
|
|
2722
|
+
}, 2000);
|
|
2723
|
+
this.#udp.addStunInterceptor(interceptor);
|
|
2724
|
+
this.#udp.sendDirect(Buffer.from(req), bn.host, 3478).catch(() => {
|
|
2725
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2726
|
+
clearTimeout(timer);
|
|
2727
|
+
resolve(undefined);
|
|
2728
|
+
});
|
|
2729
|
+
});
|
|
2730
|
+
if (addr) {
|
|
2731
|
+
this.#srflxCache = { addr, atMs: now };
|
|
2732
|
+
return addr;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
return undefined;
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Tell a friend our public UDP endpoint over the (already-working,
|
|
2739
|
+
* possibly tcp-relay) messenger channel, so they can hole-punch to us.
|
|
2740
|
+
* This is the out-of-band substitute for the broken DHT/onion-announce
|
|
2741
|
+
* endpoint discovery. Both peers do this symmetrically; on receipt each
|
|
2742
|
+
* feeds the other's endpoint into endpointCandidates and punches.
|
|
2743
|
+
*/
|
|
2744
|
+
/**
|
|
2745
|
+
* Lazily allocate our node-level TURN relay on its own dedicated UDP
|
|
2746
|
+
* socket. Returns our public relay address (on the TURN server) which we
|
|
2747
|
+
* advertise to peers so they can reach us via the relay even when direct
|
|
2748
|
+
* UDP can't be punched. Relayed data is injected into #onDatagram tagged
|
|
2749
|
+
* `viaRelay` so it updates the relay freshness, not the direct endpoint.
|
|
2750
|
+
*/
|
|
2751
|
+
async #ensureTurnRelay() {
|
|
2752
|
+
if (this.#ourRelayAddr)
|
|
2753
|
+
return this.#ourRelayAddr;
|
|
2754
|
+
if (this.#turnAllocating)
|
|
2755
|
+
return this.#turnAllocating;
|
|
2756
|
+
this.#turnAllocating = (async () => {
|
|
2757
|
+
for (const srv of TURN_RELAY_SERVERS) {
|
|
2758
|
+
try {
|
|
2759
|
+
const sock = createDgramSocket("udp4");
|
|
2760
|
+
await new Promise((resolve, reject) => {
|
|
2761
|
+
sock.once("error", reject);
|
|
2762
|
+
sock.bind(0, () => {
|
|
2763
|
+
sock.off("error", reject);
|
|
2764
|
+
resolve();
|
|
2765
|
+
});
|
|
2766
|
+
});
|
|
2767
|
+
const client = new TurnClient({
|
|
2768
|
+
sock,
|
|
2769
|
+
creds: { host: srv.host, port: srv.port, realm: "", username: srv.username, password: srv.password }
|
|
2770
|
+
});
|
|
2771
|
+
const alloc = await client.allocate();
|
|
2772
|
+
client.onData((peer, data) => {
|
|
2773
|
+
// Re-inject relayed payload as if it arrived directly from the
|
|
2774
|
+
// peer's relay address. viaRelay keeps it off the direct path.
|
|
2775
|
+
this.#onDatagram({
|
|
2776
|
+
data: Buffer.from(data),
|
|
2777
|
+
remote: { address: peer.address, port: peer.port },
|
|
2778
|
+
viaRelay: true
|
|
2779
|
+
});
|
|
2780
|
+
});
|
|
2781
|
+
this.#turnSocket = sock;
|
|
2782
|
+
this.#turnClient = client;
|
|
2783
|
+
this.#ourRelayAddr = { host: alloc.relayedAddress.address, port: alloc.relayedAddress.port };
|
|
2784
|
+
this.#debugLog(`turn relay allocated on ${srv.host}: ${this.#ourRelayAddr.host}:${this.#ourRelayAddr.port}`);
|
|
2785
|
+
return this.#ourRelayAddr;
|
|
2786
|
+
}
|
|
2787
|
+
catch (error) {
|
|
2788
|
+
this.#debugLog(`turn relay allocate failed on ${srv.host}: ${error.message}`);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
return undefined;
|
|
2792
|
+
})();
|
|
2793
|
+
const result = await this.#turnAllocating;
|
|
2794
|
+
this.#turnAllocating = undefined;
|
|
2795
|
+
return result;
|
|
2796
|
+
}
|
|
2797
|
+
/** Wrap + send a packet to a peer's TURN relay address via our own
|
|
2798
|
+
* allocation. Mirrors #sendPacket's carrier-magic framing so the peer's
|
|
2799
|
+
* #onDatagram processes it identically to a direct datagram. */
|
|
2800
|
+
#sendViaRelay(packet, relay) {
|
|
2801
|
+
if (!this.#turnClient)
|
|
2802
|
+
return false;
|
|
2803
|
+
const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
|
|
2804
|
+
try {
|
|
2805
|
+
this.#turnClient.sendTo({ family: 4, address: relay.host, port: relay.port }, wrapped);
|
|
2806
|
+
return true;
|
|
2807
|
+
}
|
|
2808
|
+
catch {
|
|
2809
|
+
return false;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
async #sendUdpEndpointOffer(friendId) {
|
|
2813
|
+
const session = this.#friendSessions.get(friendId);
|
|
2814
|
+
if (!session?.established)
|
|
2815
|
+
return;
|
|
2816
|
+
const srflx = await this.#gatherOwnSrflx();
|
|
2817
|
+
if (!srflx)
|
|
2818
|
+
return;
|
|
2819
|
+
const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
|
|
2820
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
|
|
2821
|
+
return;
|
|
2822
|
+
// Also advertise our TURN relay address (bytes 6-11) as a second
|
|
2823
|
+
// candidate. Best-effort: if the relay can't be allocated we just send
|
|
2824
|
+
// the 6-byte srflx-only offer (older peers ignore the extra bytes).
|
|
2825
|
+
const relay = await this.#ensureTurnRelay().catch(() => undefined);
|
|
2826
|
+
const relayOctets = relay ? relay.host.split(".").map((s) => parseInt(s, 10)) : undefined;
|
|
2827
|
+
const relayValid = relayOctets && relayOctets.length === 4 && !relayOctets.some((o) => Number.isNaN(o));
|
|
2828
|
+
const payload = new Uint8Array(relayValid ? 12 : 6);
|
|
2829
|
+
payload.set(octets, 0);
|
|
2830
|
+
payload[4] = (srflx.port >> 8) & 0xff;
|
|
2831
|
+
payload[5] = srflx.port & 0xff;
|
|
2832
|
+
if (relayValid && relay) {
|
|
2833
|
+
payload.set(relayOctets, 6);
|
|
2834
|
+
payload[10] = (relay.port >> 8) & 0xff;
|
|
2835
|
+
payload[11] = relay.port & 0xff;
|
|
2836
|
+
}
|
|
2837
|
+
try {
|
|
2838
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
|
|
2839
|
+
this.#debugLog(`udp-endpoint offer sent to ${friendId}: direct=${srflx.host}:${srflx.port}` +
|
|
2840
|
+
(relayValid && relay ? ` relay=${relay.host}:${relay.port}` : ""));
|
|
2841
|
+
}
|
|
2842
|
+
catch {
|
|
2843
|
+
// best-effort — the retry loop will try again
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* A friend told us their public UDP endpoint. Feed it as a high-value
|
|
2848
|
+
* candidate and immediately spray a few raw punch datagrams at it to
|
|
2849
|
+
* open our own cone-NAT mapping toward their address:port, then kick a
|
|
2850
|
+
* session initiation (the higher-pubkey side sends the cookie request;
|
|
2851
|
+
* both sides' mappings are now open so it gets through). Mirrors the
|
|
2852
|
+
* proven standalone hole-punch.
|
|
2853
|
+
*/
|
|
2854
|
+
#handleUdpEndpointOffer(friendId, payload) {
|
|
2855
|
+
if (payload.length < 6)
|
|
2856
|
+
return;
|
|
2857
|
+
const host = `${payload[0]}.${payload[1]}.${payload[2]}.${payload[3]}`;
|
|
2858
|
+
const port = ((payload[4] << 8) | payload[5]) >>> 0;
|
|
2859
|
+
if (port === 0)
|
|
2860
|
+
return;
|
|
2861
|
+
const session = this.#friendSessions.get(friendId);
|
|
2862
|
+
if (!session)
|
|
2863
|
+
return;
|
|
2864
|
+
// If the offer carries a TURN relay candidate (bytes 6-11), remember it
|
|
2865
|
+
// and permit it on our own allocation so data can flow peer→relay→us.
|
|
2866
|
+
if (payload.length >= 12) {
|
|
2867
|
+
const relayHost = `${payload[6]}.${payload[7]}.${payload[8]}.${payload[9]}`;
|
|
2868
|
+
const relayPort = ((payload[10] << 8) | payload[11]) >>> 0;
|
|
2869
|
+
if (relayPort !== 0) {
|
|
2870
|
+
const changed = session.relayRemote?.host !== relayHost || session.relayRemote?.port !== relayPort;
|
|
2871
|
+
session.relayRemote = { host: relayHost, port: relayPort };
|
|
2872
|
+
if (changed)
|
|
2873
|
+
session.relayPermitted = false;
|
|
2874
|
+
// (Re)create the permission on first sight, on address change, or
|
|
2875
|
+
// when the existing one is aging toward coturn's ~5min expiry.
|
|
2876
|
+
const permitAgeMs = Date.now() - (session.relayPermittedAtMs ?? 0);
|
|
2877
|
+
if (!session.relayPermitted || permitAgeMs > 90_000) {
|
|
2878
|
+
void this.#ensureTurnRelay()
|
|
2879
|
+
.then(() => this.#turnClient?.createPermission({ family: 4, address: relayHost, port: relayPort }))
|
|
2880
|
+
.then(() => {
|
|
2881
|
+
const first = !session.relayPermitted;
|
|
2882
|
+
session.relayPermitted = true;
|
|
2883
|
+
session.relayPermittedAtMs = Date.now();
|
|
2884
|
+
if (first)
|
|
2885
|
+
this.#debugLog(`turn permission created for ${friendId} relay ${relayHost}:${relayPort}`);
|
|
2886
|
+
})
|
|
2887
|
+
.catch(() => undefined);
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
// Don't punch toward ourselves.
|
|
2892
|
+
if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
|
|
2893
|
+
return;
|
|
2894
|
+
this.#rememberEndpointCandidate(session, host, port);
|
|
2895
|
+
this.#debugLog(`udp-endpoint offer from ${friendId}: ${host}:${port} — punching`);
|
|
2896
|
+
// Spray a handful of tiny punch datagrams (0xF2 is not a recognized
|
|
2897
|
+
// Carrier/DHT/net_crypto packet type, so the peer's #onDatagram
|
|
2898
|
+
// ignores them — their only purpose is opening our NAT mapping
|
|
2899
|
+
// toward host:port so the peer's crypto data can reach us).
|
|
2900
|
+
const punch = Uint8Array.of(0xf2);
|
|
2901
|
+
let n = 0;
|
|
2902
|
+
const punchTimer = setInterval(() => {
|
|
2903
|
+
this.#udp.sendDirectSync(Buffer.from(punch), host, port);
|
|
2904
|
+
if (++n >= 6)
|
|
2905
|
+
clearInterval(punchTimer);
|
|
2906
|
+
}, 120);
|
|
2907
|
+
// Point this session's UDP remote at the peer's reflexive endpoint
|
|
2908
|
+
// (unless we already have a confirmed real UDP remote). hasTcpRoute
|
|
2909
|
+
// stays true, so #sendToFriend fans crypto data out over BOTH UDP
|
|
2910
|
+
// and TCP relay: TCP keeps the session alive even if the punch
|
|
2911
|
+
// fails, while every UDP-bound keepalive/packet that makes it
|
|
2912
|
+
// through the freshly-punched hole causes the peer's CRYPTO_DATA
|
|
2913
|
+
// handler to set THEIR remote to our endpoint (peer.ts ~1564) —
|
|
2914
|
+
// upgrading the path to direct UDP in both directions with no new
|
|
2915
|
+
// handshake. If UDP never works, the periodic offer re-punches.
|
|
2916
|
+
const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
|
|
2917
|
+
if (!haveRealUdp) {
|
|
2918
|
+
session.remote = { host, port };
|
|
2919
|
+
}
|
|
2920
|
+
// Nudge a keepalive out immediately so the upgrade doesn't wait for
|
|
2921
|
+
// the next periodic ALIVE — fans out over UDP (punched) + TCP.
|
|
2922
|
+
if (session.established) {
|
|
2923
|
+
void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
|
|
2924
|
+
}
|
|
2925
|
+
else {
|
|
2926
|
+
void this.#initiateSession(friendId).catch(() => undefined);
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2476
2929
|
#collectSessionEndpointCandidates(friendId, friend, session) {
|
|
2477
2930
|
// Three buckets: same-LAN private (highest priority for direct UDP),
|
|
2478
2931
|
// public/routable, and other private (different LAN, last-resort).
|