@decentnetwork/peer 0.1.16 → 0.1.18
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 +257 -25
- 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,7 @@ 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";
|
|
19
20
|
const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
|
|
20
21
|
const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
|
|
21
22
|
// How many in-flight announce step1 requests to fan out at once. Toxcore
|
|
@@ -115,6 +116,13 @@ const PACKET_ID_USERSTATUS = 50; // friend's user status (1 byte enum)
|
|
|
115
116
|
const PACKET_ID_TYPING = 51; // typing indicator (1 byte bool)
|
|
116
117
|
const PACKET_ID_MESSAGE = 64; // text message (Carrier FlatBuffers payload)
|
|
117
118
|
const PACKET_ID_ACTION = 65; // /me action message
|
|
119
|
+
// Custom lossless range (toxcore reserves 160-191 for custom lossless
|
|
120
|
+
// packets). We use 160 to carry a peer's server-reflexive UDP endpoint
|
|
121
|
+
// so the other side can hole-punch directly — the job onion-announce was
|
|
122
|
+
// supposed to do but can't against the live bootnodes. Peers that don't
|
|
123
|
+
// understand this ID simply fall through and ignore it. Payload is 6
|
|
124
|
+
// bytes: 4-byte IPv4 + 2-byte big-endian port.
|
|
125
|
+
const PACKET_ID_UDP_ENDPOINT = 160;
|
|
118
126
|
export class Peer {
|
|
119
127
|
#opts;
|
|
120
128
|
#events = new EventEmitter();
|
|
@@ -192,6 +200,11 @@ export class Peer {
|
|
|
192
200
|
// #initiateSession every few seconds for every friend; without this
|
|
193
201
|
// dedupe, lower-pubkey side floods the log with "deferring to peer".
|
|
194
202
|
#initiateSkipLogged = new Set();
|
|
203
|
+
// Cached server-reflexive (public) UDP endpoint of our #udp socket,
|
|
204
|
+
// learned via STUN. Cone-NAT mappings are stable for the socket
|
|
205
|
+
// lifetime, so we only re-probe every ~60s. Used to tell friends where
|
|
206
|
+
// to hole-punch us (PACKET_ID_UDP_ENDPOINT).
|
|
207
|
+
#srflxCache;
|
|
195
208
|
// Per-friend tracking sets so we send our nickname/status/greeting once
|
|
196
209
|
// per session rather than on every PACKET_ID_ONLINE arrival.
|
|
197
210
|
#profileSentTo = new Set();
|
|
@@ -889,14 +902,16 @@ export class Peer {
|
|
|
889
902
|
return;
|
|
890
903
|
}
|
|
891
904
|
this.#tracePacket("rx", packet, { host: remote.address, port: remote.port });
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
905
|
+
// Debug: log every inbound UDP datagram (gated on DECENT_ANNOUNCE_DEBUG)
|
|
906
|
+
// so we can see what bootstraps reply with. Off by default.
|
|
907
|
+
if (process.env.DECENT_ANNOUNCE_DEBUG === "1") {
|
|
908
|
+
import("fs").then((fs) => {
|
|
909
|
+
try {
|
|
910
|
+
fs.appendFileSync("/tmp/decent-udp-rx.log", `${new Date().toISOString()} from=${remote.address}:${remote.port} firstByte=0x${packet[0]?.toString(16) ?? "??"} len=${packet.length}\n`);
|
|
911
|
+
}
|
|
912
|
+
catch { /* best-effort */ }
|
|
913
|
+
}).catch(() => { });
|
|
914
|
+
}
|
|
900
915
|
if (packet[0] === NET_PACKET_CRYPTO) {
|
|
901
916
|
const opened = openToxDhtCryptoRequest(packet, this.#keyPair);
|
|
902
917
|
if (!opened) {
|
|
@@ -1210,6 +1225,15 @@ export class Peer {
|
|
|
1210
1225
|
this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
|
|
1211
1226
|
if (!wasEstablished) {
|
|
1212
1227
|
this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
|
|
1228
|
+
// If we only have a TCP-relay path, immediately tell the peer our
|
|
1229
|
+
// public UDP endpoint so they can hole-punch — don't wait for the
|
|
1230
|
+
// 15s UDP-retry loop, which can miss a flapping relay session's
|
|
1231
|
+
// short-lived establishment window. Both sides do this on every
|
|
1232
|
+
// (re)establishment, so a brief tcp-relay window is enough to
|
|
1233
|
+
// bootstrap the direct UDP path. See docs/UDP-DIRECT-PLAN.md.
|
|
1234
|
+
if (state.hasTcpRoute && !state.remote) {
|
|
1235
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
1236
|
+
}
|
|
1213
1237
|
}
|
|
1214
1238
|
const friend = this.#friends.get(friendId);
|
|
1215
1239
|
if (friend) {
|
|
@@ -1307,6 +1331,10 @@ export class Peer {
|
|
|
1307
1331
|
}
|
|
1308
1332
|
else {
|
|
1309
1333
|
state.remote = { host: remote.address, port: remote.port };
|
|
1334
|
+
if (!state.lastUdpRecvMs) {
|
|
1335
|
+
this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
|
|
1336
|
+
}
|
|
1337
|
+
state.lastUdpRecvMs = Date.now();
|
|
1310
1338
|
}
|
|
1311
1339
|
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
1312
1340
|
const kind = opened.payload[0];
|
|
@@ -1337,6 +1365,11 @@ export class Peer {
|
|
|
1337
1365
|
this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
|
|
1338
1366
|
return;
|
|
1339
1367
|
}
|
|
1368
|
+
if (kind === PACKET_ID_UDP_ENDPOINT) {
|
|
1369
|
+
// Peer told us their public UDP endpoint — hole-punch to it.
|
|
1370
|
+
this.#handleUdpEndpointOffer(friendId, inner);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1340
1373
|
if (kind === PACKET_ID_REQUEST) {
|
|
1341
1374
|
// Lossless retransmission request from peer. Full reliable stream
|
|
1342
1375
|
// not yet implemented in JS port, so just acknowledge by logging.
|
|
@@ -1987,11 +2020,12 @@ export class Peer {
|
|
|
1987
2020
|
catch { /* best-effort */ }
|
|
1988
2021
|
continue;
|
|
1989
2022
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2023
|
+
if (process.env.DECENT_ANNOUNCE_DEBUG === "1")
|
|
2024
|
+
try {
|
|
2025
|
+
const fs = await import("fs");
|
|
2026
|
+
fs.appendFileSync("/tmp/decent-announce.log", `${new Date().toISOString()} step1 from=${c.node.host}:${c.node.port} isStored=${resp1.isStored}\n`);
|
|
2027
|
+
}
|
|
2028
|
+
catch { /* best-effort */ }
|
|
1995
2029
|
step1Hits.push({ c, resp1 });
|
|
1996
2030
|
}
|
|
1997
2031
|
const step2Settled = await Promise.allSettled(step1Hits.map(({ c, resp1 }) => this.#sendAnnounceAndWait({
|
|
@@ -2011,11 +2045,12 @@ export class Peer {
|
|
|
2011
2045
|
const r2 = step2Settled[j];
|
|
2012
2046
|
const resp2 = r2.status === "fulfilled" ? r2.value : undefined;
|
|
2013
2047
|
const final = resp2 ?? resp1;
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2048
|
+
if (process.env.DECENT_ANNOUNCE_DEBUG === "1")
|
|
2049
|
+
try {
|
|
2050
|
+
const fs = await import("fs");
|
|
2051
|
+
fs.appendFileSync("/tmp/decent-announce.log", `${new Date().toISOString()} step2 from=${c.node.host}:${c.node.port} ${resp2 ? "isStored=" + resp2.isStored : "NO_RESPONSE"}\n`);
|
|
2052
|
+
}
|
|
2053
|
+
catch { /* best-effort */ }
|
|
2019
2054
|
if (final.isStored === 2) {
|
|
2020
2055
|
storedNodes.push(c.node);
|
|
2021
2056
|
// Keep dhtHealth.selfAnnounceStoredOn live within the loop,
|
|
@@ -2121,14 +2156,36 @@ export class Peer {
|
|
|
2121
2156
|
// with the real endpoint and #sendMessengerPacket picks UDP
|
|
2122
2157
|
// over TCP from then on. Throttled to UDP_RETRY_INTERVAL_MS so
|
|
2123
2158
|
// the loop's 250ms tick doesn't flood DHT lookups.
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2159
|
+
// Gate on whether direct UDP is *confirmed* (we've actually
|
|
2160
|
+
// received a packet over a real UDP endpoint recently), NOT on
|
|
2161
|
+
// whether session.remote happens to hold a UDP-shaped value —
|
|
2162
|
+
// #handleUdpEndpointOffer sets session.remote optimistically
|
|
2163
|
+
// before the punch is proven, so using it here would stop the
|
|
2164
|
+
// offer/punch retry prematurely and deadlock a half-punched pair.
|
|
2165
|
+
// Treat the UDP path as needing re-punch once it's been quiet for
|
|
2166
|
+
// >7s. This is deliberately close to #sendToFriend's 10s
|
|
2167
|
+
// relay-fallback threshold: if UDP stalls, we want the re-offer +
|
|
2168
|
+
// re-punch to fire almost immediately so the direct path recovers
|
|
2169
|
+
// before much traffic spills onto the slow relay (whose queue can
|
|
2170
|
+
// back up 20s+ on the China↔US leg). A healthy path refreshes
|
|
2171
|
+
// lastUdpRecvMs every ~1s via keepalive/data, so this never fires
|
|
2172
|
+
// while UDP is working.
|
|
2173
|
+
const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 7_000;
|
|
2174
|
+
if (!udpConfirmed && session.hasTcpRoute) {
|
|
2175
|
+
// Aggressive re-punch cadence: the relay path is lossy, so a few
|
|
2176
|
+
// offers may drop before one lands and both sides punch in the
|
|
2177
|
+
// same window. 3s keeps recovery snappy without flooding.
|
|
2178
|
+
const UDP_RETRY_INTERVAL_MS = 3_000;
|
|
2129
2179
|
const lastTry = session.lastUdpRetryMs ?? 0;
|
|
2130
2180
|
if (now - lastTry > UDP_RETRY_INTERVAL_MS) {
|
|
2131
2181
|
session.lastUdpRetryMs = now;
|
|
2182
|
+
// Out-of-band UDP endpoint exchange (works where the broken
|
|
2183
|
+
// onion-announce DHT discovery below does not): tell the peer
|
|
2184
|
+
// our STUN-learned public endpoint over the messenger channel.
|
|
2185
|
+
// When they do the same, both sides feed each other's endpoint
|
|
2186
|
+
// into endpointCandidates and hole-punch. Cone↔cone gets a
|
|
2187
|
+
// direct ~one-RTT UDP path; see docs/UDP-DIRECT-PLAN.md.
|
|
2188
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
2132
2189
|
// Recompute friend's real public key for the DHT-PK push.
|
|
2133
2190
|
// Same fallback chain as the cooldown branch below.
|
|
2134
2191
|
let friendRealPk = session.friendRealPublicKey;
|
|
@@ -2308,7 +2365,17 @@ export class Peer {
|
|
|
2308
2365
|
incrementNonce(session.ourBaseNonce);
|
|
2309
2366
|
session.sendPacketNumber = (packetNumber + 1) >>> 0;
|
|
2310
2367
|
session.lastPingSentMs = Date.now();
|
|
2311
|
-
|
|
2368
|
+
// Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
|
|
2369
|
+
// traffic. Once UDP is established it should ride UDP exclusively and
|
|
2370
|
+
// NEVER spill onto the TCP relay during a UDP hiccup — the relay's
|
|
2371
|
+
// China↔US queue backs up 20s+ and every spilled packet returns as a
|
|
2372
|
+
// hugely-delayed duplicate. Control packets (keepalives, endpoint
|
|
2373
|
+
// offers, profile) keep the relay fallback so the session and the
|
|
2374
|
+
// re-punch survive a UDP outage. Brief UDP gaps just drop a few data
|
|
2375
|
+
// packets (the proxied TCP stream retransmits) and the re-punch loop
|
|
2376
|
+
// restores the direct path within a few seconds.
|
|
2377
|
+
const isBulkData = kind === PACKET_ID_MESSAGE;
|
|
2378
|
+
await this.#sendToFriend(friendId, encrypted, session, isBulkData);
|
|
2312
2379
|
}
|
|
2313
2380
|
/**
|
|
2314
2381
|
* Send `packet` to `friendId` over whichever transport(s) are
|
|
@@ -2318,11 +2385,12 @@ export class Peer {
|
|
|
2318
2385
|
* receiving end (toxcore does the same when both transports are up).
|
|
2319
2386
|
* Throws only if zero transports succeeded.
|
|
2320
2387
|
*/
|
|
2321
|
-
async #sendToFriend(friendId, packet, session) {
|
|
2388
|
+
async #sendToFriend(friendId, packet, session, isBulkData = false) {
|
|
2322
2389
|
const s = session ?? this.#friendSessions.get(friendId);
|
|
2323
2390
|
let udpOk = false;
|
|
2324
2391
|
let tcpOk = false;
|
|
2325
2392
|
let firstError;
|
|
2393
|
+
const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
|
|
2326
2394
|
if (s?.remote) {
|
|
2327
2395
|
try {
|
|
2328
2396
|
await this.#sendPacket(packet, s.remote);
|
|
@@ -2332,6 +2400,29 @@ export class Peer {
|
|
|
2332
2400
|
firstError = error;
|
|
2333
2401
|
}
|
|
2334
2402
|
}
|
|
2403
|
+
// UDP-confirmed-ever: we've successfully received at least one UDP
|
|
2404
|
+
// packet from this peer, so the direct path has worked at some point.
|
|
2405
|
+
const udpEverConfirmed = s?.lastUdpRecvMs !== undefined;
|
|
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) {
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
// Control traffic: once direct UDP is confirmed *recently* (<10s),
|
|
2416
|
+
// also send UDP-only to avoid steady-state duplicates; otherwise fan
|
|
2417
|
+
// out over UDP + relay so control reaches the peer even when the
|
|
2418
|
+
// direct path is down (this is what lets the re-punch recover).
|
|
2419
|
+
const udpLiveRecent = udpOk &&
|
|
2420
|
+
realUdpRemote &&
|
|
2421
|
+
s?.lastUdpRecvMs !== undefined &&
|
|
2422
|
+
Date.now() - s.lastUdpRecvMs < 10_000;
|
|
2423
|
+
if (udpLiveRecent) {
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2335
2426
|
if (this.#tcpRelays) {
|
|
2336
2427
|
// The TCP relay routes by the friend's DHT pubkey (= the pubkey
|
|
2337
2428
|
// they handshook the relay with), not by the friend's real
|
|
@@ -2469,6 +2560,147 @@ export class Peer {
|
|
|
2469
2560
|
.sort((a, b) => b.updatedMs - a.updatedMs)
|
|
2470
2561
|
.slice(0, 12);
|
|
2471
2562
|
}
|
|
2563
|
+
/**
|
|
2564
|
+
* Learn our own server-reflexive (public) UDP endpoint by sending a
|
|
2565
|
+
* STUN binding-request to a bootnode on its STUN port (3478) over the
|
|
2566
|
+
* SAME #udp socket net_crypto uses — so the reflexive port matches
|
|
2567
|
+
* where our crypto data will actually arrive. Cached briefly because
|
|
2568
|
+
* a cone NAT's mapping is stable for the socket's lifetime.
|
|
2569
|
+
*
|
|
2570
|
+
* Returns undefined if no bootnode answers within the timeout.
|
|
2571
|
+
*/
|
|
2572
|
+
async #gatherOwnSrflx() {
|
|
2573
|
+
const CACHE_MS = 60_000;
|
|
2574
|
+
const now = Date.now();
|
|
2575
|
+
if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
|
|
2576
|
+
return this.#srflxCache.addr;
|
|
2577
|
+
}
|
|
2578
|
+
const bootnodes = this.#opts.bootstrapNodes;
|
|
2579
|
+
if (!bootnodes || bootnodes.length === 0)
|
|
2580
|
+
return undefined;
|
|
2581
|
+
// Try up to 3 bootnodes; first answer wins.
|
|
2582
|
+
for (const bn of bootnodes.slice(0, 3)) {
|
|
2583
|
+
const req = buildBindingRequest();
|
|
2584
|
+
const txnHex = Buffer.from(req.slice(8, 20)).toString("hex");
|
|
2585
|
+
const addr = await new Promise((resolve) => {
|
|
2586
|
+
const interceptor = (data, rinfo) => {
|
|
2587
|
+
if (rinfo.address !== bn.host || rinfo.port !== 3478)
|
|
2588
|
+
return false;
|
|
2589
|
+
const msg = decodeStun(data);
|
|
2590
|
+
if (!msg || msg.type !== STUN_BINDING_SUCCESS)
|
|
2591
|
+
return false;
|
|
2592
|
+
if (Buffer.from(msg.transactionId).toString("hex") !== txnHex)
|
|
2593
|
+
return false;
|
|
2594
|
+
const xma = findAttr(msg, STUN_ATTR_XOR_MAPPED_ADDRESS);
|
|
2595
|
+
const parsed = xma ? decodeXorMappedAddress(xma, msg.transactionId) : undefined;
|
|
2596
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2597
|
+
clearTimeout(timer);
|
|
2598
|
+
resolve(parsed ? { host: parsed.address, port: parsed.port } : undefined);
|
|
2599
|
+
return true; // consume — don't let it reach net_crypto/DHT dispatch
|
|
2600
|
+
};
|
|
2601
|
+
const timer = setTimeout(() => {
|
|
2602
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2603
|
+
resolve(undefined);
|
|
2604
|
+
}, 2000);
|
|
2605
|
+
this.#udp.addStunInterceptor(interceptor);
|
|
2606
|
+
this.#udp.sendDirect(Buffer.from(req), bn.host, 3478).catch(() => {
|
|
2607
|
+
this.#udp.removeStunInterceptor(interceptor);
|
|
2608
|
+
clearTimeout(timer);
|
|
2609
|
+
resolve(undefined);
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
if (addr) {
|
|
2613
|
+
this.#srflxCache = { addr, atMs: now };
|
|
2614
|
+
return addr;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
return undefined;
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Tell a friend our public UDP endpoint over the (already-working,
|
|
2621
|
+
* possibly tcp-relay) messenger channel, so they can hole-punch to us.
|
|
2622
|
+
* This is the out-of-band substitute for the broken DHT/onion-announce
|
|
2623
|
+
* endpoint discovery. Both peers do this symmetrically; on receipt each
|
|
2624
|
+
* feeds the other's endpoint into endpointCandidates and punches.
|
|
2625
|
+
*/
|
|
2626
|
+
async #sendUdpEndpointOffer(friendId) {
|
|
2627
|
+
const session = this.#friendSessions.get(friendId);
|
|
2628
|
+
if (!session?.established)
|
|
2629
|
+
return;
|
|
2630
|
+
const srflx = await this.#gatherOwnSrflx();
|
|
2631
|
+
if (!srflx)
|
|
2632
|
+
return;
|
|
2633
|
+
const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
|
|
2634
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
|
|
2635
|
+
return;
|
|
2636
|
+
const payload = new Uint8Array(6);
|
|
2637
|
+
payload.set(octets, 0);
|
|
2638
|
+
payload[4] = (srflx.port >> 8) & 0xff;
|
|
2639
|
+
payload[5] = srflx.port & 0xff;
|
|
2640
|
+
try {
|
|
2641
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
|
|
2642
|
+
this.#debugLog(`udp-endpoint offer sent to ${friendId}: ${srflx.host}:${srflx.port}`);
|
|
2643
|
+
}
|
|
2644
|
+
catch {
|
|
2645
|
+
// best-effort — the retry loop will try again
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* A friend told us their public UDP endpoint. Feed it as a high-value
|
|
2650
|
+
* candidate and immediately spray a few raw punch datagrams at it to
|
|
2651
|
+
* open our own cone-NAT mapping toward their address:port, then kick a
|
|
2652
|
+
* session initiation (the higher-pubkey side sends the cookie request;
|
|
2653
|
+
* both sides' mappings are now open so it gets through). Mirrors the
|
|
2654
|
+
* proven standalone hole-punch.
|
|
2655
|
+
*/
|
|
2656
|
+
#handleUdpEndpointOffer(friendId, payload) {
|
|
2657
|
+
if (payload.length < 6)
|
|
2658
|
+
return;
|
|
2659
|
+
const host = `${payload[0]}.${payload[1]}.${payload[2]}.${payload[3]}`;
|
|
2660
|
+
const port = ((payload[4] << 8) | payload[5]) >>> 0;
|
|
2661
|
+
if (port === 0)
|
|
2662
|
+
return;
|
|
2663
|
+
const session = this.#friendSessions.get(friendId);
|
|
2664
|
+
if (!session)
|
|
2665
|
+
return;
|
|
2666
|
+
// Don't punch toward ourselves.
|
|
2667
|
+
if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
|
|
2668
|
+
return;
|
|
2669
|
+
this.#rememberEndpointCandidate(session, host, port);
|
|
2670
|
+
this.#debugLog(`udp-endpoint offer from ${friendId}: ${host}:${port} — punching`);
|
|
2671
|
+
// Spray a handful of tiny punch datagrams (0xF2 is not a recognized
|
|
2672
|
+
// Carrier/DHT/net_crypto packet type, so the peer's #onDatagram
|
|
2673
|
+
// ignores them — their only purpose is opening our NAT mapping
|
|
2674
|
+
// toward host:port so the peer's crypto data can reach us).
|
|
2675
|
+
const punch = Uint8Array.of(0xf2);
|
|
2676
|
+
let n = 0;
|
|
2677
|
+
const punchTimer = setInterval(() => {
|
|
2678
|
+
this.#udp.sendDirectSync(Buffer.from(punch), host, port);
|
|
2679
|
+
if (++n >= 6)
|
|
2680
|
+
clearInterval(punchTimer);
|
|
2681
|
+
}, 120);
|
|
2682
|
+
// Point this session's UDP remote at the peer's reflexive endpoint
|
|
2683
|
+
// (unless we already have a confirmed real UDP remote). hasTcpRoute
|
|
2684
|
+
// stays true, so #sendToFriend fans crypto data out over BOTH UDP
|
|
2685
|
+
// and TCP relay: TCP keeps the session alive even if the punch
|
|
2686
|
+
// fails, while every UDP-bound keepalive/packet that makes it
|
|
2687
|
+
// through the freshly-punched hole causes the peer's CRYPTO_DATA
|
|
2688
|
+
// handler to set THEIR remote to our endpoint (peer.ts ~1564) —
|
|
2689
|
+
// upgrading the path to direct UDP in both directions with no new
|
|
2690
|
+
// handshake. If UDP never works, the periodic offer re-punches.
|
|
2691
|
+
const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
|
|
2692
|
+
if (!haveRealUdp) {
|
|
2693
|
+
session.remote = { host, port };
|
|
2694
|
+
}
|
|
2695
|
+
// Nudge a keepalive out immediately so the upgrade doesn't wait for
|
|
2696
|
+
// the next periodic ALIVE — fans out over UDP (punched) + TCP.
|
|
2697
|
+
if (session.established) {
|
|
2698
|
+
void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
|
|
2699
|
+
}
|
|
2700
|
+
else {
|
|
2701
|
+
void this.#initiateSession(friendId).catch(() => undefined);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2472
2704
|
#collectSessionEndpointCandidates(friendId, friend, session) {
|
|
2473
2705
|
// Three buckets: same-LAN private (highest priority for direct UDP),
|
|
2474
2706
|
// public/routable, and other private (different LAN, last-resort).
|
package/dist/stun.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STUN message codec (RFC 5389 + parts of 5766 for TURN integration).
|
|
3
|
+
*
|
|
4
|
+
* Supports the subset we need:
|
|
5
|
+
* - Binding Request / Binding Success Response (RFC 5389) for SRFLX
|
|
6
|
+
* discovery and ICE connectivity checks.
|
|
7
|
+
* - Long-term credential MESSAGE-INTEGRITY (HMAC-SHA1) and FINGERPRINT,
|
|
8
|
+
* needed by the bootnode TURN servers (coturn).
|
|
9
|
+
* - XOR-MAPPED-ADDRESS decoding.
|
|
10
|
+
* - USERNAME, REALM, NONCE, ERROR-CODE attributes (TURN auth dance).
|
|
11
|
+
* - SOFTWARE / FINGERPRINT attributes for being polite.
|
|
12
|
+
*
|
|
13
|
+
* Wire format:
|
|
14
|
+
*
|
|
15
|
+
* 0 1 2 3
|
|
16
|
+
* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
17
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
18
|
+
* |0 0| STUN Message Type | Message Length |
|
|
19
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
20
|
+
* | Magic Cookie |
|
|
21
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
22
|
+
* | |
|
|
23
|
+
* | Transaction ID (96 bits) |
|
|
24
|
+
* | |
|
|
25
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
26
|
+
*
|
|
27
|
+
* Attributes follow as { type: u16, length: u16, value: bytes, pad to 4 }.
|
|
28
|
+
*
|
|
29
|
+
* MESSAGE-INTEGRITY is HMAC-SHA1 over the entire message up to (not
|
|
30
|
+
* including) the MESSAGE-INTEGRITY attribute itself, with the message
|
|
31
|
+
* length pre-adjusted as if MESSAGE-INTEGRITY were present.
|
|
32
|
+
*/
|
|
33
|
+
export declare const STUN_MAGIC_COOKIE = 554869826;
|
|
34
|
+
export declare const STUN_BINDING_REQUEST = 1;
|
|
35
|
+
export declare const STUN_BINDING_SUCCESS = 257;
|
|
36
|
+
export declare const STUN_BINDING_ERROR = 273;
|
|
37
|
+
export declare const TURN_ALLOCATE_REQUEST = 3;
|
|
38
|
+
export declare const TURN_ALLOCATE_SUCCESS = 259;
|
|
39
|
+
export declare const TURN_ALLOCATE_ERROR = 275;
|
|
40
|
+
export declare const TURN_REFRESH_REQUEST = 4;
|
|
41
|
+
export declare const TURN_REFRESH_SUCCESS = 260;
|
|
42
|
+
export declare const TURN_CREATE_PERMISSION_REQUEST = 8;
|
|
43
|
+
export declare const TURN_CREATE_PERMISSION_SUCCESS = 264;
|
|
44
|
+
export declare const TURN_SEND_INDICATION = 22;
|
|
45
|
+
export declare const TURN_DATA_INDICATION = 23;
|
|
46
|
+
export declare const STUN_ATTR_MAPPED_ADDRESS = 1;
|
|
47
|
+
export declare const STUN_ATTR_USERNAME = 6;
|
|
48
|
+
export declare const STUN_ATTR_MESSAGE_INTEGRITY = 8;
|
|
49
|
+
export declare const STUN_ATTR_ERROR_CODE = 9;
|
|
50
|
+
export declare const STUN_ATTR_REALM = 20;
|
|
51
|
+
export declare const STUN_ATTR_NONCE = 21;
|
|
52
|
+
export declare const STUN_ATTR_XOR_MAPPED_ADDRESS = 32;
|
|
53
|
+
export declare const STUN_ATTR_SOFTWARE = 32802;
|
|
54
|
+
export declare const STUN_ATTR_FINGERPRINT = 32808;
|
|
55
|
+
export declare const STUN_ATTR_CHANNEL_NUMBER = 12;
|
|
56
|
+
export declare const STUN_ATTR_LIFETIME = 13;
|
|
57
|
+
export declare const STUN_ATTR_XOR_PEER_ADDRESS = 18;
|
|
58
|
+
export declare const STUN_ATTR_DATA = 19;
|
|
59
|
+
export declare const STUN_ATTR_XOR_RELAYED_ADDRESS = 22;
|
|
60
|
+
export declare const STUN_ATTR_REQUESTED_TRANSPORT = 25;
|
|
61
|
+
export declare const STUN_ATTR_DONT_FRAGMENT = 26;
|
|
62
|
+
export interface StunAttribute {
|
|
63
|
+
type: number;
|
|
64
|
+
value: Uint8Array;
|
|
65
|
+
}
|
|
66
|
+
export interface StunMessage {
|
|
67
|
+
type: number;
|
|
68
|
+
transactionId: Uint8Array;
|
|
69
|
+
attributes: StunAttribute[];
|
|
70
|
+
}
|
|
71
|
+
export interface AddressValue {
|
|
72
|
+
family: 4 | 6;
|
|
73
|
+
address: string;
|
|
74
|
+
port: number;
|
|
75
|
+
}
|
|
76
|
+
export declare function newTransactionId(): Uint8Array;
|
|
77
|
+
/**
|
|
78
|
+
* Encode a STUN message. If `integrityKey` is provided, a
|
|
79
|
+
* MESSAGE-INTEGRITY attribute (HMAC-SHA1) is appended. If `fingerprint`
|
|
80
|
+
* is true, a FINGERPRINT (CRC32 XOR 0x5354554e) is appended last.
|
|
81
|
+
*
|
|
82
|
+
* The order matters because MESSAGE-INTEGRITY must be computed over
|
|
83
|
+
* the message *as if* it were the last attribute (with length already
|
|
84
|
+
* adjusted), and FINGERPRINT must be computed over the message
|
|
85
|
+
* including MESSAGE-INTEGRITY.
|
|
86
|
+
*/
|
|
87
|
+
export declare function encodeStun(message: StunMessage, opts?: {
|
|
88
|
+
integrityKey?: Uint8Array;
|
|
89
|
+
fingerprint?: boolean;
|
|
90
|
+
}): Uint8Array;
|
|
91
|
+
export declare function decodeStun(data: Uint8Array): StunMessage | undefined;
|
|
92
|
+
export declare function findAttr(message: StunMessage, type: number): Uint8Array | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Decode XOR-MAPPED-ADDRESS (RFC 5389 §15.2). The high bits of the
|
|
95
|
+
* port and the address are XORed with the magic cookie + transactionId.
|
|
96
|
+
*/
|
|
97
|
+
export declare function decodeXorMappedAddress(value: Uint8Array, transactionId: Uint8Array): AddressValue | undefined;
|
|
98
|
+
/** Encode XOR-MAPPED-ADDRESS for outgoing STUN responses (TURN, conn check). */
|
|
99
|
+
export declare function encodeXorMappedAddress(addr: AddressValue, transactionId: Uint8Array): Uint8Array;
|
|
100
|
+
export declare function encodeErrorCode(code: number, reason: string): Uint8Array;
|
|
101
|
+
export declare function decodeErrorCode(value: Uint8Array): {
|
|
102
|
+
code: number;
|
|
103
|
+
reason: string;
|
|
104
|
+
} | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Long-term-credential integrity key: MD5("username:realm:password").
|
|
107
|
+
* RFC 5389 §15.4. coturn uses this.
|
|
108
|
+
*/
|
|
109
|
+
export declare function longTermIntegrityKey(username: string, realm: string, password: string): Uint8Array;
|
|
110
|
+
/** Short-term-credential integrity key: just the password as UTF-8. */
|
|
111
|
+
export declare function shortTermIntegrityKey(password: string): Uint8Array;
|
|
112
|
+
export declare function buildBindingRequest(transactionId?: Uint8Array): Uint8Array;
|
|
113
|
+
export declare function buildBindingRequestWithIntegrity(opts: {
|
|
114
|
+
username: string;
|
|
115
|
+
password: string;
|
|
116
|
+
transactionId?: Uint8Array;
|
|
117
|
+
}): Uint8Array;
|