@zero-server/sdk 0.9.5 → 0.9.7
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/README.md +54 -64
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +19 -4
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +2 -2
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/package.json +2 -2
- package/types/body.d.ts +1 -1
- package/types/cli.d.ts +1 -1
- package/types/index.d.ts +16 -4
- package/types/middleware.d.ts +1 -1
- package/types/orm.d.ts +3 -3
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/turn/server
|
|
3
|
+
* @description Zero-dependency embedded TURN server (RFC 5766) backing the
|
|
4
|
+
* `TurnServer` public API. Implements the long-term-credential
|
|
5
|
+
* auth flow paired with `issueTurnCredentials` ephemeral
|
|
6
|
+
* accounts, UDP allocations, permissions, Send / Data
|
|
7
|
+
* indications, channel bindings, lifetimes, and per-user
|
|
8
|
+
* quotas.
|
|
9
|
+
*
|
|
10
|
+
* The hot path is intentionally compact: all framing lives in
|
|
11
|
+
* `lib/webrtc/turn/codec.js` and the server only owns state +
|
|
12
|
+
* relay-socket multiplexing. TCP and TLS listeners are reserved in the
|
|
13
|
+
* constructor surface and will be implemented in a later PR; calling
|
|
14
|
+
* `start()` against either currently throws `TURN_TRANSPORT_UNSUPPORTED`.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const { TurnServer, issueTurnCredentials } = require('@zero-server/webrtc');
|
|
18
|
+
*
|
|
19
|
+
* const turn = new TurnServer({
|
|
20
|
+
* secret: process.env.TURN_SECRET,
|
|
21
|
+
* realm: 'rtc.example.com',
|
|
22
|
+
* listeners: [{ proto: 'udp', port: 3478 }],
|
|
23
|
+
* quotas: { maxAllocationsPerUser: 4, maxBytesPerMinute: 50_000_000 },
|
|
24
|
+
* });
|
|
25
|
+
* await turn.start();
|
|
26
|
+
*
|
|
27
|
+
* const creds = issueTurnCredentials({
|
|
28
|
+
* secret: process.env.TURN_SECRET,
|
|
29
|
+
* userId: req.user.id,
|
|
30
|
+
* servers: ['turn:rtc.example.com:3478'],
|
|
31
|
+
* ttl: '20m',
|
|
32
|
+
* });
|
|
33
|
+
* res.json(creds);
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const dgram = require('node:dgram');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
40
|
+
const { EventEmitter } = require('node:events');
|
|
41
|
+
|
|
42
|
+
const { TurnError } = require('../../errors');
|
|
43
|
+
const codec = require('./codec');
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
STUN_CLASS, TURN_METHOD, ATTR, PROTO_UDP,
|
|
47
|
+
CHANNEL_MIN, CHANNEL_MAX,
|
|
48
|
+
encodeMessage, decodeMessage,
|
|
49
|
+
getAttr, getAttrs,
|
|
50
|
+
longTermKey, verifyIntegrity, findIntegrity,
|
|
51
|
+
encodeErrorCode, encodeUInt32, decodeUInt32,
|
|
52
|
+
encodeChannelNumber, decodeChannelNumber,
|
|
53
|
+
encodeChannelData, decodeChannelData, looksLikeChannelData,
|
|
54
|
+
encodeXorAddress, decodeXorAddress,
|
|
55
|
+
endpointKey,
|
|
56
|
+
} = codec;
|
|
57
|
+
|
|
58
|
+
const DEFAULT_LIFETIME = 600; // 10 minutes
|
|
59
|
+
const MAX_LIFETIME = 3600;
|
|
60
|
+
const PERMISSION_LIFETIME = 300; // 5 minutes
|
|
61
|
+
const CHANNEL_LIFETIME = 600;
|
|
62
|
+
const NONCE_LIFETIME_MS = 60_000;
|
|
63
|
+
const SOFTWARE_NAME = 'zero-server-turn';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Embedded TURN server backed by `dgram` sockets.
|
|
67
|
+
*
|
|
68
|
+
* @class TurnServer
|
|
69
|
+
* @extends EventEmitter
|
|
70
|
+
*/
|
|
71
|
+
class TurnServer extends EventEmitter
|
|
72
|
+
{
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.secret
|
|
76
|
+
* Shared HMAC secret for ephemeral credentials produced by
|
|
77
|
+
* {@link module:webrtc/turn/credentials.issueTurnCredentials}.
|
|
78
|
+
* @param {string} [opts.realm='zero-server']
|
|
79
|
+
* @param {Array<{proto:'udp'|'tcp'|'tls',port:number,host?:string,tls?:object}>} opts.listeners
|
|
80
|
+
* @param {object} [opts.quotas]
|
|
81
|
+
* @param {number} [opts.quotas.maxAllocationsPerUser=Infinity]
|
|
82
|
+
* @param {number} [opts.quotas.maxBytesPerMinute=Infinity]
|
|
83
|
+
* @param {number} [opts.defaultLifetime=600]
|
|
84
|
+
* @param {number} [opts.maxLifetime=3600]
|
|
85
|
+
* @param {string} [opts.relayHost='127.0.0.1']
|
|
86
|
+
* Address bound by each per-allocation relay socket. Production
|
|
87
|
+
* deployments should pass the server's public IP.
|
|
88
|
+
*/
|
|
89
|
+
constructor(opts)
|
|
90
|
+
{
|
|
91
|
+
super();
|
|
92
|
+
const o = opts || {};
|
|
93
|
+
if (typeof o.secret !== 'string' || o.secret.length === 0)
|
|
94
|
+
throw new TurnError('TurnServer: opts.secret is required', { code: 'TURN_CONFIG' });
|
|
95
|
+
if (!Array.isArray(o.listeners) || o.listeners.length === 0)
|
|
96
|
+
throw new TurnError('TurnServer: opts.listeners must be a non-empty array', { code: 'TURN_CONFIG' });
|
|
97
|
+
|
|
98
|
+
this._secret = o.secret;
|
|
99
|
+
this.realm = typeof o.realm === 'string' && o.realm.length ? o.realm : 'zero-server';
|
|
100
|
+
this._listeners = o.listeners.map((l) => Object.assign({}, l));
|
|
101
|
+
this._quotas = Object.assign(
|
|
102
|
+
{ maxAllocationsPerUser: Infinity, maxBytesPerMinute: Infinity },
|
|
103
|
+
o.quotas || {},
|
|
104
|
+
);
|
|
105
|
+
this._defaultLifetime = Number.isFinite(o.defaultLifetime) ? o.defaultLifetime : DEFAULT_LIFETIME;
|
|
106
|
+
this._maxLifetime = Number.isFinite(o.maxLifetime) ? o.maxLifetime : MAX_LIFETIME;
|
|
107
|
+
this._relayHost = typeof o.relayHost === 'string' ? o.relayHost : '127.0.0.1';
|
|
108
|
+
|
|
109
|
+
/** @type {Map<string, _Allocation>} */
|
|
110
|
+
this._allocations = new Map();
|
|
111
|
+
/** @type {Map<string, Set<string>>} */
|
|
112
|
+
this._userAllocs = new Map();
|
|
113
|
+
/** @type {Map<string, {windowStart:number, bytes:number}>} */
|
|
114
|
+
this._userBytes = new Map();
|
|
115
|
+
/** @type {Map<string, {value:string, expiresAt:number}>} */
|
|
116
|
+
this._nonces = new Map();
|
|
117
|
+
/** @type {Array<{proto:string, socket:any, address:string, port:number}>} */
|
|
118
|
+
this._bound = [];
|
|
119
|
+
|
|
120
|
+
this._closed = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------------
|
|
124
|
+
// Lifecycle
|
|
125
|
+
// ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bind all configured listeners. Resolves once every listener is
|
|
129
|
+
* listening; rejects on the first bind error.
|
|
130
|
+
*
|
|
131
|
+
* @returns {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
async start()
|
|
134
|
+
{
|
|
135
|
+
if (this._closed) throw new TurnError('TurnServer: already stopped', { code: 'TURN_STOPPED' });
|
|
136
|
+
for (const l of this._listeners)
|
|
137
|
+
{
|
|
138
|
+
if (l.proto !== 'udp')
|
|
139
|
+
throw new TurnError(
|
|
140
|
+
`TurnServer: ${l.proto} listeners are not implemented yet`,
|
|
141
|
+
{ code: 'TURN_TRANSPORT_UNSUPPORTED' },
|
|
142
|
+
);
|
|
143
|
+
const sock = dgram.createSocket(l.family === 6 ? 'udp6' : 'udp4');
|
|
144
|
+
await new Promise((resolve, reject) =>
|
|
145
|
+
{
|
|
146
|
+
sock.once('error', reject);
|
|
147
|
+
sock.bind(l.port, l.host || '127.0.0.1', () =>
|
|
148
|
+
{
|
|
149
|
+
sock.removeListener('error', reject);
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
const addr = sock.address();
|
|
154
|
+
sock.on('message', (msg, rinfo) => this._onClientMessage(sock, msg, rinfo));
|
|
155
|
+
sock.on('error', (err) => this.emit('error', err));
|
|
156
|
+
this._bound.push({ proto: 'udp', socket: sock, address: addr.address, port: addr.port });
|
|
157
|
+
}
|
|
158
|
+
this._sweepInterval = setInterval(() => this._sweep(), 1000);
|
|
159
|
+
if (this._sweepInterval.unref) this._sweepInterval.unref();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Close all listeners and free every allocation's relay socket.
|
|
164
|
+
*
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
async stop()
|
|
168
|
+
{
|
|
169
|
+
this._closed = true;
|
|
170
|
+
if (this._sweepInterval) { clearInterval(this._sweepInterval); this._sweepInterval = null; }
|
|
171
|
+
for (const alloc of this._allocations.values()) this._freeAllocation(alloc);
|
|
172
|
+
this._allocations.clear();
|
|
173
|
+
this._userAllocs.clear();
|
|
174
|
+
const closes = this._bound.map((b) => new Promise((r) => b.socket.close(r)));
|
|
175
|
+
this._bound = [];
|
|
176
|
+
await Promise.all(closes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The bound address of the first listener (handy for tests that ask
|
|
181
|
+
* the kernel for an ephemeral port via `port: 0`).
|
|
182
|
+
*
|
|
183
|
+
* @returns {{address:string, port:number}|null}
|
|
184
|
+
*/
|
|
185
|
+
address()
|
|
186
|
+
{
|
|
187
|
+
return this._bound.length ? { address: this._bound[0].address, port: this._bound[0].port } : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------------------
|
|
191
|
+
// Dispatch
|
|
192
|
+
// ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** @private */
|
|
195
|
+
_onClientMessage(sock, raw, rinfo)
|
|
196
|
+
{
|
|
197
|
+
try
|
|
198
|
+
{
|
|
199
|
+
if (looksLikeChannelData(raw))
|
|
200
|
+
{
|
|
201
|
+
this._handleChannelData(sock, raw, rinfo);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const msg = decodeMessage(raw);
|
|
205
|
+
switch (msg.method)
|
|
206
|
+
{
|
|
207
|
+
case TURN_METHOD.ALLOCATE: this._handleAllocate(sock, raw, msg, rinfo); break;
|
|
208
|
+
case TURN_METHOD.REFRESH: this._handleRefresh(sock, raw, msg, rinfo); break;
|
|
209
|
+
case TURN_METHOD.CREATE_PERMISSION: this._handleCreatePermission(sock, raw, msg, rinfo); break;
|
|
210
|
+
case TURN_METHOD.CHANNEL_BIND: this._handleChannelBind(sock, raw, msg, rinfo); break;
|
|
211
|
+
case TURN_METHOD.SEND: this._handleSend(sock, raw, msg, rinfo); break;
|
|
212
|
+
default:
|
|
213
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Bad Request');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err)
|
|
217
|
+
{
|
|
218
|
+
this.emit('error', err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ------------------------------------------------------------------
|
|
223
|
+
// Auth
|
|
224
|
+
// ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Validate a request that carries a USERNAME / REALM / NONCE / MI set.
|
|
228
|
+
* Returns `{ ok:true, key, username, userId }` on success or
|
|
229
|
+
* `{ ok:false, code, reason, withNonce }` on failure.
|
|
230
|
+
*
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
_checkAuth(raw, msg, rinfo)
|
|
234
|
+
{
|
|
235
|
+
const usernameBuf = getAttr(msg, ATTR.USERNAME);
|
|
236
|
+
const realmBuf = getAttr(msg, ATTR.REALM);
|
|
237
|
+
const nonceBuf = getAttr(msg, ATTR.NONCE);
|
|
238
|
+
const miSpec = findIntegrity(raw);
|
|
239
|
+
if (!usernameBuf || !realmBuf || !nonceBuf || !miSpec)
|
|
240
|
+
return { ok: false, code: 401, reason: 'Unauthorized', withNonce: true };
|
|
241
|
+
|
|
242
|
+
const username = usernameBuf.toString('utf8');
|
|
243
|
+
const realm = realmBuf.toString('utf8');
|
|
244
|
+
const nonce = nonceBuf.toString('utf8');
|
|
245
|
+
|
|
246
|
+
if (realm !== this.realm)
|
|
247
|
+
return { ok: false, code: 441, reason: 'Wrong Credentials' };
|
|
248
|
+
|
|
249
|
+
const stored = this._nonces.get(`${rinfo.address}:${rinfo.port}`);
|
|
250
|
+
if (!stored || stored.value !== nonce || stored.expiresAt <= Date.now())
|
|
251
|
+
return { ok: false, code: 438, reason: 'Stale Nonce', withNonce: true };
|
|
252
|
+
|
|
253
|
+
// ephemeral credentials: username = "<expiry>:<userId>"
|
|
254
|
+
const colon = username.indexOf(':');
|
|
255
|
+
if (colon <= 0)
|
|
256
|
+
return { ok: false, code: 441, reason: 'Wrong Credentials' };
|
|
257
|
+
const expiry = Number(username.slice(0, colon));
|
|
258
|
+
const userId = username.slice(colon + 1);
|
|
259
|
+
if (!Number.isFinite(expiry) || expiry <= Math.floor(Date.now() / 1000))
|
|
260
|
+
return { ok: false, code: 401, reason: 'Expired Credentials', withNonce: true };
|
|
261
|
+
|
|
262
|
+
const password = crypto.createHmac('sha1', this._secret).update(username).digest('base64');
|
|
263
|
+
const key = longTermKey(username, this.realm, password);
|
|
264
|
+
|
|
265
|
+
if (!verifyIntegrity(raw, key, miSpec.value, miSpec.offset))
|
|
266
|
+
return { ok: false, code: 401, reason: 'Bad MAC', withNonce: true };
|
|
267
|
+
|
|
268
|
+
return { ok: true, key, username, userId };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @private */
|
|
272
|
+
_issueNonce(rinfo)
|
|
273
|
+
{
|
|
274
|
+
const value = crypto.randomBytes(12).toString('hex');
|
|
275
|
+
this._nonces.set(`${rinfo.address}:${rinfo.port}`, {
|
|
276
|
+
value, expiresAt: Date.now() + NONCE_LIFETIME_MS,
|
|
277
|
+
});
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @private */
|
|
282
|
+
_sendError(sock, method, txid, rinfo, code, reason, opts)
|
|
283
|
+
{
|
|
284
|
+
const attrs = [
|
|
285
|
+
{ type: ATTR.ERROR_CODE, value: encodeErrorCode(code, reason) },
|
|
286
|
+
];
|
|
287
|
+
if (opts && opts.withNonce)
|
|
288
|
+
{
|
|
289
|
+
attrs.push({ type: ATTR.NONCE, value: Buffer.from(this._issueNonce(rinfo), 'utf8') });
|
|
290
|
+
attrs.push({ type: ATTR.REALM, value: Buffer.from(this.realm, 'utf8') });
|
|
291
|
+
}
|
|
292
|
+
attrs.push({ type: ATTR.SOFTWARE, value: Buffer.from(SOFTWARE_NAME, 'utf8') });
|
|
293
|
+
const buf = encodeMessage(method, STUN_CLASS.ERROR, txid, attrs);
|
|
294
|
+
sock.send(buf, rinfo.port, rinfo.address);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ------------------------------------------------------------------
|
|
298
|
+
// ALLOCATE
|
|
299
|
+
// ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/** @private */
|
|
302
|
+
_handleAllocate(sock, raw, msg, rinfo)
|
|
303
|
+
{
|
|
304
|
+
const clientKey = endpointKey(rinfo.address, rinfo.port);
|
|
305
|
+
if (this._allocations.has(clientKey))
|
|
306
|
+
{
|
|
307
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 437, 'Allocation Mismatch');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const auth = this._checkAuth(raw, msg, rinfo);
|
|
312
|
+
if (!auth.ok)
|
|
313
|
+
{
|
|
314
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo,
|
|
315
|
+
auth.code, auth.reason, { withNonce: auth.withNonce });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const rt = getAttr(msg, ATTR.REQUESTED_TRANSPORT);
|
|
320
|
+
if (!rt || rt.length < 1 || rt[0] !== PROTO_UDP)
|
|
321
|
+
{
|
|
322
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 442, 'Unsupported Transport Protocol');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const userSet = this._userAllocs.get(auth.userId) || new Set();
|
|
327
|
+
if (userSet.size >= this._quotas.maxAllocationsPerUser)
|
|
328
|
+
{
|
|
329
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 486, 'Allocation Quota Reached');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const lifetimeAttr = getAttr(msg, ATTR.LIFETIME);
|
|
334
|
+
const requested = lifetimeAttr ? decodeUInt32(lifetimeAttr) : this._defaultLifetime;
|
|
335
|
+
const lifetime = Math.max(60, Math.min(this._maxLifetime, requested || this._defaultLifetime));
|
|
336
|
+
|
|
337
|
+
const relay = dgram.createSocket('udp4');
|
|
338
|
+
relay.bind({ address: this._relayHost, port: 0 }, () =>
|
|
339
|
+
{
|
|
340
|
+
const ra = relay.address();
|
|
341
|
+
const alloc = {
|
|
342
|
+
clientKey, sock, rinfo: { address: rinfo.address, port: rinfo.port },
|
|
343
|
+
userId: auth.userId, key: auth.key,
|
|
344
|
+
relay, relayAddress: ra.address, relayPort: ra.port,
|
|
345
|
+
permissions: new Map(), // peerIp -> expiresAt
|
|
346
|
+
channels: new Map(), // channel# -> { peerIp, peerPort, expiresAt }
|
|
347
|
+
channelByPeer: new Map(), // "ip:port" -> channel#
|
|
348
|
+
expiresAt: Date.now() + lifetime * 1000,
|
|
349
|
+
};
|
|
350
|
+
this._allocations.set(clientKey, alloc);
|
|
351
|
+
userSet.add(clientKey);
|
|
352
|
+
this._userAllocs.set(auth.userId, userSet);
|
|
353
|
+
|
|
354
|
+
relay.on('message', (data, peerRinfo) => this._onRelayMessage(alloc, data, peerRinfo));
|
|
355
|
+
relay.on('error', (err) => this.emit('error', err));
|
|
356
|
+
|
|
357
|
+
const attrs = [
|
|
358
|
+
{ type: ATTR.XOR_RELAYED_ADDRESS, value: encodeXorAddress(ra.address, ra.port, msg.transactionId) },
|
|
359
|
+
{ type: ATTR.XOR_MAPPED_ADDRESS, value: encodeXorAddress(rinfo.address, rinfo.port, msg.transactionId) },
|
|
360
|
+
{ type: ATTR.LIFETIME, value: encodeUInt32(lifetime) },
|
|
361
|
+
{ type: ATTR.SOFTWARE, value: Buffer.from(SOFTWARE_NAME, 'utf8') },
|
|
362
|
+
];
|
|
363
|
+
const reply = encodeMessage(msg.method, STUN_CLASS.SUCCESS, msg.transactionId, attrs, auth.key);
|
|
364
|
+
sock.send(reply, rinfo.port, rinfo.address);
|
|
365
|
+
this.emit('allocation', { userId: auth.userId, relay: ra, client: rinfo });
|
|
366
|
+
});
|
|
367
|
+
relay.on('error', (err) => this.emit('error', err));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ------------------------------------------------------------------
|
|
371
|
+
// REFRESH
|
|
372
|
+
// ------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/** @private */
|
|
375
|
+
_handleRefresh(sock, raw, msg, rinfo)
|
|
376
|
+
{
|
|
377
|
+
const alloc = this._allocations.get(endpointKey(rinfo.address, rinfo.port));
|
|
378
|
+
if (!alloc)
|
|
379
|
+
{
|
|
380
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 437, 'Allocation Mismatch');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const auth = this._checkAuth(raw, msg, rinfo);
|
|
384
|
+
if (!auth.ok)
|
|
385
|
+
{
|
|
386
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo,
|
|
387
|
+
auth.code, auth.reason, { withNonce: auth.withNonce });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lifetimeAttr = getAttr(msg, ATTR.LIFETIME);
|
|
392
|
+
const requested = lifetimeAttr ? decodeUInt32(lifetimeAttr) : this._defaultLifetime;
|
|
393
|
+
let lifetime;
|
|
394
|
+
if (requested === 0)
|
|
395
|
+
{
|
|
396
|
+
this._freeAllocation(alloc);
|
|
397
|
+
this._allocations.delete(alloc.clientKey);
|
|
398
|
+
const set = this._userAllocs.get(alloc.userId);
|
|
399
|
+
if (set) { set.delete(alloc.clientKey); if (!set.size) this._userAllocs.delete(alloc.userId); }
|
|
400
|
+
lifetime = 0;
|
|
401
|
+
this.emit('deallocation', { userId: alloc.userId, client: rinfo });
|
|
402
|
+
}
|
|
403
|
+
else
|
|
404
|
+
{
|
|
405
|
+
lifetime = Math.max(60, Math.min(this._maxLifetime, requested));
|
|
406
|
+
alloc.expiresAt = Date.now() + lifetime * 1000;
|
|
407
|
+
}
|
|
408
|
+
const reply = encodeMessage(msg.method, STUN_CLASS.SUCCESS, msg.transactionId, [
|
|
409
|
+
{ type: ATTR.LIFETIME, value: encodeUInt32(lifetime) },
|
|
410
|
+
{ type: ATTR.SOFTWARE, value: Buffer.from(SOFTWARE_NAME, 'utf8') },
|
|
411
|
+
], auth.key);
|
|
412
|
+
sock.send(reply, rinfo.port, rinfo.address);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ------------------------------------------------------------------
|
|
416
|
+
// CREATE-PERMISSION
|
|
417
|
+
// ------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
/** @private */
|
|
420
|
+
_handleCreatePermission(sock, raw, msg, rinfo)
|
|
421
|
+
{
|
|
422
|
+
const alloc = this._allocations.get(endpointKey(rinfo.address, rinfo.port));
|
|
423
|
+
if (!alloc)
|
|
424
|
+
{
|
|
425
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 437, 'Allocation Mismatch');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const auth = this._checkAuth(raw, msg, rinfo);
|
|
429
|
+
if (!auth.ok)
|
|
430
|
+
{
|
|
431
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo,
|
|
432
|
+
auth.code, auth.reason, { withNonce: auth.withNonce });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const peerAttrs = getAttrs(msg, ATTR.XOR_PEER_ADDRESS);
|
|
436
|
+
if (peerAttrs.length === 0)
|
|
437
|
+
{
|
|
438
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Bad Request');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const now = Date.now();
|
|
442
|
+
for (const av of peerAttrs)
|
|
443
|
+
{
|
|
444
|
+
const peer = decodeXorAddress(av, msg.transactionId);
|
|
445
|
+
alloc.permissions.set(peer.address, now + PERMISSION_LIFETIME * 1000);
|
|
446
|
+
}
|
|
447
|
+
const reply = encodeMessage(msg.method, STUN_CLASS.SUCCESS, msg.transactionId, [
|
|
448
|
+
{ type: ATTR.SOFTWARE, value: Buffer.from(SOFTWARE_NAME, 'utf8') },
|
|
449
|
+
], auth.key);
|
|
450
|
+
sock.send(reply, rinfo.port, rinfo.address);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ------------------------------------------------------------------
|
|
454
|
+
// CHANNEL-BIND
|
|
455
|
+
// ------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
/** @private */
|
|
458
|
+
_handleChannelBind(sock, raw, msg, rinfo)
|
|
459
|
+
{
|
|
460
|
+
const alloc = this._allocations.get(endpointKey(rinfo.address, rinfo.port));
|
|
461
|
+
if (!alloc)
|
|
462
|
+
{
|
|
463
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 437, 'Allocation Mismatch');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const auth = this._checkAuth(raw, msg, rinfo);
|
|
467
|
+
if (!auth.ok)
|
|
468
|
+
{
|
|
469
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo,
|
|
470
|
+
auth.code, auth.reason, { withNonce: auth.withNonce });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const chAttr = getAttr(msg, ATTR.CHANNEL_NUMBER);
|
|
474
|
+
const peerAttr = getAttr(msg, ATTR.XOR_PEER_ADDRESS);
|
|
475
|
+
if (!chAttr || !peerAttr)
|
|
476
|
+
{
|
|
477
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Bad Request');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const channel = decodeChannelNumber(chAttr);
|
|
481
|
+
if (channel < CHANNEL_MIN || channel > CHANNEL_MAX)
|
|
482
|
+
{
|
|
483
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Bad Channel Number');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const peer = decodeXorAddress(peerAttr, msg.transactionId);
|
|
487
|
+
const peerKey = endpointKey(peer.address, peer.port);
|
|
488
|
+
const existing = alloc.channels.get(channel);
|
|
489
|
+
if (existing && (existing.peerIp !== peer.address || existing.peerPort !== peer.port))
|
|
490
|
+
{
|
|
491
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Channel In Use');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const otherChan = alloc.channelByPeer.get(peerKey);
|
|
495
|
+
if (otherChan && otherChan !== channel)
|
|
496
|
+
{
|
|
497
|
+
this._sendError(sock, msg.method, msg.transactionId, rinfo, 400, 'Peer Already Bound');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
alloc.channels.set(channel, {
|
|
501
|
+
peerIp: peer.address, peerPort: peer.port,
|
|
502
|
+
expiresAt: Date.now() + CHANNEL_LIFETIME * 1000,
|
|
503
|
+
});
|
|
504
|
+
alloc.channelByPeer.set(peerKey, channel);
|
|
505
|
+
alloc.permissions.set(peer.address, Date.now() + PERMISSION_LIFETIME * 1000);
|
|
506
|
+
|
|
507
|
+
const reply = encodeMessage(msg.method, STUN_CLASS.SUCCESS, msg.transactionId, [
|
|
508
|
+
{ type: ATTR.SOFTWARE, value: Buffer.from(SOFTWARE_NAME, 'utf8') },
|
|
509
|
+
], auth.key);
|
|
510
|
+
sock.send(reply, rinfo.port, rinfo.address);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ------------------------------------------------------------------
|
|
514
|
+
// SEND (indication)
|
|
515
|
+
// ------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/** @private */
|
|
518
|
+
_handleSend(sock, raw, msg, rinfo)
|
|
519
|
+
{
|
|
520
|
+
const alloc = this._allocations.get(endpointKey(rinfo.address, rinfo.port));
|
|
521
|
+
if (!alloc) return; // indications are unauthenticated, silent drop
|
|
522
|
+
const peerAttr = getAttr(msg, ATTR.XOR_PEER_ADDRESS);
|
|
523
|
+
const data = getAttr(msg, ATTR.DATA);
|
|
524
|
+
if (!peerAttr || !data) return;
|
|
525
|
+
const peer = decodeXorAddress(peerAttr, msg.transactionId);
|
|
526
|
+
const exp = alloc.permissions.get(peer.address);
|
|
527
|
+
if (!exp || exp <= Date.now()) return;
|
|
528
|
+
if (!this._chargeBytes(alloc.userId, data.length)) return;
|
|
529
|
+
alloc.relay.send(data, peer.port, peer.address);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ------------------------------------------------------------------
|
|
533
|
+
// ChannelData (no STUN header)
|
|
534
|
+
// ------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
/** @private */
|
|
537
|
+
_handleChannelData(sock, raw, rinfo)
|
|
538
|
+
{
|
|
539
|
+
const alloc = this._allocations.get(endpointKey(rinfo.address, rinfo.port));
|
|
540
|
+
if (!alloc) return;
|
|
541
|
+
const cd = decodeChannelData(raw);
|
|
542
|
+
if (!cd) return;
|
|
543
|
+
const bind = alloc.channels.get(cd.channel);
|
|
544
|
+
if (!bind || bind.expiresAt <= Date.now()) return;
|
|
545
|
+
if (!this._chargeBytes(alloc.userId, cd.payload.length)) return;
|
|
546
|
+
alloc.relay.send(cd.payload, bind.peerPort, bind.peerIp);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ------------------------------------------------------------------
|
|
550
|
+
// Relay -> client
|
|
551
|
+
// ------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
/** @private */
|
|
554
|
+
_onRelayMessage(alloc, data, peerRinfo)
|
|
555
|
+
{
|
|
556
|
+
const exp = alloc.permissions.get(peerRinfo.address);
|
|
557
|
+
if (!exp || exp <= Date.now()) return;
|
|
558
|
+
if (!this._chargeBytes(alloc.userId, data.length)) return;
|
|
559
|
+
|
|
560
|
+
const channel = alloc.channelByPeer.get(endpointKey(peerRinfo.address, peerRinfo.port));
|
|
561
|
+
if (channel)
|
|
562
|
+
{
|
|
563
|
+
const frame = encodeChannelData(channel, data);
|
|
564
|
+
alloc.sock.send(frame, alloc.rinfo.port, alloc.rinfo.address);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const txid = crypto.randomBytes(12);
|
|
568
|
+
const attrs = [
|
|
569
|
+
{ type: ATTR.XOR_PEER_ADDRESS, value: encodeXorAddress(peerRinfo.address, peerRinfo.port, txid) },
|
|
570
|
+
{ type: ATTR.DATA, value: data },
|
|
571
|
+
];
|
|
572
|
+
const ind = encodeMessage(TURN_METHOD.DATA, STUN_CLASS.INDICATION, txid, attrs);
|
|
573
|
+
alloc.sock.send(ind, alloc.rinfo.port, alloc.rinfo.address);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ------------------------------------------------------------------
|
|
577
|
+
// Quotas + sweep
|
|
578
|
+
// ------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
/** @private */
|
|
581
|
+
_chargeBytes(userId, n)
|
|
582
|
+
{
|
|
583
|
+
const cap = this._quotas.maxBytesPerMinute;
|
|
584
|
+
if (!Number.isFinite(cap)) return true;
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
let q = this._userBytes.get(userId);
|
|
587
|
+
if (!q || now - q.windowStart >= 60_000)
|
|
588
|
+
{
|
|
589
|
+
q = { windowStart: now, bytes: 0 };
|
|
590
|
+
this._userBytes.set(userId, q);
|
|
591
|
+
}
|
|
592
|
+
if (q.bytes + n > cap) return false;
|
|
593
|
+
q.bytes += n;
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** @private */
|
|
598
|
+
_freeAllocation(alloc)
|
|
599
|
+
{
|
|
600
|
+
try { alloc.relay.close(); } catch (_) { /* ignore */ }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** @private */
|
|
604
|
+
_sweep()
|
|
605
|
+
{
|
|
606
|
+
const now = Date.now();
|
|
607
|
+
for (const [k, alloc] of this._allocations)
|
|
608
|
+
{
|
|
609
|
+
if (alloc.expiresAt <= now)
|
|
610
|
+
{
|
|
611
|
+
this._freeAllocation(alloc);
|
|
612
|
+
this._allocations.delete(k);
|
|
613
|
+
const set = this._userAllocs.get(alloc.userId);
|
|
614
|
+
if (set) { set.delete(k); if (!set.size) this._userAllocs.delete(alloc.userId); }
|
|
615
|
+
this.emit('deallocation', { userId: alloc.userId, client: alloc.rinfo, reason: 'expired' });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
for (const [ip, exp] of alloc.permissions)
|
|
619
|
+
if (exp <= now) alloc.permissions.delete(ip);
|
|
620
|
+
for (const [chan, b] of alloc.channels)
|
|
621
|
+
{
|
|
622
|
+
if (b.expiresAt <= now)
|
|
623
|
+
{
|
|
624
|
+
alloc.channels.delete(chan);
|
|
625
|
+
alloc.channelByPeer.delete(endpointKey(b.peerIp, b.peerPort));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const [k, n] of this._nonces) if (n.expiresAt <= now) this._nonces.delete(k);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
module.exports = { TurnServer };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zero-server/sdk",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Zero-dependency backend framework for Node.js
|
|
3
|
+
"version": "0.9.7",
|
|
4
|
+
"description": "Zero-dependency backend framework for Node.js - routing, ORM, auth, WebSocket, SSE, gRPC, observability, and 20+ middleware. Distributed as a single SDK and as scoped @zero-server/* packages.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zh": "lib/cli.js",
|
package/types/body.d.ts
CHANGED
package/types/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Re-exports for @zero-server/cli
|
|
1
|
+
// Re-exports for @zero-server/cli - CLI runner types
|
|
2
2
|
export { CLI, runCLI } from './orm';
|