@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/turn.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Minimal TURN client (RFC 5766 subset) for the Carrier bootnode TURN
3
+ * servers running on UDP/3478. Covers:
4
+ *
5
+ * - ALLOCATE (with the long-term cred dance: 401 → echo realm/nonce → success)
6
+ * - REFRESH (keeps the allocation alive past `lifetime`)
7
+ * - CREATE-PERMISSION (one per peer address we want to send to)
8
+ * - SEND-INDICATION (wraps app data going out via the relay)
9
+ * - DATA-INDICATION (unwraps app data coming in via the relay)
10
+ *
11
+ * Deliberately skipped to keep this small:
12
+ * - ChannelBind / ChannelData framing — minor bandwidth win, more state
13
+ * - TCP TURN, TURN-over-TLS
14
+ * - DontFragment, Even-port, reservation tokens
15
+ *
16
+ * Owns its UDP socket exclusively while a request is in flight. The
17
+ * caller can multiplex by using a separate TurnClient per allocation
18
+ * (one per bootnode), which is what ICE will do.
19
+ */
20
+ import { buildBindingRequest, // re-used as a NAT-probe before TURN allocation
21
+ decodeStun, decodeXorMappedAddress, encodeStun, encodeXorMappedAddress, findAttr, longTermIntegrityKey, newTransactionId, STUN_ATTR_DATA, STUN_ATTR_ERROR_CODE, STUN_ATTR_LIFETIME, STUN_ATTR_NONCE, STUN_ATTR_REALM, STUN_ATTR_REQUESTED_TRANSPORT, STUN_ATTR_SOFTWARE, STUN_ATTR_USERNAME, STUN_ATTR_XOR_PEER_ADDRESS, STUN_ATTR_XOR_RELAYED_ADDRESS, TURN_ALLOCATE_ERROR, TURN_ALLOCATE_REQUEST, TURN_ALLOCATE_SUCCESS, TURN_CREATE_PERMISSION_REQUEST, TURN_CREATE_PERMISSION_SUCCESS, TURN_DATA_INDICATION, TURN_REFRESH_REQUEST, TURN_REFRESH_SUCCESS, TURN_SEND_INDICATION, decodeErrorCode } from "./stun.js";
22
+ const REQUEST_TIMEOUT_MS = 3000;
23
+ const REQUEST_RETRIES = 3;
24
+ const SOFTWARE_NAME = "decentnet-peer/0.1";
25
+ export class TurnClient {
26
+ creds;
27
+ #sock;
28
+ #realm;
29
+ #nonce;
30
+ #integrityKey;
31
+ #allocation;
32
+ #pending = new Map();
33
+ #onData;
34
+ #refreshTimer;
35
+ #closed = false;
36
+ /**
37
+ * The TURN server's resolved IP. We filter inbound datagrams by source
38
+ * so a shared socket doesn't feed us unrelated traffic — but inbound
39
+ * `rinfo.address` is always an IP, while `creds.host` may be a
40
+ * hostname (e.g. "tokyo.fi.chat"). Resolve once and compare against
41
+ * the IP. Until resolved, we fall back to txn-matching only.
42
+ */
43
+ #serverIp;
44
+ constructor(opts) {
45
+ this.#sock = opts.sock;
46
+ this.creds = opts.creds;
47
+ this.#realm = opts.creds.realm;
48
+ // If creds.host is already a dotted-quad, use it directly.
49
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(opts.creds.host)) {
50
+ this.#serverIp = opts.creds.host;
51
+ }
52
+ this.#sock.on("message", this.#onMessage);
53
+ }
54
+ /**
55
+ * Perform the long-term-credential ALLOCATE dance. Returns the
56
+ * server-reflexive (mapped) and relay-allocated addresses, plus the
57
+ * allocation lifetime.
58
+ */
59
+ async allocate() {
60
+ // Resolve the TURN server host to an IP so inbound-source filtering
61
+ // works when creds.host is a hostname.
62
+ if (!this.#serverIp) {
63
+ try {
64
+ const { lookup } = await import("dns/promises");
65
+ const { address } = await lookup(this.creds.host, { family: 4 });
66
+ this.#serverIp = address;
67
+ }
68
+ catch {
69
+ // best-effort; #onMessage falls back to txn-only matching
70
+ }
71
+ }
72
+ // Step 1: send a bare ALLOCATE — server replies 401 with realm/nonce
73
+ const initialAttrs = [
74
+ { type: STUN_ATTR_REQUESTED_TRANSPORT, value: Uint8Array.of(17, 0, 0, 0) }, // 17 = UDP
75
+ { type: STUN_ATTR_SOFTWARE, value: Buffer.from(SOFTWARE_NAME, "utf8") }
76
+ ];
77
+ const first = await this.#request(TURN_ALLOCATE_REQUEST, initialAttrs);
78
+ if (first.type === TURN_ALLOCATE_SUCCESS) {
79
+ return this.#parseAllocateSuccess(first);
80
+ }
81
+ if (first.type !== TURN_ALLOCATE_ERROR) {
82
+ throw new Error(`unexpected ALLOCATE response type 0x${first.type.toString(16)}`);
83
+ }
84
+ const realmAttr = findAttr(first, STUN_ATTR_REALM);
85
+ const nonceAttr = findAttr(first, STUN_ATTR_NONCE);
86
+ if (!realmAttr || !nonceAttr) {
87
+ const err = findAttr(first, STUN_ATTR_ERROR_CODE);
88
+ const decoded = err ? decodeErrorCode(err) : undefined;
89
+ throw new Error(`ALLOCATE 401 missing realm/nonce (code=${decoded?.code} reason=${decoded?.reason})`);
90
+ }
91
+ this.#realm = Buffer.from(realmAttr).toString("utf8");
92
+ this.#nonce = nonceAttr;
93
+ this.#integrityKey = longTermIntegrityKey(this.creds.username, this.#realm, this.creds.password);
94
+ // Step 2: re-send ALLOCATE authenticated
95
+ const authed = await this.#request(TURN_ALLOCATE_REQUEST, [
96
+ { type: STUN_ATTR_REQUESTED_TRANSPORT, value: Uint8Array.of(17, 0, 0, 0) },
97
+ { type: STUN_ATTR_SOFTWARE, value: Buffer.from(SOFTWARE_NAME, "utf8") },
98
+ { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
99
+ { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
100
+ { type: STUN_ATTR_NONCE, value: this.#nonce }
101
+ ]);
102
+ if (authed.type !== TURN_ALLOCATE_SUCCESS) {
103
+ const err = findAttr(authed, STUN_ATTR_ERROR_CODE);
104
+ const decoded = err ? decodeErrorCode(err) : undefined;
105
+ throw new Error(`ALLOCATE failed code=${decoded?.code} reason=${decoded?.reason}`);
106
+ }
107
+ const allocation = this.#parseAllocateSuccess(authed);
108
+ this.#allocation = allocation;
109
+ this.#scheduleRefresh(allocation.lifetime);
110
+ return allocation;
111
+ }
112
+ /**
113
+ * Tell the TURN server we want to send to / receive from `peer`.
114
+ * Required before SEND-INDICATION will actually relay.
115
+ */
116
+ async createPermission(peer) {
117
+ if (!this.#integrityKey || !this.#nonce) {
118
+ throw new Error("createPermission called before allocate()");
119
+ }
120
+ const resp = await this.#request(TURN_CREATE_PERMISSION_REQUEST, [
121
+ { type: STUN_ATTR_XOR_PEER_ADDRESS, value: encodeXorMappedAddress(peer, newTransactionId()) },
122
+ { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
123
+ { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
124
+ { type: STUN_ATTR_NONCE, value: this.#nonce }
125
+ ]);
126
+ if (resp.type !== TURN_CREATE_PERMISSION_SUCCESS) {
127
+ const err = findAttr(resp, STUN_ATTR_ERROR_CODE);
128
+ const decoded = err ? decodeErrorCode(err) : undefined;
129
+ throw new Error(`CREATE-PERMISSION failed code=${decoded?.code} reason=${decoded?.reason}`);
130
+ }
131
+ }
132
+ /**
133
+ * Wrap `data` in a SEND-INDICATION and ship it to the TURN server.
134
+ * Fire-and-forget — TURN indications never get a response.
135
+ */
136
+ sendTo(peer, data) {
137
+ const txn = newTransactionId();
138
+ const pkt = encodeStun({
139
+ type: TURN_SEND_INDICATION,
140
+ transactionId: txn,
141
+ attributes: [
142
+ { type: STUN_ATTR_XOR_PEER_ADDRESS, value: encodeXorMappedAddress(peer, txn) },
143
+ { type: STUN_ATTR_DATA, value: data }
144
+ ]
145
+ });
146
+ this.#sock.send(Buffer.from(pkt), this.creds.port, this.creds.host);
147
+ }
148
+ onData(cb) {
149
+ this.#onData = cb;
150
+ }
151
+ async refresh() {
152
+ if (!this.#integrityKey || !this.#nonce) {
153
+ throw new Error("refresh called before allocate()");
154
+ }
155
+ const resp = await this.#request(TURN_REFRESH_REQUEST, [
156
+ { type: STUN_ATTR_LIFETIME, value: u32Bytes(600) },
157
+ { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
158
+ { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
159
+ { type: STUN_ATTR_NONCE, value: this.#nonce }
160
+ ]);
161
+ if (resp.type !== TURN_REFRESH_SUCCESS) {
162
+ // Stale nonce — server may have rotated. Re-allocate on next call.
163
+ this.#allocation = undefined;
164
+ const err = findAttr(resp, STUN_ATTR_ERROR_CODE);
165
+ const decoded = err ? decodeErrorCode(err) : undefined;
166
+ throw new Error(`REFRESH failed code=${decoded?.code} reason=${decoded?.reason}`);
167
+ }
168
+ const lifetimeAttr = findAttr(resp, STUN_ATTR_LIFETIME);
169
+ const lifetime = lifetimeAttr ? readU32(lifetimeAttr) : 600;
170
+ if (this.#allocation)
171
+ this.#allocation.lifetime = lifetime;
172
+ this.#scheduleRefresh(lifetime);
173
+ }
174
+ close() {
175
+ this.#closed = true;
176
+ if (this.#refreshTimer)
177
+ clearTimeout(this.#refreshTimer);
178
+ this.#sock.off("message", this.#onMessage);
179
+ }
180
+ // -- internal helpers -----------------------------------------------------
181
+ #onMessage = (data, rinfo) => {
182
+ // Filter to our TURN server. Compare against the resolved IP (rinfo
183
+ // is always an IP; creds.host may be a hostname). Wrong port is
184
+ // never ours; wrong IP only filters once resolved.
185
+ if (rinfo.port !== this.creds.port)
186
+ return;
187
+ if (this.#serverIp && rinfo.address !== this.#serverIp)
188
+ return;
189
+ const msg = decodeStun(data);
190
+ if (!msg)
191
+ return;
192
+ if (msg.type === TURN_DATA_INDICATION) {
193
+ const peerAttr = findAttr(msg, STUN_ATTR_XOR_PEER_ADDRESS);
194
+ const dataAttr = findAttr(msg, STUN_ATTR_DATA);
195
+ if (peerAttr && dataAttr && this.#onData) {
196
+ const peer = decodeXorMappedAddress(peerAttr, msg.transactionId);
197
+ if (peer)
198
+ this.#onData(peer, dataAttr);
199
+ }
200
+ return;
201
+ }
202
+ const key = Buffer.from(msg.transactionId).toString("hex");
203
+ const resolve = this.#pending.get(key);
204
+ if (resolve) {
205
+ this.#pending.delete(key);
206
+ resolve(msg);
207
+ }
208
+ };
209
+ async #request(type, attrs) {
210
+ const txn = newTransactionId();
211
+ const key = Buffer.from(txn).toString("hex");
212
+ const integrity = type !== TURN_ALLOCATE_REQUEST || this.#integrityKey ? this.#integrityKey : undefined;
213
+ const pkt = encodeStun({ type, transactionId: txn, attributes: attrs }, integrity
214
+ ? { integrityKey: integrity, fingerprint: false }
215
+ : { fingerprint: false });
216
+ let lastError;
217
+ for (let attempt = 0; attempt < REQUEST_RETRIES; attempt++) {
218
+ const result = await new Promise((resolve) => {
219
+ const timeout = setTimeout(() => {
220
+ this.#pending.delete(key);
221
+ resolve(undefined);
222
+ }, REQUEST_TIMEOUT_MS);
223
+ this.#pending.set(key, (msg) => {
224
+ clearTimeout(timeout);
225
+ resolve(msg);
226
+ });
227
+ this.#sock.send(Buffer.from(pkt), this.creds.port, this.creds.host, (err) => {
228
+ if (err) {
229
+ clearTimeout(timeout);
230
+ this.#pending.delete(key);
231
+ lastError = err;
232
+ resolve(undefined);
233
+ }
234
+ });
235
+ });
236
+ if (result)
237
+ return result;
238
+ }
239
+ throw new Error(`TURN request (type=0x${type.toString(16)}) timed out: ${lastError?.message ?? "no response"}`);
240
+ }
241
+ #parseAllocateSuccess(msg) {
242
+ const relayAttr = findAttr(msg, STUN_ATTR_XOR_RELAYED_ADDRESS);
243
+ if (!relayAttr)
244
+ throw new Error("ALLOCATE success missing XOR-RELAYED-ADDRESS");
245
+ const relayed = decodeXorMappedAddress(relayAttr, msg.transactionId);
246
+ if (!relayed)
247
+ throw new Error("ALLOCATE success has malformed XOR-RELAYED-ADDRESS");
248
+ const mappedAttr = findAttr(msg, 0x0020 /* XOR-MAPPED-ADDRESS */);
249
+ const mapped = mappedAttr ? decodeXorMappedAddress(mappedAttr, msg.transactionId) : undefined;
250
+ const lifetimeAttr = findAttr(msg, STUN_ATTR_LIFETIME);
251
+ const lifetime = lifetimeAttr ? readU32(lifetimeAttr) : 600;
252
+ return { relayedAddress: relayed, mappedAddress: mapped, lifetime };
253
+ }
254
+ #scheduleRefresh(lifetime) {
255
+ if (this.#refreshTimer)
256
+ clearTimeout(this.#refreshTimer);
257
+ if (this.#closed)
258
+ return;
259
+ const ms = Math.max(15_000, (lifetime * 1000) / 2);
260
+ this.#refreshTimer = setTimeout(() => {
261
+ this.refresh().catch((err) => {
262
+ // Log only — next sendTo() may re-trigger allocate via caller.
263
+ console.error("[turn] refresh failed:", err.message);
264
+ });
265
+ }, ms);
266
+ }
267
+ }
268
+ function u32Bytes(n) {
269
+ return Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
270
+ }
271
+ function readU32(b) {
272
+ return ((b[0] << 24) >>> 0) + ((b[1] << 16) >>> 0) + ((b[2] << 8) >>> 0) + b[3];
273
+ }
274
+ // Re-export so callers can use the binding-request bootstrap probe.
275
+ export { buildBindingRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",