@decentnetwork/peer 0.1.66 → 0.1.68
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.d.ts +7 -0
- package/dist/peer.js +233 -62
- package/package.json +1 -1
package/dist/peer.d.ts
CHANGED
|
@@ -194,10 +194,17 @@ export declare class Peer {
|
|
|
194
194
|
* tcp-relay, discovery itself is broken — we don't even know
|
|
195
195
|
* where to send a cookie request. */
|
|
196
196
|
endpointCandidatesCount: number;
|
|
197
|
+
/** The actual candidate endpoints (host:port) — lets diag see whether a
|
|
198
|
+
* same-LAN host candidate (e.g. 10.0.0.x) was received but not adopted. */
|
|
199
|
+
endpointCandidates: string[];
|
|
197
200
|
/** Last time we sent an outbound cookie request via UDP for this
|
|
198
201
|
* peer (null = never). Used to confirm Phase 1.2 retries are
|
|
199
202
|
* actually firing. */
|
|
200
203
|
cookieRequestSentMs: number | null;
|
|
204
|
+
/** The peer's physical-LAN host we've locked onto (set by the periodic
|
|
205
|
+
* offer/maintenance LAN-adoption code). When set and equal to udpRemote's
|
|
206
|
+
* host, the per-packet receive path refuses a public/hairpin downgrade. */
|
|
207
|
+
lanRemoteHost: string | null;
|
|
201
208
|
} | null;
|
|
202
209
|
waitForFriendRequest(timeoutMs?: number): Promise<FriendRequest>;
|
|
203
210
|
/**
|
package/dist/peer.js
CHANGED
|
@@ -82,6 +82,28 @@ const FRIEND_PING_INTERVAL_MS = readEnvInt("DECENT_FRIEND_PING_INTERVAL_MS", 400
|
|
|
82
82
|
// slow enough not to burn CPU on idle peers. Tunable via env.
|
|
83
83
|
const FRIEND_CONNECTION_LOOP_MS = readEnvInt("DECENT_FRIEND_CONNECTION_LOOP_MS", 250);
|
|
84
84
|
const FRIEND_TIMEOUT_MS = readEnvInt("DECENT_FRIEND_TIMEOUT_MS", 32000);
|
|
85
|
+
// How long the direct UDP path may go without an inbound packet before we
|
|
86
|
+
// release a physical-LAN lock (so a peer that left the LAN can recover onto the
|
|
87
|
+
// public/relay path). 12s ≈ 3 missed 4s keepalives — long enough to never trip
|
|
88
|
+
// on a healthy same-LAN path, short enough to recover quickly.
|
|
89
|
+
const LAN_LOCK_STALE_MS = readEnvInt("DECENT_LAN_LOCK_STALE_MS", 12000);
|
|
90
|
+
// Half-open breaker: if the peer proposes a NEW session key this many times over
|
|
91
|
+
// this long while our session looks "alive", accept it (the peer can't decrypt
|
|
92
|
+
// our current session — a desynced half-open session, observed as a flood of
|
|
93
|
+
// "no session matched"). Each handshake carries a fresh cookie so these are
|
|
94
|
+
// genuine attempts, not replays. Bounded so it never fires on a healthy session.
|
|
95
|
+
const REHS_ACCEPT_COUNT = readEnvInt("DECENT_REHS_ACCEPT_COUNT", 4);
|
|
96
|
+
const REHS_ACCEPT_MS = readEnvInt("DECENT_REHS_ACCEPT_MS", 2500);
|
|
97
|
+
const REHS_WINDOW_MS = readEnvInt("DECENT_REHS_WINDOW_MS", 8000);
|
|
98
|
+
// Re-handshake when we keep receiving undecryptable crypto data from a known
|
|
99
|
+
// friend's endpoint (desynced sessions), rate-limited per friend.
|
|
100
|
+
// Conservative: deleting a session forces a re-handshake which ROTATES our
|
|
101
|
+
// ephemeral key, so doing it too eagerly turns a transient blip into a
|
|
102
|
+
// rotate→desync→delete thrash. Only nuke a session that's been UNABLE to
|
|
103
|
+
// decrypt anything for a long time (genuinely dead, well past any
|
|
104
|
+
// establishment-transition window), and at most once per long interval.
|
|
105
|
+
const REINIT_ON_DESYNC_MS = readEnvInt("DECENT_REINIT_ON_DESYNC_MS", 12000);
|
|
106
|
+
const REINIT_STUCK_MS = readEnvInt("DECENT_REINIT_STUCK_MS", 20000);
|
|
85
107
|
// How long a relay path stays "confirmed" (carrying bulk data on its own, no
|
|
86
108
|
// tcp-relay fan-out) after the last packet received over it. The relay
|
|
87
109
|
// keepalive refreshes this every ~4s on a healthy path, so a generous window
|
|
@@ -256,6 +278,9 @@ export class Peer {
|
|
|
256
278
|
// #initiateSession every few seconds for every friend; without this
|
|
257
279
|
// dedupe, lower-pubkey side floods the log with "deferring to peer".
|
|
258
280
|
#initiateSkipLogged = new Set();
|
|
281
|
+
// Per-friend last time we deleted a desynced session (rate-limit; survives the
|
|
282
|
+
// session deletion, unlike a field on the session object itself).
|
|
283
|
+
#lastDesyncDeleteMs = new Map();
|
|
259
284
|
// Cached server-reflexive (public) UDP endpoint of our #udp socket,
|
|
260
285
|
// learned via STUN. Cone-NAT mappings are stable for the socket
|
|
261
286
|
// lifetime, so we only re-probe every ~60s. Used to tell friends where
|
|
@@ -1062,7 +1087,9 @@ export class Peer {
|
|
|
1062
1087
|
ourLocalUdpPort: this.#udp?.localPort() ?? null,
|
|
1063
1088
|
friendUdpEndpoint: friendUdp,
|
|
1064
1089
|
endpointCandidatesCount: s.endpointCandidates?.length ?? 0,
|
|
1090
|
+
endpointCandidates: (s.endpointCandidates ?? []).map((c) => `${c.host}:${c.port}`),
|
|
1065
1091
|
cookieRequestSentMs: s.cookieRequestSentMs ?? null,
|
|
1092
|
+
lanRemoteHost: s.lanRemoteHost ?? null,
|
|
1066
1093
|
};
|
|
1067
1094
|
}
|
|
1068
1095
|
waitForFriendRequest(timeoutMs = 30000) {
|
|
@@ -1365,7 +1392,13 @@ export class Peer {
|
|
|
1365
1392
|
state.hasTcpRoute = true;
|
|
1366
1393
|
}
|
|
1367
1394
|
else {
|
|
1368
|
-
|
|
1395
|
+
// Same LAN-lock guard as the crypto-data path: a duplicate handshake
|
|
1396
|
+
// retransmitted via the public/hairpin endpoint must not knock us off
|
|
1397
|
+
// a locked physical-LAN path. Cheap string compare — no syscall.
|
|
1398
|
+
const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
|
|
1399
|
+
if (!lockedOnLan || remote.address === state.lanRemoteHost) {
|
|
1400
|
+
state.remote = { host: remote.address, port: remote.port };
|
|
1401
|
+
}
|
|
1369
1402
|
}
|
|
1370
1403
|
state.lastPingRecvMs = Date.now();
|
|
1371
1404
|
this.#debugVerboseLog(`hs_recv duplicate friend=${friendId} remote=${remote.address}:${remote.port} (session preserved)`);
|
|
@@ -1435,9 +1468,10 @@ export class Peer {
|
|
|
1435
1468
|
const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
|
|
1436
1469
|
const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
|
|
1437
1470
|
if (sinceEstablished > 1000 && currentSessionAlive) {
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1471
|
+
// Reject a new-key handshake while our session is alive — toxcore does
|
|
1472
|
+
// the same. (The "half-open breaker" that accepted these after N tries
|
|
1473
|
+
// was reverted: accepting a new key ROTATES the session, which churned
|
|
1474
|
+
// healthy sessions; the real fix was the TURN-crash fix, not this.)
|
|
1441
1475
|
this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
|
|
1442
1476
|
return;
|
|
1443
1477
|
}
|
|
@@ -1454,7 +1488,13 @@ export class Peer {
|
|
|
1454
1488
|
// Don't set state.remote — there's no UDP endpoint for this peer.
|
|
1455
1489
|
}
|
|
1456
1490
|
else {
|
|
1457
|
-
|
|
1491
|
+
// LAN-lock guard (see crypto-data path): don't let an establish/
|
|
1492
|
+
// re-handshake arriving over the public/hairpin endpoint downgrade us
|
|
1493
|
+
// off a locked physical-LAN path. Cheap string compare — no syscall.
|
|
1494
|
+
const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
|
|
1495
|
+
if (!lockedOnLan || remote.address === state.lanRemoteHost) {
|
|
1496
|
+
state.remote = { host: remote.address, port: remote.port };
|
|
1497
|
+
}
|
|
1458
1498
|
}
|
|
1459
1499
|
if (isNewSession) {
|
|
1460
1500
|
state.sendPacketNumber = 0;
|
|
@@ -1470,13 +1510,21 @@ export class Peer {
|
|
|
1470
1510
|
this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
|
|
1471
1511
|
if (!wasEstablished) {
|
|
1472
1512
|
this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
1513
|
+
// Send our UDP endpoint offer (public srflx + LAN host candidate)
|
|
1514
|
+
// whenever the current path isn't already a same-LAN direct one:
|
|
1515
|
+
// - relay-only sessions need it to bootstrap a direct UDP path; and
|
|
1516
|
+
// - sessions that came up over a PUBLIC UDP endpoint (internet
|
|
1517
|
+
// hairpin) when both peers are actually on the SAME LAN — without
|
|
1518
|
+
// the offer they never learn each other's 10.x/192.168.x address and
|
|
1519
|
+
// stay stuck on the public path, which a symmetric / hairpin NAT
|
|
1520
|
+
// breaks one-way (observed: two boxes on one LAN where each only saw
|
|
1521
|
+
// the other one-directionally). The peer's same-subnet filter drops
|
|
1522
|
+
// the LAN candidate when it doesn't apply, so offering is always safe.
|
|
1523
|
+
const r = state.remote;
|
|
1524
|
+
const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
|
|
1525
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
|
|
1526
|
+
const haveLanToOffer = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
|
|
1527
|
+
if ((state.hasTcpRoute && !r) || (!remoteIsSameLan && haveLanToOffer)) {
|
|
1480
1528
|
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
1481
1529
|
}
|
|
1482
1530
|
}
|
|
@@ -1598,34 +1646,54 @@ export class Peer {
|
|
|
1598
1646
|
innerKind === PACKET_ID_FILE_SENDREQUEST ||
|
|
1599
1647
|
innerKind === PACKET_ID_FILE_CONTROL ||
|
|
1600
1648
|
innerKind === PACKET_ID_FILE_DATA;
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1649
|
+
// Confirm the transport path + liveness for EVERY decrypted packet —
|
|
1650
|
+
// INCLUDING a dual-send duplicate. Receiving over this transport proves
|
|
1651
|
+
// it works, so this MUST run BEFORE the dedup drop below. Otherwise the
|
|
1652
|
+
// duplicate UDP copy (the relay copy already advanced the buffer) is
|
|
1653
|
+
// dropped without ever marking the direct-UDP path live → lastUdpRecvMs
|
|
1654
|
+
// stays unset → udpFresh false → bulk data (files) never ride the LAN
|
|
1655
|
+
// even when LAN UDP is flowing both ways (the snoopy slow-file bug).
|
|
1607
1656
|
state.lastPingRecvMs = Date.now();
|
|
1608
|
-
// Same TCP-vs-UDP rule as the handshake handlers: don't poison
|
|
1609
|
-
// the UDP send-back endpoint when this packet came in via TCP.
|
|
1610
1657
|
if (this.#remoteIsTcp(remote)) {
|
|
1611
1658
|
state.hasTcpRoute = true;
|
|
1612
1659
|
}
|
|
1613
1660
|
else if (viaRelay) {
|
|
1614
|
-
// Arrived over the TURN relay. Track relay freshness separately;
|
|
1615
|
-
// do NOT set state.remote (that's the direct endpoint). The relay
|
|
1616
|
-
// address it came from is already in state.relayRemote.
|
|
1617
1661
|
if (!state.lastRelayRecvMs) {
|
|
1618
1662
|
this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
|
|
1619
1663
|
}
|
|
1620
1664
|
state.lastRelayRecvMs = Date.now();
|
|
1621
1665
|
}
|
|
1622
1666
|
else {
|
|
1623
|
-
|
|
1667
|
+
// Adopt the source as our send endpoint — but NEVER downgrade from a
|
|
1668
|
+
// locked same-LAN (physical) path to a non-LAN source (a hairpin packet
|
|
1669
|
+
// via the peer's public / Tailscale-exit endpoint must not knock us off
|
|
1670
|
+
// the direct LAN path, which is strictly better).
|
|
1671
|
+
//
|
|
1672
|
+
// CCTV-safe: the LAN decision (which needs a networkInterfaces() syscall)
|
|
1673
|
+
// is made ONLY in the periodic offer/maintenance paths, which record the
|
|
1674
|
+
// locked peer LAN host in state.lanRemoteHost. Here in the per-packet hot
|
|
1675
|
+
// path we do a single string compare — NO interface syscall — so a
|
|
1676
|
+
// high-rate stream (CCTV: thousands of packets/sec) never pays for it.
|
|
1677
|
+
// (The per-packet getPhysicalLanSubnets() call this replaces was the
|
|
1678
|
+
// CCTV CPU regression.)
|
|
1679
|
+
const lockedOnLan = !!state.lanRemoteHost && state.remote?.host === state.lanRemoteHost;
|
|
1680
|
+
if (!lockedOnLan || remote.address === state.lanRemoteHost) {
|
|
1681
|
+
state.remote = { host: remote.address, port: remote.port };
|
|
1682
|
+
}
|
|
1624
1683
|
if (!state.lastUdpRecvMs) {
|
|
1625
1684
|
this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
|
|
1626
1685
|
}
|
|
1627
1686
|
state.lastUdpRecvMs = Date.now();
|
|
1628
1687
|
}
|
|
1688
|
+
// De-duplicate: skip the DISPATCH + nonce advance for an already-seen
|
|
1689
|
+
// packet number. The transport confirmation above already ran, so a
|
|
1690
|
+
// duplicate still keeps the path fresh.
|
|
1691
|
+
if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
state.peerBaseNonce[state.peerBaseNonce.length - 2] = packet[1];
|
|
1695
|
+
state.peerBaseNonce[state.peerBaseNonce.length - 1] = packet[2];
|
|
1696
|
+
incrementNonce(state.peerBaseNonce);
|
|
1629
1697
|
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
1630
1698
|
const kind = opened.payload[0];
|
|
1631
1699
|
const inner = opened.payload.slice(1);
|
|
@@ -1778,7 +1846,16 @@ export class Peer {
|
|
|
1778
1846
|
this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
|
|
1779
1847
|
return;
|
|
1780
1848
|
}
|
|
1781
|
-
|
|
1849
|
+
// Reverted: we used to DELETE a "desynced" session here to force a clean
|
|
1850
|
+
// re-handshake. It caused more harm than good — deleting a session
|
|
1851
|
+
// interrupts the data plane, and a transient/late undecryptable packet
|
|
1852
|
+
// (common over the relay) triggered a delete→re-key→re-desync thrash that
|
|
1853
|
+
// churned MANY friends' sessions (observed: CCTV jitter + slow file
|
|
1854
|
+
// transfer to 4090, not just snoopy). The real instability was the daemon
|
|
1855
|
+
// CRASH (TURN DNS); with that fixed, sessions stay converged on their own.
|
|
1856
|
+
// Just drop the undecryptable packet — net_crypto's normal re-handshake
|
|
1857
|
+
// path recovers a genuinely dead session.
|
|
1858
|
+
this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
|
|
1782
1859
|
return;
|
|
1783
1860
|
}
|
|
1784
1861
|
if (packet[0] === NET_PACKET_ONION_DATA_RESPONSE && this.#announceDataKey) {
|
|
@@ -3085,6 +3162,15 @@ export class Peer {
|
|
|
3085
3162
|
session.endpointCandidates = next
|
|
3086
3163
|
.sort((a, b) => b.updatedMs - a.updatedMs)
|
|
3087
3164
|
.slice(0, 12);
|
|
3165
|
+
// The moment we learn the peer's physical-LAN host candidate, record it as
|
|
3166
|
+
// the LAN lock so the per-packet/handshake hot paths refuse a public/hairpin
|
|
3167
|
+
// downgrade (cheap string compare there, no syscall). This runs at
|
|
3168
|
+
// candidate-learn time (offer/discovery), not per packet, so the cached
|
|
3169
|
+
// getPhysicalLanSubnets() call is fine here.
|
|
3170
|
+
if (isPrivateAddress(host) && !isCgnatAddress(host) &&
|
|
3171
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(host, s))) {
|
|
3172
|
+
session.lanRemoteHost = host;
|
|
3173
|
+
}
|
|
3088
3174
|
}
|
|
3089
3175
|
/**
|
|
3090
3176
|
* Learn our own server-reflexive (public) UDP endpoint by sending a
|
|
@@ -3177,11 +3263,13 @@ export class Peer {
|
|
|
3177
3263
|
resolve();
|
|
3178
3264
|
});
|
|
3179
3265
|
});
|
|
3180
|
-
// CRITICAL: persistent 'error' listener. A TURN send to an
|
|
3181
|
-
// host (
|
|
3182
|
-
//
|
|
3183
|
-
//
|
|
3184
|
-
//
|
|
3266
|
+
// CRITICAL: a persistent 'error' listener. A TURN send to an
|
|
3267
|
+
// unresolvable host (DNS NXDOMAIN — e.g. tokyo.fi.chat on a box whose
|
|
3268
|
+
// resolver doesn't know it) emits an 'error' on this dgram socket;
|
|
3269
|
+
// with no listener, Node re-throws it as an uncaught exception and
|
|
3270
|
+
// CRASHES THE ENTIRE DAEMON (observed: snoopy repeatedly dying on
|
|
3271
|
+
// "getaddrinfo ENOTFOUND tokyo.fi.chat", which churned every session).
|
|
3272
|
+
// Swallow it — the relay simply won't allocate/relay over this server.
|
|
3185
3273
|
sock.on("error", (e) => this.#debugLog(`turn socket error (${srv.host}): ${e.message}`));
|
|
3186
3274
|
const client = new TurnClient({
|
|
3187
3275
|
sock,
|
|
@@ -3351,10 +3439,24 @@ export class Peer {
|
|
|
3351
3439
|
if (++ln >= 6)
|
|
3352
3440
|
clearInterval(lanTimer);
|
|
3353
3441
|
}, 120);
|
|
3354
|
-
|
|
3355
|
-
|
|
3442
|
+
// The physical LAN is strictly better than ANY public path — direct, no
|
|
3443
|
+
// NAT, no relay, and crucially no Tailscale-exit hairpin (a peer whose
|
|
3444
|
+
// internet egresses via Tailscale advertises its exit's public IP as
|
|
3445
|
+
// its srflx, so the "direct" public path actually loops out through
|
|
3446
|
+
// Tailscale and back). So adopt the LAN candidate as session.remote even
|
|
3447
|
+
// when we already have a public "real UDP" remote — UNLESS we're already
|
|
3448
|
+
// on a same-LAN address (don't flap between two LAN candidates). Without
|
|
3449
|
+
// this the session stuck to the public/Tailscale endpoint and never sent
|
|
3450
|
+
// over the LAN (observed: session.remote stayed public, data on relay).
|
|
3451
|
+
const cur = session.remote?.host;
|
|
3452
|
+
const onSameLanAlready = !!cur && !cur.startsWith("tcp:") && isPrivateAddress(cur) && !isCgnatAddress(cur) &&
|
|
3453
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(cur, s));
|
|
3454
|
+
if (!onSameLanAlready) {
|
|
3356
3455
|
session.remote = { host: lanHost, port: lanPort };
|
|
3357
3456
|
}
|
|
3457
|
+
// Record the locked LAN host so the per-packet receive path can refuse a
|
|
3458
|
+
// public/hairpin downgrade with a plain string compare (no syscall).
|
|
3459
|
+
session.lanRemoteHost = lanHost;
|
|
3358
3460
|
if (session.established) {
|
|
3359
3461
|
void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
|
|
3360
3462
|
}
|
|
@@ -3389,7 +3491,14 @@ export class Peer {
|
|
|
3389
3491
|
// upgrading the path to direct UDP in both directions with no new
|
|
3390
3492
|
// handshake. If UDP never works, the periodic offer re-punches.
|
|
3391
3493
|
const haveRealUdp = session.remote && !session.remote.host?.startsWith("tcp:") && session.remote.port !== 0;
|
|
3392
|
-
|
|
3494
|
+
// NEVER point the session at the public srflx when we know a same-LAN
|
|
3495
|
+
// candidate for this peer: the physical LAN is strictly better, and letting
|
|
3496
|
+
// the srflx win here caused a LAN↔public FLAP (the LAN block above set
|
|
3497
|
+
// remote=LAN, then this overwrote it with the Tailscale-exit public IP),
|
|
3498
|
+
// so the direct LAN path never stabilised and udpRecv never confirmed.
|
|
3499
|
+
const haveLanCandidate = (session.endpointCandidates ?? []).some((c) => isPrivateAddress(c.host) && !isCgnatAddress(c.host) &&
|
|
3500
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(c.host, s)));
|
|
3501
|
+
if (!haveRealUdp && !haveLanCandidate) {
|
|
3393
3502
|
session.remote = { host, port };
|
|
3394
3503
|
}
|
|
3395
3504
|
// Nudge a keepalive out immediately so the upgrade doesn't wait for
|
|
@@ -3967,6 +4076,57 @@ export class Peer {
|
|
|
3967
4076
|
if (!node.isTcp)
|
|
3968
4077
|
void this.#sendDhtPing(node);
|
|
3969
4078
|
}
|
|
4079
|
+
// 4. LAN-direct upgrade. Re-offer our endpoint (public srflx + LAN host
|
|
4080
|
+
// candidate) to any established friend whose current path is NOT already
|
|
4081
|
+
// a same-LAN address, as long as we have a LAN address to advertise. This
|
|
4082
|
+
// upgrades sessions that came up over a public UDP endpoint (internet
|
|
4083
|
+
// hairpin) to the direct LAN path even without a re-establish, and self-
|
|
4084
|
+
// stops once the path becomes same-LAN. The peer's same-subnet filter
|
|
4085
|
+
// drops the candidate when it doesn't apply, so this is always safe.
|
|
4086
|
+
const haveLan = getPhysicalLanAddresses().some((ip) => isPrivateAddress(ip) && !isCgnatAddress(ip));
|
|
4087
|
+
if (haveLan) {
|
|
4088
|
+
for (const [friendId, session] of this.#friendSessions.entries()) {
|
|
4089
|
+
if (!session.established)
|
|
4090
|
+
continue;
|
|
4091
|
+
// Release a stale LAN lock. If we're locked onto a LAN host but the
|
|
4092
|
+
// direct UDP path has gone quiet (peer left the LAN / changed network),
|
|
4093
|
+
// the lock would otherwise block recovery onto the public/relay path.
|
|
4094
|
+
// Clearing it lets the hot-path guard adopt a working endpoint again.
|
|
4095
|
+
if (session.lanRemoteHost &&
|
|
4096
|
+
session.remote?.host === session.lanRemoteHost &&
|
|
4097
|
+
session.lastUdpRecvMs !== undefined &&
|
|
4098
|
+
Date.now() - session.lastUdpRecvMs > LAN_LOCK_STALE_MS) {
|
|
4099
|
+
this.#debugLog(`lan_lock_released friend=${friendId} host=${session.lanRemoteHost} (no direct UDP recv for >${LAN_LOCK_STALE_MS}ms)`);
|
|
4100
|
+
session.lanRemoteHost = undefined;
|
|
4101
|
+
}
|
|
4102
|
+
const r = session.remote;
|
|
4103
|
+
const remoteIsSameLan = !!r && isPrivateAddress(r.host) && !isCgnatAddress(r.host) &&
|
|
4104
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(r.host, s));
|
|
4105
|
+
if (remoteIsSameLan) {
|
|
4106
|
+
// Already on a physical-LAN path (e.g. the session established directly
|
|
4107
|
+
// over LAN). Lock it so the per-packet hot path's cheap string-compare
|
|
4108
|
+
// guard refuses any later public/hairpin downgrade — no syscall there.
|
|
4109
|
+
session.lanRemoteHost = r.host;
|
|
4110
|
+
}
|
|
4111
|
+
if (!remoteIsSameLan) {
|
|
4112
|
+
// If we ALREADY hold a same-LAN candidate for this peer (learned from
|
|
4113
|
+
// an earlier offer), switch the session onto it NOW — don't wait to
|
|
4114
|
+
// receive yet another offer at exactly the right moment. That delay is
|
|
4115
|
+
// what kept sessions stuck on the public/Tailscale-hairpin path after
|
|
4116
|
+
// the staggered restarts. Then send a keepalive over the LAN so the
|
|
4117
|
+
// peer learns our LAN source and replies over it; the downgrade guard
|
|
4118
|
+
// keeps us there once it sticks.
|
|
4119
|
+
const lanCand = (session.endpointCandidates ?? []).find((c) => isPrivateAddress(c.host) && !isCgnatAddress(c.host) &&
|
|
4120
|
+
getPhysicalLanSubnets().some((s) => isInIpv4Subnet(c.host, s)));
|
|
4121
|
+
if (lanCand) {
|
|
4122
|
+
session.remote = { host: lanCand.host, port: lanCand.port };
|
|
4123
|
+
session.lanRemoteHost = lanCand.host;
|
|
4124
|
+
void this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => undefined);
|
|
4125
|
+
}
|
|
4126
|
+
void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
3970
4130
|
}
|
|
3971
4131
|
/**
|
|
3972
4132
|
* Send an onion DHT-PK announcement to a friend so they learn our DHT
|
|
@@ -4899,7 +5059,15 @@ function isInIpv4Subnet(host, subnet) {
|
|
|
4899
5059
|
// path onto the overlay or loops into our own mesh, then silently falls back to
|
|
4900
5060
|
// the relay — the snoopy/lili "lastUdpRecv=never -> bufferbloat" path. (lili
|
|
4901
5061
|
// runs Tailscale; that's exactly this.)
|
|
4902
|
-
|
|
5062
|
+
// Names of interfaces that are NOT a real physical LAN — overlays (tailscale,
|
|
5063
|
+
// zerotier, wireguard, the agentnet TUN), and especially container/VM bridges.
|
|
5064
|
+
// Docker creates `docker0` AND per-network `br-<hash>` bridges (172.x); libvirt
|
|
5065
|
+
// `virbr0`; k8s `cni0`/`flannel`/`kube`. These were leaking their 172.x address
|
|
5066
|
+
// into the LAN host-candidate offer, so a peer on a real 10.x LAN advertised a
|
|
5067
|
+
// Docker-bridge IP its LAN partner could never match → LAN-direct never engaged
|
|
5068
|
+
// (observed: two boxes on one 10.0.0.0/24 stuck hairpinning through the public
|
|
5069
|
+
// internet). `^bridge` did NOT cover `br-…`.
|
|
5070
|
+
const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|zt|ham|agentnet|awdl|llw|gif|stf|bridge|br-|virbr|cni|flannel|weave|kube|vnet|vnic|vmnet|veth|docker)/i;
|
|
4903
5071
|
/** True for RFC 6598 carrier-grade-NAT space 100.64.0.0/10 (Tailscale's range). */
|
|
4904
5072
|
function isCgnatAddress(host) {
|
|
4905
5073
|
const o = host.split(".");
|
|
@@ -4911,50 +5079,53 @@ function isCgnatAddress(host) {
|
|
|
4911
5079
|
}
|
|
4912
5080
|
/** Local IPv4 addresses on PHYSICAL interfaces only (excludes overlay/virtual
|
|
4913
5081
|
* interfaces by name and the CGNAT range). Used for LAN-direct host candidates. */
|
|
4914
|
-
|
|
4915
|
-
|
|
5082
|
+
// CACHED interface enumeration. getPhysicalLanSubnets/Addresses are called on
|
|
5083
|
+
// the receive hot path (the same-LAN downgrade guard runs per packet); calling
|
|
5084
|
+
// networkInterfaces() — a syscall — for every CRYPTO_DATA packet pegged the CPU
|
|
5085
|
+
// on a high-rate stream (CCTV jitter/instability). Cache the result for 5s; LAN
|
|
5086
|
+
// interfaces almost never change, so this is safe and ~free.
|
|
5087
|
+
let _lanIfaceCacheMs = 0;
|
|
5088
|
+
let _lanAddrsCache = [];
|
|
5089
|
+
let _lanSubnetsCache = [];
|
|
5090
|
+
function refreshLanIfaceCache() {
|
|
5091
|
+
const now = Date.now();
|
|
5092
|
+
if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
|
|
5093
|
+
return;
|
|
5094
|
+
_lanIfaceCacheMs = now;
|
|
5095
|
+
const addrs = [];
|
|
5096
|
+
const subnets = [];
|
|
4916
5097
|
try {
|
|
4917
5098
|
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
4918
5099
|
if (!list || VIRTUAL_IFACE_RE.test(name))
|
|
4919
5100
|
continue;
|
|
4920
5101
|
for (const info of list) {
|
|
4921
|
-
if (info.family !== "IPv4" || info.internal)
|
|
4922
|
-
continue;
|
|
4923
|
-
if (isCgnatAddress(info.address))
|
|
5102
|
+
if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
|
|
4924
5103
|
continue;
|
|
4925
|
-
|
|
5104
|
+
addrs.push(info.address);
|
|
5105
|
+
const addr = ipv4ToInt(info.address);
|
|
5106
|
+
const mask = netmaskToInt(info.netmask);
|
|
5107
|
+
if (addr !== undefined && mask !== undefined && mask !== 0) {
|
|
5108
|
+
subnets.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
|
|
5109
|
+
}
|
|
4926
5110
|
}
|
|
4927
5111
|
}
|
|
4928
5112
|
}
|
|
4929
5113
|
catch {
|
|
4930
5114
|
// best-effort
|
|
4931
5115
|
}
|
|
4932
|
-
|
|
5116
|
+
_lanAddrsCache = addrs;
|
|
5117
|
+
_lanSubnetsCache = subnets;
|
|
5118
|
+
}
|
|
5119
|
+
function getPhysicalLanAddresses() {
|
|
5120
|
+
refreshLanIfaceCache();
|
|
5121
|
+
return _lanAddrsCache;
|
|
4933
5122
|
}
|
|
4934
5123
|
/** Subnets of PHYSICAL interfaces only — for matching a peer's host candidate
|
|
4935
5124
|
* to OUR real LAN (so we never match a peer's Tailscale/TUN address against our
|
|
4936
|
-
* own overlay subnet). */
|
|
5125
|
+
* own overlay subnet). Cached (see refreshLanIfaceCache). */
|
|
4937
5126
|
function getPhysicalLanSubnets() {
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
4941
|
-
if (!list || VIRTUAL_IFACE_RE.test(name))
|
|
4942
|
-
continue;
|
|
4943
|
-
for (const info of list) {
|
|
4944
|
-
if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
|
|
4945
|
-
continue;
|
|
4946
|
-
const addr = ipv4ToInt(info.address);
|
|
4947
|
-
const mask = netmaskToInt(info.netmask);
|
|
4948
|
-
if (addr === undefined || mask === undefined || mask === 0)
|
|
4949
|
-
continue;
|
|
4950
|
-
out.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
|
|
4951
|
-
}
|
|
4952
|
-
}
|
|
4953
|
-
}
|
|
4954
|
-
catch {
|
|
4955
|
-
// best-effort
|
|
4956
|
-
}
|
|
4957
|
-
return out;
|
|
5127
|
+
refreshLanIfaceCache();
|
|
5128
|
+
return _lanSubnetsCache;
|
|
4958
5129
|
}
|
|
4959
5130
|
function isPrivateAddress(host) {
|
|
4960
5131
|
// RFC1918 IPv4: 10/8, 172.16/12, 192.168/16; loopback 127/8;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
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",
|