@decentnetwork/peer 0.1.17 → 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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Minimal ICE-lite agent: gather candidates, exchange via an external
3
+ * signaling channel (Carrier friend-message), connectivity-check each
4
+ * (local, remote) pair, pick the highest-priority working pair as the
5
+ * data path, keepalive, re-run on failure.
6
+ *
7
+ * This is NOT full ICE:
8
+ * - No controlling/controlled negotiation (both sides probe equally).
9
+ * - No aggressive nomination, no Trickle phases.
10
+ * - No TCP candidates.
11
+ *
12
+ * Just enough to upgrade a Carrier friend session from "tcp-relay only"
13
+ * to "UDP-direct or TURN-relayed", whichever works.
14
+ */
15
+ import { type Socket as DgramSocket } from "dgram";
16
+ import { AddressValue } from "./stun.js";
17
+ export type CandidateKind = "host" | "srflx" | "relay";
18
+ export interface Candidate {
19
+ kind: CandidateKind;
20
+ address: string;
21
+ port: number;
22
+ /** Higher beats lower. Convention: host=10000, srflx=5000, relay=1000. */
23
+ priority: number;
24
+ }
25
+ export interface CandidateOffer {
26
+ candidates: Candidate[];
27
+ ufrag: string;
28
+ password: string;
29
+ generation: number;
30
+ }
31
+ interface BootnodeRef {
32
+ host: string;
33
+ publicKey: Uint8Array;
34
+ }
35
+ interface SignalingHandle {
36
+ send(offer: CandidateOffer): void;
37
+ onOffer(cb: (offer: CandidateOffer) => void): void;
38
+ }
39
+ export interface IceAgentOpts {
40
+ /**
41
+ * The peer's UDP socket. ICE shares it so that once a pair wins, the
42
+ * caller can use the same socket for data traffic — bypassing any
43
+ * extra demultiplexing.
44
+ */
45
+ sock: DgramSocket;
46
+ /** Bootnodes we can use as STUN servers. ≥2 → can detect symmetric NAT. */
47
+ stunBootnodes: BootnodeRef[];
48
+ /** Bootnodes we can use as TURN servers (usually the same list). */
49
+ turnBootnodes: BootnodeRef[];
50
+ /** Our Carrier identity, used for TURN credential derivation. */
51
+ ourUserid: string;
52
+ ourSecretKey: Uint8Array;
53
+ /** How we tell the remote peer our candidates. */
54
+ signaling: SignalingHandle;
55
+ /** Called when ICE picks a winning pair. May be called again on re-run. */
56
+ onSelected: (pair: {
57
+ local: Candidate;
58
+ remote: Candidate;
59
+ }) => void;
60
+ /** Called for raw data received via the TURN relay (DATA-INDICATION). */
61
+ onRelayedData?: (peer: AddressValue, data: Uint8Array) => void;
62
+ }
63
+ export declare class IceLiteAgent {
64
+ #private;
65
+ constructor(opts: IceAgentOpts);
66
+ /** Gather local candidates and ship them via signaling. Idempotent-ish. */
67
+ start(): Promise<void>;
68
+ close(): void;
69
+ }
70
+ export {};
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Minimal ICE-lite agent: gather candidates, exchange via an external
3
+ * signaling channel (Carrier friend-message), connectivity-check each
4
+ * (local, remote) pair, pick the highest-priority working pair as the
5
+ * data path, keepalive, re-run on failure.
6
+ *
7
+ * This is NOT full ICE:
8
+ * - No controlling/controlled negotiation (both sides probe equally).
9
+ * - No aggressive nomination, no Trickle phases.
10
+ * - No TCP candidates.
11
+ *
12
+ * Just enough to upgrade a Carrier friend session from "tcp-relay only"
13
+ * to "UDP-direct or TURN-relayed", whichever works.
14
+ */
15
+ import { networkInterfaces } from "os";
16
+ import { buildBindingRequest, decodeStun, decodeXorMappedAddress, encodeStun, encodeXorMappedAddress, findAttr, newTransactionId, shortTermIntegrityKey, STUN_ATTR_USERNAME, STUN_ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_REQUEST, STUN_BINDING_SUCCESS } from "./stun.js";
17
+ import { TurnClient } from "./turn.js";
18
+ import { deriveCarrierTurnCreds } from "./turn-creds.js";
19
+ import { randomBytes } from "./utils/bytes.js";
20
+ const HOST_PRIORITY = 10_000;
21
+ const SRFLX_PRIORITY = 5_000;
22
+ const RELAY_PRIORITY = 1_000;
23
+ const CHECK_INTERVAL_MS = 200;
24
+ const PING_TIMEOUT_MS = 3_000;
25
+ const KEEPALIVE_MS = 5_000;
26
+ const LOST_THRESHOLD = 3;
27
+ export class IceLiteAgent {
28
+ #opts;
29
+ #ufrag = base64url(randomBytes(6));
30
+ #password = base64url(randomBytes(16));
31
+ #generation = 0;
32
+ #localCandidates = [];
33
+ #remoteOffer;
34
+ #turnByBootnode = new Map();
35
+ #pairs = [];
36
+ #selected;
37
+ #keepaliveTimer;
38
+ #pendingChecks = new Map(); // txnHex -> resolve
39
+ #closed = false;
40
+ constructor(opts) {
41
+ this.#opts = opts;
42
+ this.#opts.sock.on("message", this.#onSocketMessage);
43
+ this.#opts.signaling.onOffer((offer) => this.#onRemoteOffer(offer));
44
+ }
45
+ /** Gather local candidates and ship them via signaling. Idempotent-ish. */
46
+ async start() {
47
+ this.#generation++;
48
+ this.#localCandidates = [];
49
+ // 1. HOST candidates — every non-loopback IPv4 we own
50
+ const localPort = this.#opts.sock.address().port;
51
+ for (const iface of Object.values(networkInterfaces())) {
52
+ if (!iface)
53
+ continue;
54
+ for (const addr of iface) {
55
+ if (addr.family === "IPv4" && !addr.internal) {
56
+ this.#localCandidates.push({
57
+ kind: "host",
58
+ address: addr.address,
59
+ port: localPort,
60
+ priority: HOST_PRIORITY
61
+ });
62
+ }
63
+ }
64
+ }
65
+ // 2. SRFLX candidates — STUN binding-request to ≥2 bootnodes
66
+ const srflxResults = await Promise.all(this.#opts.stunBootnodes.slice(0, 3).map((bn) => this.#probeSrflx(bn.host)));
67
+ const uniqueSrflx = new Map();
68
+ for (const r of srflxResults) {
69
+ if (!r)
70
+ continue;
71
+ const key = `${r.address}:${r.port}`;
72
+ if (!uniqueSrflx.has(key)) {
73
+ uniqueSrflx.set(key, {
74
+ kind: "srflx",
75
+ address: r.address,
76
+ port: r.port,
77
+ priority: SRFLX_PRIORITY
78
+ });
79
+ }
80
+ }
81
+ this.#localCandidates.push(...uniqueSrflx.values());
82
+ // 3. RELAY candidates — one TURN allocation per bootnode (start with one)
83
+ if (this.#opts.turnBootnodes.length > 0) {
84
+ const bn = this.#opts.turnBootnodes[0];
85
+ const creds = deriveCarrierTurnCreds({
86
+ bootnodeHost: bn.host,
87
+ bootnodePublicKey: bn.publicKey,
88
+ ourUserid: this.#opts.ourUserid,
89
+ ourSecretKey: this.#opts.ourSecretKey
90
+ });
91
+ try {
92
+ const turn = await this.#ensureTurnClient(bn.host, creds);
93
+ const allocation = await turn.allocate();
94
+ this.#localCandidates.push({
95
+ kind: "relay",
96
+ address: allocation.relayedAddress.address,
97
+ port: allocation.relayedAddress.port,
98
+ priority: RELAY_PRIORITY
99
+ });
100
+ }
101
+ catch (err) {
102
+ // Best-effort — if TURN fails, we still have host/srflx.
103
+ console.error(`[ice-lite] TURN allocate via ${bn.host} failed:`, err.message);
104
+ }
105
+ }
106
+ // 4. Ship offer to remote
107
+ this.#opts.signaling.send({
108
+ candidates: this.#localCandidates,
109
+ ufrag: this.#ufrag,
110
+ password: this.#password,
111
+ generation: this.#generation
112
+ });
113
+ if (this.#remoteOffer) {
114
+ this.#runChecks();
115
+ }
116
+ }
117
+ close() {
118
+ this.#closed = true;
119
+ if (this.#keepaliveTimer)
120
+ clearInterval(this.#keepaliveTimer);
121
+ this.#opts.sock.off("message", this.#onSocketMessage);
122
+ for (const turn of this.#turnByBootnode.values())
123
+ turn.close();
124
+ this.#turnByBootnode.clear();
125
+ }
126
+ // -- internal -------------------------------------------------------------
127
+ async #probeSrflx(host) {
128
+ return new Promise((resolve) => {
129
+ const req = buildBindingRequest();
130
+ const txnKey = Buffer.from(req.slice(8, 20)).toString("hex");
131
+ const timer = setTimeout(() => {
132
+ this.#pendingChecks.delete(`srflx:${txnKey}`);
133
+ resolve(undefined);
134
+ }, PING_TIMEOUT_MS);
135
+ this.#pendingChecks.set(`srflx:${txnKey}`, () => {
136
+ // resolved out-of-band by #onSocketMessage; here we just clear timer
137
+ clearTimeout(timer);
138
+ });
139
+ // Stash a one-shot resolver
140
+ const oneShot = (data, rinfo) => {
141
+ if (rinfo.address !== host)
142
+ return;
143
+ const msg = decodeStun(data);
144
+ if (!msg || msg.type !== STUN_BINDING_SUCCESS)
145
+ return;
146
+ const xma = findAttr(msg, STUN_ATTR_XOR_MAPPED_ADDRESS);
147
+ if (!xma)
148
+ return;
149
+ const addr = decodeXorMappedAddress(xma, msg.transactionId);
150
+ if (addr) {
151
+ clearTimeout(timer);
152
+ this.#opts.sock.off("message", oneShot);
153
+ this.#pendingChecks.delete(`srflx:${txnKey}`);
154
+ resolve(addr);
155
+ }
156
+ };
157
+ this.#opts.sock.on("message", oneShot);
158
+ this.#opts.sock.send(Buffer.from(req), 3478, host);
159
+ });
160
+ }
161
+ async #ensureTurnClient(bootnodeHost, creds) {
162
+ const existing = this.#turnByBootnode.get(bootnodeHost);
163
+ if (existing)
164
+ return existing;
165
+ const turn = new TurnClient({ sock: this.#opts.sock, creds });
166
+ if (this.#opts.onRelayedData) {
167
+ turn.onData(this.#opts.onRelayedData);
168
+ }
169
+ this.#turnByBootnode.set(bootnodeHost, turn);
170
+ return turn;
171
+ }
172
+ #onRemoteOffer(offer) {
173
+ if (this.#remoteOffer && offer.generation <= this.#remoteOffer.generation)
174
+ return;
175
+ this.#remoteOffer = offer;
176
+ if (this.#localCandidates.length > 0)
177
+ this.#runChecks();
178
+ }
179
+ #runChecks() {
180
+ if (!this.#remoteOffer)
181
+ return;
182
+ const pairs = [];
183
+ for (const local of this.#localCandidates) {
184
+ for (const remote of this.#remoteOffer.candidates) {
185
+ pairs.push({ local, remote, ok: false, lastPingMs: 0, lostInARow: 0 });
186
+ }
187
+ }
188
+ // Sort by combined priority — try best first
189
+ pairs.sort((a, b) => b.local.priority + b.remote.priority - (a.local.priority + a.remote.priority));
190
+ this.#pairs = pairs;
191
+ // Fire connectivity checks (staggered to spread bursts on the same socket)
192
+ let i = 0;
193
+ const interval = setInterval(() => {
194
+ if (this.#closed || i >= pairs.length || this.#selected) {
195
+ clearInterval(interval);
196
+ return;
197
+ }
198
+ const p = pairs[i++];
199
+ this.#sendCheck(p);
200
+ }, CHECK_INTERVAL_MS);
201
+ if (!this.#keepaliveTimer) {
202
+ this.#keepaliveTimer = setInterval(() => this.#keepalive(), KEEPALIVE_MS);
203
+ }
204
+ }
205
+ #sendCheck(p) {
206
+ if (!this.#remoteOffer)
207
+ return;
208
+ const username = `${this.#remoteOffer.ufrag}:${this.#ufrag}`;
209
+ const txn = newTransactionId();
210
+ const pkt = encodeStun({
211
+ type: STUN_BINDING_REQUEST,
212
+ transactionId: txn,
213
+ attributes: [{ type: STUN_ATTR_USERNAME, value: Buffer.from(username, "utf8") }]
214
+ }, { integrityKey: shortTermIntegrityKey(this.#remoteOffer.password), fingerprint: true });
215
+ const key = `check:${Buffer.from(txn).toString("hex")}:${p.remote.address}:${p.remote.port}`;
216
+ p.lastPingMs = Date.now();
217
+ this.#pendingChecks.set(key, () => {
218
+ p.ok = true;
219
+ p.lostInARow = 0;
220
+ if (!this.#selected)
221
+ this.#selectIfBetter(p);
222
+ });
223
+ setTimeout(() => {
224
+ if (this.#pendingChecks.delete(key)) {
225
+ p.lostInARow++;
226
+ }
227
+ }, PING_TIMEOUT_MS);
228
+ this.#opts.sock.send(Buffer.from(pkt), p.remote.port, p.remote.address);
229
+ }
230
+ #selectIfBetter(p) {
231
+ const score = (pp) => pp.local.priority + pp.remote.priority;
232
+ if (!this.#selected) {
233
+ this.#selected = { local: p.local, remote: p.remote };
234
+ this.#opts.onSelected(this.#selected);
235
+ return;
236
+ }
237
+ const current = this.#pairs.find((x) => x.local === this.#selected.local && x.remote === this.#selected.remote);
238
+ if (current && score(p) > score(current)) {
239
+ this.#selected = { local: p.local, remote: p.remote };
240
+ this.#opts.onSelected(this.#selected);
241
+ }
242
+ }
243
+ #keepalive() {
244
+ for (const p of this.#pairs) {
245
+ if (!p.ok)
246
+ continue;
247
+ this.#sendCheck(p);
248
+ if (p.lostInARow >= LOST_THRESHOLD) {
249
+ p.ok = false;
250
+ if (this.#selected &&
251
+ this.#selected.local === p.local &&
252
+ this.#selected.remote === p.remote) {
253
+ this.#selected = undefined;
254
+ // Promote the next still-OK pair, or run discovery again
255
+ const next = this.#pairs.find((x) => x.ok);
256
+ if (next)
257
+ this.#selectIfBetter(next);
258
+ else
259
+ this.start().catch(() => { });
260
+ }
261
+ }
262
+ }
263
+ }
264
+ #onSocketMessage = (data, rinfo) => {
265
+ const msg = decodeStun(data);
266
+ if (!msg)
267
+ return;
268
+ if (msg.type === STUN_BINDING_REQUEST) {
269
+ this.#respondToCheck(msg, rinfo);
270
+ return;
271
+ }
272
+ if (msg.type === STUN_BINDING_SUCCESS) {
273
+ const txn = Buffer.from(msg.transactionId).toString("hex");
274
+ for (const key of this.#pendingChecks.keys()) {
275
+ if (key.startsWith("check:" + txn + ":")) {
276
+ const cb = this.#pendingChecks.get(key);
277
+ this.#pendingChecks.delete(key);
278
+ cb();
279
+ return;
280
+ }
281
+ }
282
+ }
283
+ };
284
+ #respondToCheck(req, rinfo) {
285
+ if (!this.#remoteOffer)
286
+ return;
287
+ const pkt = encodeStun({
288
+ type: STUN_BINDING_SUCCESS,
289
+ transactionId: req.transactionId,
290
+ attributes: [
291
+ {
292
+ type: STUN_ATTR_XOR_MAPPED_ADDRESS,
293
+ value: encodeXorMappedAddress({ family: 4, address: rinfo.address, port: rinfo.port }, req.transactionId)
294
+ }
295
+ ]
296
+ }, { integrityKey: shortTermIntegrityKey(this.#password), fingerprint: true });
297
+ this.#opts.sock.send(Buffer.from(pkt), rinfo.port, rinfo.address);
298
+ }
299
+ }
300
+ function base64url(bytes) {
301
+ return Buffer.from(bytes)
302
+ .toString("base64")
303
+ .replace(/\+/g, "-")
304
+ .replace(/\//g, "_")
305
+ .replace(/=+$/, "");
306
+ }