@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/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.
|
|
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",
|