tabby-rdp 0.4.1 → 0.5.1
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/CHANGELOG.md +580 -0
- package/README.md +252 -34
- package/dist/accountForm.js +0 -1
- package/dist/accounts.js +0 -1
- package/dist/audio.js +0 -1
- package/dist/authorities.js +331 -0
- package/dist/byteQueue.js +102 -0
- package/dist/clipboard.js +106 -0
- package/dist/connectionStatus.js +0 -1
- package/dist/desk.js +16 -47
- package/dist/deskScript.js +196 -6
- package/dist/desktop.service.js +1763 -204
- package/dist/desktopForm.js +72 -13
- package/dist/desktops.js +243 -19
- package/dist/drives.js +525 -0
- package/dist/fileTransfer.js +1064 -72
- package/dist/gateway.js +651 -0
- package/dist/header.js +76 -5
- package/dist/help.js +34 -13
- package/dist/hyperv.js +129 -0
- package/dist/index.js +2 -2
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +225 -0
- package/dist/osd.js +56 -15
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +468 -52
- package/dist/rdpFile.js +53 -4
- package/dist/rdpProfile.js +75 -22
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +117 -27
- package/dist/signin.js +429 -54
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +50 -6
- package/dist/targets.js +185 -39
- package/dist/ui.js +98 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +37 -2
- package/dist/vms.js +36 -5
- package/dist/wake.js +31 -12
- package/package.json +4 -3
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/iron-remote-desktop-rdp.js +418 -390
- package/vendor/ironrdp_web_bg.wasm +0 -0
- package/dist/accountForm.js.map +0 -1
- package/dist/accounts.js.map +0 -1
- package/dist/audio.js.map +0 -1
- package/dist/connectionStatus.js.map +0 -1
- package/dist/desk.js.map +0 -1
- package/dist/deskScript.js.map +0 -1
- package/dist/desktop.service.js.map +0 -1
- package/dist/desktopForm.js.map +0 -1
- package/dist/desktops.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keyboard.js.map +0 -1
- package/dist/microphone.js.map +0 -1
- package/dist/osd.js.map +0 -1
- package/dist/probe.js.map +0 -1
- package/dist/rdcleanpath.js.map +0 -1
- package/dist/rdpFile.js.map +0 -1
- package/dist/rdpProfile.js.map +0 -1
- package/dist/remoteSetup.js.map +0 -1
- package/dist/settingsPage.js.map +0 -1
- package/dist/signin.js.map +0 -1
- package/dist/ssh.js.map +0 -1
- package/dist/sshChannelStream.js.map +0 -1
- package/dist/targets.js.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/updates.js.map +0 -1
- package/dist/vms.js.map +0 -1
- package/dist/wake.js.map +0 -1
package/dist/rdcleanpath.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FLOW_WINDOW = void 0;
|
|
3
4
|
exports.normalizeFingerprint = normalizeFingerprint;
|
|
5
|
+
exports.preconnectionPdu = preconnectionPdu;
|
|
6
|
+
exports.offersNla = offersNla;
|
|
7
|
+
exports.earlyUserAuthorization = earlyUserAuthorization;
|
|
8
|
+
exports.withoutUserCookie = withoutUserCookie;
|
|
4
9
|
exports.serverFraming = serverFraming;
|
|
10
|
+
exports.signsNothing = signsNothing;
|
|
11
|
+
exports.withoutToken = withoutToken;
|
|
5
12
|
exports.startRDCleanPathProxy = startRDCleanPathProxy;
|
|
6
13
|
// RDCleanPath proxy for the IronRDP web client, running inside Tabby's renderer.
|
|
7
14
|
// The WASM client talks WebSocket to this proxy; the proxy opens the upstream stream
|
|
@@ -9,6 +16,9 @@ exports.startRDCleanPathProxy = startRDCleanPathProxy;
|
|
|
9
16
|
// terminates TLS, checks the server's certificate, hands the cert chain back to the client (for CredSSP), then relays.
|
|
10
17
|
const tls = require("tls");
|
|
11
18
|
const crypto_1 = require("crypto");
|
|
19
|
+
const net_1 = require("net");
|
|
20
|
+
const authorities_1 = require("./authorities");
|
|
21
|
+
const byteQueue_1 = require("./byteQueue");
|
|
12
22
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
13
23
|
const { WebSocketServer } = require('ws');
|
|
14
24
|
const VERSION_1 = 3390;
|
|
@@ -21,6 +31,16 @@ function normalizeFingerprint(value) {
|
|
|
21
31
|
function fingerprintOf(der) {
|
|
22
32
|
return normalizeFingerprint((0, crypto_1.createHash)('sha256').update(der).digest('hex'));
|
|
23
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* What answers to the remote's requests may have taken of the window's memory, read for it or handed to the connection,
|
|
36
|
+
* beyond what the proxy has read from the connection (see ServerFlow): four of the largest parts of files a request
|
|
37
|
+
* gets. A link stays busy with far less, as the proxy lets no more than HIGH_WATER wait for the server.
|
|
38
|
+
*/
|
|
39
|
+
exports.FLOW_WINDOW = 64 * 1024 * 1024;
|
|
40
|
+
/** The answer to a server without Network Level Authentication where nobody decides otherwise: refused. */
|
|
41
|
+
function refuseWithoutNla() {
|
|
42
|
+
throw new Error('the server doesn\'t use Network Level Authentication');
|
|
43
|
+
}
|
|
24
44
|
// ---- minimal DER -------------------------------------------------------------------------------
|
|
25
45
|
function derLength(n) {
|
|
26
46
|
if (n < 0x80) {
|
|
@@ -89,6 +109,9 @@ function decodeRequest(buf) {
|
|
|
89
109
|
case 3:
|
|
90
110
|
req.proxyAuth = value.toString('utf8');
|
|
91
111
|
break;
|
|
112
|
+
case 5:
|
|
113
|
+
req.pcb = value.toString('utf8');
|
|
114
|
+
break;
|
|
92
115
|
case 6:
|
|
93
116
|
req.x224 = Buffer.from(value);
|
|
94
117
|
break;
|
|
@@ -97,8 +120,24 @@ function decodeRequest(buf) {
|
|
|
97
120
|
}
|
|
98
121
|
return req;
|
|
99
122
|
}
|
|
123
|
+
/** The response: the server's X.224 confirm (none for a Hyper-V VM's console), its certificate chain, its address. */
|
|
100
124
|
function encodeResponse(x224, certChain, serverAddr) {
|
|
101
|
-
return seq(explicit(0, derInteger(VERSION_1)), explicit(6, octets(x224)), explicit(7, seq(...certChain.map(octets))), explicit(9, utf8(serverAddr)));
|
|
125
|
+
return seq(explicit(0, derInteger(VERSION_1)), ...x224 ? [explicit(6, octets(x224))] : [], explicit(7, seq(...certChain.map(octets))), explicit(9, utf8(serverAddr)));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A version 2 pre-connection PDU ([MS-RDPEPS] 2.2.1.2) carrying `text`: what a client sends first to a server that
|
|
129
|
+
* routes by it, as Hyper-V does to a VM's console by the VM's id.
|
|
130
|
+
*/
|
|
131
|
+
function preconnectionPdu(text) {
|
|
132
|
+
const blob = Buffer.from(text + '\0', 'utf16le');
|
|
133
|
+
const pdu = Buffer.alloc(18 + blob.length);
|
|
134
|
+
pdu.writeUInt32LE(pdu.length, 0); // cbSize
|
|
135
|
+
pdu.writeUInt32LE(0, 4); // Flags
|
|
136
|
+
pdu.writeUInt32LE(2, 8); // Version
|
|
137
|
+
pdu.writeUInt32LE(0, 12); // Id
|
|
138
|
+
pdu.writeUInt16LE(blob.length / 2, 16); // cchPCB, with the terminator
|
|
139
|
+
blob.copy(pdu, 18);
|
|
140
|
+
return pdu;
|
|
102
141
|
}
|
|
103
142
|
function encodeError() {
|
|
104
143
|
return seq(explicit(0, derInteger(VERSION_1)), explicit(1, seq(explicit(0, derInteger(GENERAL_ERROR)))));
|
|
@@ -107,33 +146,46 @@ function encodeError() {
|
|
|
107
146
|
// Reads exactly one TPKT-framed PDU (the X.224 Connection Confirm) without consuming TLS bytes.
|
|
108
147
|
function readTpkt(stream) {
|
|
109
148
|
return new Promise((resolve, reject) => {
|
|
110
|
-
|
|
149
|
+
const acc = new byteQueue_1.ByteQueue();
|
|
111
150
|
const cleanup = () => {
|
|
112
151
|
stream.off('data', onData);
|
|
113
152
|
stream.off('error', onError);
|
|
114
153
|
stream.off('close', onClose);
|
|
154
|
+
stream.off('end', onClose);
|
|
115
155
|
};
|
|
116
156
|
const onError = (e) => { cleanup(); reject(e); };
|
|
117
157
|
const onClose = () => { cleanup(); reject(new Error('upstream closed during X.224')); };
|
|
118
158
|
const onData = (chunk) => {
|
|
119
|
-
acc
|
|
159
|
+
acc.push(chunk);
|
|
120
160
|
if (acc.length < 4) {
|
|
121
161
|
return;
|
|
122
162
|
}
|
|
123
|
-
const
|
|
163
|
+
const header = acc.peek(4);
|
|
164
|
+
const len = header.readUInt16BE(2);
|
|
165
|
+
// TPKT is version 3, and an X.224 TPDU takes three bytes at least: anything else is not an RDP server
|
|
166
|
+
// answering (a web server on that port, say), and isn't waited on for the length it seems to give.
|
|
167
|
+
if (header[0] !== 3 || len < 7) {
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(new Error('the server\'s answer isn\'t RDP (is that the desktop\'s port?)'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
124
172
|
if (acc.length < len) {
|
|
125
173
|
return;
|
|
126
174
|
}
|
|
127
175
|
stream.pause();
|
|
128
176
|
cleanup();
|
|
129
|
-
|
|
130
|
-
|
|
177
|
+
// A copy: it is kept for the connection, and is a few bytes of the queue's storage.
|
|
178
|
+
const pdu = Buffer.from(acc.take(len));
|
|
179
|
+
if (acc.length) {
|
|
180
|
+
stream.unshift(acc.take(acc.length));
|
|
131
181
|
}
|
|
132
|
-
resolve(
|
|
182
|
+
resolve(pdu);
|
|
133
183
|
};
|
|
134
184
|
stream.on('data', onData);
|
|
135
185
|
stream.on('error', onError);
|
|
136
186
|
stream.on('close', onClose);
|
|
187
|
+
// An SSH channel says when the server has closed its side ("end") without always saying it is closed.
|
|
188
|
+
stream.on('end', onClose);
|
|
137
189
|
});
|
|
138
190
|
}
|
|
139
191
|
function certChainOf(socket) {
|
|
@@ -147,6 +199,62 @@ function certChainOf(socket) {
|
|
|
147
199
|
}
|
|
148
200
|
return chain;
|
|
149
201
|
}
|
|
202
|
+
/** selectedProtocol's flags in the server's negotiation response (RDP_NEG_RSP). */
|
|
203
|
+
const PROTOCOL_HYBRID = 0x02;
|
|
204
|
+
const PROTOCOL_HYBRID_EX = 0x08;
|
|
205
|
+
/**
|
|
206
|
+
* The security protocol the server chose (RDP_NEG_RSP's selectedProtocol), or 0 when its answer to the connection
|
|
207
|
+
* request has no negotiation response. Tested by its flags, as IronRDP does, so that the proxy and the client never
|
|
208
|
+
* read the same answer differently.
|
|
209
|
+
*/
|
|
210
|
+
function selectedProtocol(x224) {
|
|
211
|
+
return x224.length >= 19 && x224[11] === 0x02 ? x224.readUInt32LE(15) : 0;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Whether the server's answer to the connection request chose Network Level Authentication (CredSSP: HYBRID or
|
|
215
|
+
* HYBRID_EX). With it, the sign-in is a proof both ways and the password only goes to a server that knows it already.
|
|
216
|
+
* Without it (plain TLS, as xrdp does), the client sends the password itself in its Client Info PDU: encrypted on the
|
|
217
|
+
* way, readable to whatever answered.
|
|
218
|
+
*/
|
|
219
|
+
function offersNla(x224) {
|
|
220
|
+
return (selectedProtocol(x224) & (PROTOCOL_HYBRID | PROTOCOL_HYBRID_EX)) !== 0;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether an Early User Authorization Result follows CredSSP: the server chose HYBRID_EX (see serverFraming). IronRDP
|
|
224
|
+
* expects one whenever that flag is set, alone or not.
|
|
225
|
+
*/
|
|
226
|
+
function earlyUserAuthorization(x224) {
|
|
227
|
+
return (selectedProtocol(x224) & PROTOCOL_HYBRID_EX) !== 0;
|
|
228
|
+
}
|
|
229
|
+
/** The routing cookie that names the user (see withoutUserCookie). */
|
|
230
|
+
const COOKIE = Buffer.from('Cookie: mstshash=');
|
|
231
|
+
/**
|
|
232
|
+
* The client's X.224 connection request without its routing cookie, `Cookie: mstshash=<user name>`, which IronRDP fills
|
|
233
|
+
* in with the user name. It goes before TLS, in the clear, and before the certificate check and the stop at a server
|
|
234
|
+
* without Network Level Authentication: the account's name would reach whoever watches the way there, and servers the
|
|
235
|
+
* proxy then refuses. The cookie is optional, and what it is for, routing by user name in a load balancer in front of
|
|
236
|
+
* several servers, the plugin doesn't take part in (it sends no load-balancing info either). A request that isn't a
|
|
237
|
+
* connection request with such a cookie comes back as it is; so does a broker's routing token (`Cookie: msts=`), which
|
|
238
|
+
* names no user. The proxy sends none that still has the cookie (see handshake).
|
|
239
|
+
*/
|
|
240
|
+
function withoutUserCookie(request) {
|
|
241
|
+
// TPKT (4: version 3, 0, length), then X.224: length indicator (the rest of the header), the CR code, DST-REF,
|
|
242
|
+
// SRC-REF and class (6), then the cookie, the negotiation request and the rest.
|
|
243
|
+
const li = request[4];
|
|
244
|
+
if (request.length < 11 + COOKIE.length || request[0] !== 3 || request.readUInt16BE(2) !== request.length ||
|
|
245
|
+
(request[5] & 0xf0) !== 0xe0 || 5 + li !== request.length || !request.subarray(11, 11 + COOKIE.length).equals(COOKIE)) {
|
|
246
|
+
return request;
|
|
247
|
+
}
|
|
248
|
+
const end = request.indexOf('\r\n', 11 + COOKIE.length);
|
|
249
|
+
if (end < 0) {
|
|
250
|
+
return request;
|
|
251
|
+
}
|
|
252
|
+
const cut = end + 2 - 11;
|
|
253
|
+
const out = Buffer.concat([request.subarray(0, 11), request.subarray(end + 2)]);
|
|
254
|
+
out.writeUInt16BE(request.length - cut, 2);
|
|
255
|
+
out[4] = li - cut;
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
150
258
|
/**
|
|
151
259
|
* The server's answer to the client's security negotiation, when it rules out TLS (which IronRDP and RDCleanPath
|
|
152
260
|
* need): a server with RDP's standard security only (xrdp with security_layer=rdp, for example) answers without a
|
|
@@ -235,9 +343,11 @@ function autologon() {
|
|
|
235
343
|
* function from server data to the PDUs it completes; it throws on data that is none of these.
|
|
236
344
|
*/
|
|
237
345
|
function serverFraming(earlyAuth) {
|
|
238
|
-
|
|
346
|
+
// A server's CredSSP messages are small: its NTLM challenge or Kerberos reply, and its proof of the public key, a
|
|
347
|
+
// few kilobytes at most. A larger claim is no server's: it would only have Tabby keep whatever comes, and wait.
|
|
348
|
+
const MAX_DER = 256 * 1024;
|
|
239
349
|
let rdp = false;
|
|
240
|
-
|
|
350
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
241
351
|
const invalid = () => new Error('invalid RDP server frame');
|
|
242
352
|
/** The length of the PDU at the start of `b`, 0 while its header is incomplete. */
|
|
243
353
|
const lengthOf = (b) => {
|
|
@@ -296,24 +406,23 @@ function serverFraming(earlyAuth) {
|
|
|
296
406
|
throw invalid();
|
|
297
407
|
};
|
|
298
408
|
return data => {
|
|
299
|
-
pending
|
|
409
|
+
pending.push(data);
|
|
300
410
|
const out = [];
|
|
301
411
|
for (;;) {
|
|
302
|
-
|
|
412
|
+
// Six bytes hold any of these headers: DER's tag and length with up to four length octets.
|
|
413
|
+
const head = pending.peek(6);
|
|
414
|
+
const length = lengthOf(head);
|
|
303
415
|
if (!length || pending.length < length) {
|
|
304
416
|
break;
|
|
305
417
|
}
|
|
306
|
-
if (!rdp && earlyAuth &&
|
|
418
|
+
if (!rdp && earlyAuth && head[0] !== 0x30) {
|
|
307
419
|
earlyAuth = false;
|
|
308
420
|
}
|
|
309
|
-
else if (
|
|
421
|
+
else if (head[0] === 0x03) {
|
|
310
422
|
rdp = true;
|
|
311
423
|
}
|
|
312
|
-
out.push(pending.
|
|
313
|
-
pending = pending.subarray(length);
|
|
424
|
+
out.push(pending.take(length));
|
|
314
425
|
}
|
|
315
|
-
// A copy of the rest: it would otherwise keep the whole chunk it came in alive.
|
|
316
|
-
pending = Buffer.from(pending);
|
|
317
426
|
return out;
|
|
318
427
|
};
|
|
319
428
|
}
|
|
@@ -328,22 +437,136 @@ const KEY_ENCIPHERMENT_ONLY_TLS = {
|
|
|
328
437
|
maxVersion: 'TLSv1.2',
|
|
329
438
|
ciphers: 'AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256',
|
|
330
439
|
};
|
|
440
|
+
/**
|
|
441
|
+
* The TLS library turned the server's certificate down for its key usage, the one failure KEY_ENCIPHERMENT_ONLY_TLS is
|
|
442
|
+
* for. Known by the library's own reason (Node's code is made from it), never by an error's text: that can carry what
|
|
443
|
+
* a gateway or an SSH host put in it, which would take forward secrecy away from a server that has no need to lose it.
|
|
444
|
+
* And only when the TLS socket raised it itself: an error the stream under it failed with reaches the TLS socket as it
|
|
445
|
+
* is, code and all (Electron's BoringSSL raises its own refusal with that stream untouched).
|
|
446
|
+
*/
|
|
447
|
+
class KeyUsageRefused extends Error {
|
|
448
|
+
}
|
|
449
|
+
const isKeyUsageRefusal = (e) => e?.code === 'ERR_SSL_KEY_USAGE_BIT_INCORRECT' || e?.reason === 'KEY_USAGE_BIT_INCORRECT';
|
|
450
|
+
const KEY_USAGE = Buffer.from([0x55, 0x1d, 0x0f]); // the key usage extension's OID, 2.5.29.15
|
|
451
|
+
/**
|
|
452
|
+
* Whether a certificate (DER) shows that it leaves digital signatures out of its key usage, as Windows' self-signed RDP
|
|
453
|
+
* certificate does (key and data encipherment only): the certificates KEY_ENCIPHERMENT_ONLY_TLS is for. False for one
|
|
454
|
+
* that allows them, has no key usage at all (anything goes), or can't be read here: the fallback, which gives up
|
|
455
|
+
* forward secrecy, is only for a certificate shown to need it.
|
|
456
|
+
*/
|
|
457
|
+
function signsNothing(der) {
|
|
458
|
+
const at = (offset, end) => {
|
|
459
|
+
const t = readTlv(der, offset);
|
|
460
|
+
if (!t || t.end > end) {
|
|
461
|
+
throw new Error('not DER');
|
|
462
|
+
}
|
|
463
|
+
return t;
|
|
464
|
+
};
|
|
465
|
+
const children = (parent) => {
|
|
466
|
+
const list = [];
|
|
467
|
+
for (let p = parent.start; p < parent.end; p = list[list.length - 1].end) {
|
|
468
|
+
list.push(at(p, parent.end));
|
|
469
|
+
}
|
|
470
|
+
return list;
|
|
471
|
+
};
|
|
472
|
+
try {
|
|
473
|
+
// Certificate: SEQUENCE { tbsCertificate: SEQUENCE { ..., extensions: [3] { SEQUENCE OF Extension } }, ... }
|
|
474
|
+
const certificate = at(0, der.length);
|
|
475
|
+
for (const field of children(at(certificate.start, certificate.end))) {
|
|
476
|
+
if (field.tag !== 0xa3) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
for (const extension of children(at(field.start, field.end))) {
|
|
480
|
+
// Extension: SEQUENCE { extnID OID, critical BOOLEAN (optional), extnValue OCTET STRING }
|
|
481
|
+
const [id, ...rest] = children(extension);
|
|
482
|
+
if (id.tag === 0x06 && der.subarray(id.start, id.end).equals(KEY_USAGE)) {
|
|
483
|
+
const value = rest[rest.length - 1];
|
|
484
|
+
const bits = at(value.start, value.end);
|
|
485
|
+
if (bits.tag !== 0x03) {
|
|
486
|
+
throw new Error('not a key usage');
|
|
487
|
+
}
|
|
488
|
+
// A BIT STRING: the count of unused bits, then the bits; digitalSignature is the first.
|
|
489
|
+
return bits.end - bits.start < 2 || !(der[bits.start + 1] & 0x80);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
331
499
|
class NoTlsError extends Error {
|
|
332
500
|
}
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
501
|
+
/** The client's first message (the RDCleanPath request: a destination, a token, an X.224 request) is small. */
|
|
502
|
+
const MAX_HELLO = 64 * 1024;
|
|
503
|
+
/** How long a client has to send it, and how long the server has to answer X.224 and TLS. */
|
|
504
|
+
const HELLO_TIMEOUT_MS = 10_000;
|
|
505
|
+
const HANDSHAKE_TIMEOUT_MS = 45_000;
|
|
506
|
+
/** What the client may send ahead while the server is being reached (CredSSP hasn't started: next to nothing). */
|
|
507
|
+
const MAX_EARLY = 256 * 1024;
|
|
508
|
+
/** One client at a time uses a proxy; a reconnect can overlap the connection it replaces. */
|
|
509
|
+
const MAX_CLIENTS = 4;
|
|
510
|
+
/** A WebSocket message from the client: RDP PDUs, at most a few megabytes for a shared drive's read. */
|
|
511
|
+
const MAX_MESSAGE = 32 * 1024 * 1024;
|
|
512
|
+
/** Data waiting for the other side past this pauses the side it comes from, until half of it is gone. */
|
|
513
|
+
const HIGH_WATER = 8 * 1024 * 1024;
|
|
514
|
+
/**
|
|
515
|
+
* Sends the client's X.224 request upstream, reads the confirm, then does TLS there. With a pre-connection blob, that
|
|
516
|
+
* goes first; without an X.224 request (a Hyper-V VM's console), TLS follows the blob at once and X.224 is the
|
|
517
|
+
* client's to do later. `track` hears of each stream as soon as it exists, so the caller can end them if the client
|
|
518
|
+
* goes away meanwhile; `gone` says it has, and `signal` aborts then, for the factory that opens the first stream (which
|
|
519
|
+
* `track` only hears of once it is open).
|
|
520
|
+
*/
|
|
521
|
+
async function handshake(openUpstream, destination, pcb, x224Request, legacyTls, track, gone, withoutNla, options, signal) {
|
|
522
|
+
// Without the user name IronRDP puts in it. A request that still names one after that (its form not the one
|
|
523
|
+
// withoutUserCookie knows) goes nowhere: the name isn't to reach the server before it is checked.
|
|
524
|
+
const request = x224Request && withoutUserCookie(x224Request);
|
|
525
|
+
if (request?.includes(COOKIE)) {
|
|
526
|
+
throw new Error('the connection request carries the user name in a form the proxy can\'t take it out of');
|
|
527
|
+
}
|
|
528
|
+
const raw = await openUpstream(destination, signal);
|
|
529
|
+
track(raw);
|
|
336
530
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const refused = noTls(x224);
|
|
340
|
-
if (refused) {
|
|
341
|
-
throw new NoTlsError(refused);
|
|
531
|
+
if (gone()) {
|
|
532
|
+
throw new Error('the client left');
|
|
342
533
|
}
|
|
343
|
-
|
|
534
|
+
if (pcb) {
|
|
535
|
+
raw.write(preconnectionPdu(pcb));
|
|
536
|
+
}
|
|
537
|
+
let x224 = null;
|
|
538
|
+
if (request) {
|
|
539
|
+
raw.write(request);
|
|
540
|
+
x224 = await readTpkt(raw);
|
|
541
|
+
const refused = noTls(x224);
|
|
542
|
+
if (refused) {
|
|
543
|
+
throw new NoTlsError(refused);
|
|
544
|
+
}
|
|
545
|
+
// Before TLS: a server that isn't to get the password gets nothing more at all.
|
|
546
|
+
if (!offersNla(x224)) {
|
|
547
|
+
await withoutNla();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// With a name, the certificate is checked against it (an address against the certificate's addresses; it goes
|
|
551
|
+
// without SNI, which only takes names), the authorities and its dates. Whatever the outcome, the handshake
|
|
552
|
+
// completes: the certificate check decides.
|
|
553
|
+
const name = options.serverName;
|
|
554
|
+
const upstream = tls.connect({
|
|
555
|
+
socket: raw, rejectUnauthorized: false,
|
|
556
|
+
...name ? { host: name, ca: (0, authorities_1.certificateAuthorities)(), ...(0, net_1.isIP)(name) ? {} : { servername: name } } : {},
|
|
557
|
+
...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {}, ...options.tls,
|
|
558
|
+
});
|
|
559
|
+
track(upstream);
|
|
560
|
+
// An error after the one the handshake below waits for (the stream under it failing as the socket is destroyed,
|
|
561
|
+
// which a TLS socket over a stream passes on as its own) would have no listener until the relay is up, and an
|
|
562
|
+
// 'error' without one is an uncaught exception in Tabby's window. The relay listens for its own.
|
|
563
|
+
upstream.on('error', () => { });
|
|
344
564
|
await new Promise((resolve, reject) => {
|
|
345
565
|
upstream.once('secureConnect', resolve);
|
|
346
|
-
|
|
566
|
+
// TLS's own refusal only: not an error of the stream under it (raw.errored), whatever its code.
|
|
567
|
+
upstream.once('error', e => reject(isKeyUsageRefusal(e) && !raw.errored ? new KeyUsageRefused(e.message) : e));
|
|
568
|
+
upstream.once('close', () => reject(new Error('upstream closed during TLS')));
|
|
569
|
+
upstream.once('end', () => reject(new Error('upstream closed during TLS')));
|
|
347
570
|
});
|
|
348
571
|
return { raw, upstream, x224 };
|
|
349
572
|
}
|
|
@@ -352,28 +575,116 @@ async function handshake(openUpstream, destination, x224Request, legacyTls) {
|
|
|
352
575
|
throw e;
|
|
353
576
|
}
|
|
354
577
|
}
|
|
355
|
-
|
|
578
|
+
/** Whether `given` is the secret, compared in a time that doesn't tell how much of it was right. */
|
|
579
|
+
function sameSecret(given, secret) {
|
|
580
|
+
const bytes = Buffer.from(given);
|
|
581
|
+
return bytes.length === secret.length && (0, crypto_1.timingSafeEqual)(bytes, secret);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The Origin a client may say it comes from: none (not a browser's page), or a file's page, as Tabby's window is (its
|
|
585
|
+
* WebSockets carry `Origin: file://`). Not a web page (`http:`, `https:`), a sandboxed or otherwise opaque one (`null`),
|
|
586
|
+
* or a browser extension's.
|
|
587
|
+
*/
|
|
588
|
+
const ALLOWED_ORIGINS = [undefined, 'file://'];
|
|
589
|
+
/**
|
|
590
|
+
* `text` without the proxy's token. IronRDP's error for an address it couldn't connect to names the address, whose path
|
|
591
|
+
* is the token, and such an error goes to the desktop's status line and its log, which "Copy log" hands out.
|
|
592
|
+
*/
|
|
593
|
+
function withoutToken(text, proxy) {
|
|
594
|
+
return proxy?.token ? text.split(proxy.token).join('…') : text;
|
|
595
|
+
}
|
|
596
|
+
async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () => { }, options = { withoutNla: refuseWithoutNla }) {
|
|
597
|
+
// Called from JavaScript without the decision, the proxy still refuses such servers rather than let them through.
|
|
598
|
+
if (typeof options.withoutNla !== 'function') {
|
|
599
|
+
options = { ...options, withoutNla: refuseWithoutNla };
|
|
600
|
+
}
|
|
356
601
|
const token = (0, crypto_1.randomBytes)(24).toString('hex');
|
|
602
|
+
// The client shows it twice: as its address's path, before it is let in at all, and in its request.
|
|
603
|
+
const path = Buffer.from(`/${token}`);
|
|
604
|
+
const secret = Buffer.from(token);
|
|
357
605
|
let failure = null;
|
|
358
|
-
// Set once
|
|
606
|
+
// Set once a connection showed that the server needs KEY_ENCIPHERMENT_ONLY_TLS (its handshake fell back, and the
|
|
607
|
+
// certificate leaves signatures out and was accepted). The next connection starts with it and clears it, to set it
|
|
608
|
+
// again once it has shown the same: one that doesn't get that far, whatever stops it (a failure, the server not
|
|
609
|
+
// answering in time, the client leaving), or that finds a certificate that doesn't need it, leaves it cleared.
|
|
359
610
|
let legacyTls = false;
|
|
360
611
|
const stats = { bytesIn: 0, bytesOut: 0 };
|
|
361
|
-
|
|
612
|
+
// The room (see ServerFlow): what was taken for answers and not read from the connection since, the client whose
|
|
613
|
+
// connection that is (the latest to reach the relay: a reconnect can overlap the connection it replaces), and what
|
|
614
|
+
// waits for room. Once the proxy is closed there is room for good: nothing more goes anywhere.
|
|
615
|
+
let owed = 0;
|
|
616
|
+
let flowClient = null;
|
|
617
|
+
let roomWaiters = [];
|
|
618
|
+
let shut = false;
|
|
619
|
+
const hasRoom = () => shut || owed < exports.FLOW_WINDOW;
|
|
620
|
+
const roomMade = () => {
|
|
621
|
+
if (roomWaiters.length && hasRoom()) {
|
|
622
|
+
const go = roomWaiters;
|
|
623
|
+
roomWaiters = [];
|
|
624
|
+
go.forEach(f => f());
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const wss = new WebSocketServer({
|
|
628
|
+
host: '127.0.0.1',
|
|
629
|
+
port: 0,
|
|
630
|
+
maxPayload: MAX_MESSAGE,
|
|
631
|
+
// Anything on this computer can reach the port, and so can a web page in a browser. A connection without the
|
|
632
|
+
// token is turned away before it takes one of the few places, which the desktop's own client would then find
|
|
633
|
+
// taken; and one from a page other than a file's, as Tabby's window is, is turned away whatever it has.
|
|
634
|
+
verifyClient: ({ req }) => ALLOWED_ORIGINS.includes(req.headers.origin) && sameSecret(req.url ?? '', path),
|
|
635
|
+
});
|
|
362
636
|
await new Promise((resolve, reject) => {
|
|
363
637
|
wss.once('listening', resolve);
|
|
364
638
|
wss.once('error', reject);
|
|
365
639
|
});
|
|
640
|
+
wss.on('error', (e) => log(`proxy: ${e.message}`));
|
|
366
641
|
wss.on('connection', (ws) => {
|
|
642
|
+
// Only a client that knows the token gets here (see verifyClient), and then only as far as its first message
|
|
643
|
+
// (which has to be small and arrive soon) until that shows the token too.
|
|
644
|
+
if (wss.clients.size > MAX_CLIENTS) {
|
|
645
|
+
ws.terminate();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
367
648
|
let buf = Buffer.alloc(0);
|
|
368
649
|
let stage = 'hello';
|
|
369
650
|
const early = [];
|
|
651
|
+
let earlySize = 0;
|
|
370
652
|
let upstream = null;
|
|
371
|
-
let
|
|
653
|
+
let closed = false;
|
|
654
|
+
// Aborts when the client has gone, for an upstream still being opened, which isn't among the streams yet.
|
|
655
|
+
const leaving = new AbortController();
|
|
656
|
+
// Every stream opened for this client, from the moment it exists: ended with the client.
|
|
657
|
+
const streams = new Set();
|
|
658
|
+
const track = (stream) => {
|
|
659
|
+
streams.add(stream);
|
|
660
|
+
stream.once('close', () => streams.delete(stream));
|
|
661
|
+
if (closed) {
|
|
662
|
+
stream.destroy();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const end = () => {
|
|
666
|
+
closed = true;
|
|
667
|
+
leaving.abort();
|
|
668
|
+
clearTimeout(deadline);
|
|
669
|
+
for (const stream of streams) {
|
|
670
|
+
stream.destroy();
|
|
671
|
+
}
|
|
672
|
+
// What was handed to this connection went with it: the next starts with all the room.
|
|
673
|
+
if (flowClient === ws) {
|
|
674
|
+
flowClient = null;
|
|
675
|
+
owed = 0;
|
|
676
|
+
roomMade();
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
let deadline = setTimeout(() => stage === 'hello' && ws.terminate(), HELLO_TIMEOUT_MS);
|
|
372
680
|
const toServer = options.autologon ? autologon() : (data) => data;
|
|
373
681
|
const send = (data) => {
|
|
374
682
|
const out = toServer(data);
|
|
375
|
-
|
|
376
|
-
|
|
683
|
+
// The server (or the SSH channel to it) not keeping up: stop reading from the client until it has (see
|
|
684
|
+
// ServerFlow).
|
|
685
|
+
if (out.length && !upstream.write(out) && upstream.writableLength > HIGH_WATER) {
|
|
686
|
+
ws.pause();
|
|
687
|
+
upstream.once('drain', () => ws.resume());
|
|
377
688
|
}
|
|
378
689
|
};
|
|
379
690
|
failure = null;
|
|
@@ -385,47 +696,79 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
385
696
|
}
|
|
386
697
|
catch { }
|
|
387
698
|
ws.close();
|
|
388
|
-
|
|
389
|
-
raw?.destroy();
|
|
699
|
+
end();
|
|
390
700
|
};
|
|
701
|
+
ws.on('error', (e) => {
|
|
702
|
+
log(`proxy: client: ${e.message}`);
|
|
703
|
+
ws.terminate();
|
|
704
|
+
end();
|
|
705
|
+
});
|
|
391
706
|
ws.on('message', async (data) => {
|
|
392
707
|
if (stage === 'relay') {
|
|
393
708
|
stats.bytesOut += data.length;
|
|
709
|
+
// Read from the connection: as much room again (see ServerFlow).
|
|
710
|
+
if (flowClient === ws) {
|
|
711
|
+
owed = Math.max(0, owed - data.length);
|
|
712
|
+
roomMade();
|
|
713
|
+
}
|
|
394
714
|
send(data);
|
|
395
715
|
return;
|
|
396
716
|
}
|
|
397
717
|
if (stage === 'handshake') {
|
|
718
|
+
earlySize += data.length;
|
|
719
|
+
if (earlySize > MAX_EARLY) {
|
|
720
|
+
return fail('the client sent too much before the server was reached');
|
|
721
|
+
}
|
|
398
722
|
early.push(data);
|
|
399
723
|
return;
|
|
400
724
|
}
|
|
725
|
+
// Before the token is checked: no more than a request's worth is kept, whatever length it claims.
|
|
726
|
+
if (buf.length + data.length > MAX_HELLO) {
|
|
727
|
+
ws.terminate();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
401
730
|
buf = Buffer.concat([buf, data]);
|
|
402
731
|
const outer = readTlv(buf, 0);
|
|
403
732
|
if (!outer || buf.length < outer.end) {
|
|
404
733
|
return;
|
|
405
734
|
}
|
|
406
735
|
stage = 'handshake';
|
|
736
|
+
clearTimeout(deadline);
|
|
407
737
|
try {
|
|
408
738
|
const req = decodeRequest(buf.subarray(0, outer.end));
|
|
409
|
-
|
|
739
|
+
buf = Buffer.alloc(0);
|
|
740
|
+
if (req.version !== VERSION_1 || !req.destination || !req.x224 && !req.pcb?.trim()) {
|
|
410
741
|
return fail('malformed request');
|
|
411
742
|
}
|
|
412
|
-
if (req.proxyAuth
|
|
743
|
+
if (!sameSecret(req.proxyAuth ?? '', secret)) {
|
|
413
744
|
return fail('bad token');
|
|
414
745
|
}
|
|
746
|
+
deadline = setTimeout(() => stage === 'handshake' && fail('the server didn\'t answer in time'), options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
|
|
747
|
+
// Whether this connection uses KEY_ENCIPHERMENT_ONLY_TLS, and whether it came to it by falling back
|
|
748
|
+
// (rather than starting with it, as remembered): kept here until the connection shows it was needed.
|
|
749
|
+
// Taken from legacyTls, which stays cleared until then: a server that hangs, or a client that leaves
|
|
750
|
+
// meanwhile, has the next connection try modern TLS first, as one whose handshake fails does.
|
|
751
|
+
let legacy = legacyTls;
|
|
752
|
+
legacyTls = false;
|
|
753
|
+
let fellBack = false;
|
|
415
754
|
let up;
|
|
416
755
|
try {
|
|
417
|
-
up = await handshake(openUpstream, req.destination, req.x224,
|
|
756
|
+
up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
|
|
418
757
|
}
|
|
419
758
|
catch (e) {
|
|
420
|
-
if (
|
|
759
|
+
if (closed || legacy || !(e instanceof KeyUsageRefused)) {
|
|
421
760
|
throw e;
|
|
422
761
|
}
|
|
423
762
|
// The X.224 exchange is spent on that connection: start over on a fresh one.
|
|
424
763
|
log('TLS: the server certificate only allows key encipherment; using TLS 1.2 with RSA key exchange');
|
|
425
|
-
|
|
426
|
-
up = await handshake(openUpstream, req.destination, req.x224,
|
|
764
|
+
legacy = fellBack = true;
|
|
765
|
+
up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
|
|
766
|
+
}
|
|
767
|
+
// Reached: from here on the time is the user's (a certificate to look at) and the session's.
|
|
768
|
+
clearTimeout(deadline);
|
|
769
|
+
if (closed) {
|
|
770
|
+
return;
|
|
427
771
|
}
|
|
428
|
-
raw = up.raw;
|
|
429
772
|
upstream = up.upstream;
|
|
430
773
|
const x224 = up.x224;
|
|
431
774
|
// Both handshakes above, the first and the legacy-TLS retry, end here: the certificate is checked
|
|
@@ -435,11 +778,45 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
435
778
|
if (!chain.length) {
|
|
436
779
|
return fail('the server sent no certificate');
|
|
437
780
|
}
|
|
438
|
-
|
|
781
|
+
// RSA key exchange, without forward secrecy, only for a certificate shown to leave no better choice. One
|
|
782
|
+
// that doesn't show it, after a handshake that failed as if it did, means something on the way made
|
|
783
|
+
// that failure up; after one that started with it as remembered, that the certificate changed. Either
|
|
784
|
+
// way the next connection starts without the fallback.
|
|
785
|
+
if (legacy && !signsNothing(chain[0])) {
|
|
786
|
+
legacyTls = false;
|
|
787
|
+
return fail(fellBack
|
|
788
|
+
? 'the server\'s certificate doesn\'t show a need for the fallback to TLS 1.2 with RSA key exchange (no forward secrecy) that this connection made: something on the way may be interfering with TLS'
|
|
789
|
+
: 'the server\'s certificate changed, and no longer needs TLS 1.2 with RSA key exchange: the next connection uses modern TLS');
|
|
790
|
+
}
|
|
791
|
+
// Valid only as checked against a name: without one, Node checks against "localhost".
|
|
792
|
+
const valid = !!options.serverName && upstream.authorized;
|
|
793
|
+
const reason = valid ? '' : options.serverName ? String(upstream.authorizationError ?? '') : '';
|
|
794
|
+
// Where the chain stands and whether it is for the name, which Node's code for why it isn't valid can
|
|
795
|
+
// hide (see certificateDetails), by the authorities the handshake was checked against.
|
|
796
|
+
const peer = upstream.getPeerCertificate(true);
|
|
797
|
+
const checked = options.serverName && !valid ? { name: options.serverName, authorities: options.tls?.ca ?? (0, authorities_1.certificateAuthorities)(), reason } : undefined;
|
|
798
|
+
await checkCertificate(fingerprintOf(chain[0]), valid, reason, (0, authorities_1.certificateDetails)(peer, checked));
|
|
799
|
+
if (closed) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Remembered only now: the handshake worked, with a certificate that needs it and that was accepted.
|
|
803
|
+
legacyTls = legacy;
|
|
804
|
+
// A server without NLA, let through before TLS: what let it through is held to its certificate.
|
|
805
|
+
if (x224 && !offersNla(x224) && options.withoutNlaCertificate) {
|
|
806
|
+
await options.withoutNlaCertificate(fingerprintOf(chain[0]));
|
|
807
|
+
if (closed) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
439
811
|
ws.send(encodeResponse(x224, chain, req.destination));
|
|
440
812
|
stage = 'relay';
|
|
813
|
+
// The room is this connection's from now on, all of it (see ServerFlow).
|
|
814
|
+
flowClient = ws;
|
|
815
|
+
owed = 0;
|
|
816
|
+
roomMade();
|
|
441
817
|
// PROTOCOL_HYBRID_EX in the server's negotiation response: an Early User Authorization Result follows CredSSP.
|
|
442
|
-
|
|
818
|
+
// (A Hyper-V VM's console: CredSSP first, then the X.224 exchange, which never asks for HYBRID_EX.)
|
|
819
|
+
const frames = serverFraming(!!x224 && earlyUserAuthorization(x224));
|
|
443
820
|
upstream.on('data', (d) => {
|
|
444
821
|
stats.bytesIn += d.length;
|
|
445
822
|
let pdus;
|
|
@@ -455,8 +832,25 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
455
832
|
for (const pdu of pdus) {
|
|
456
833
|
ws.send(pdu);
|
|
457
834
|
}
|
|
835
|
+
// The client not keeping up: stop reading from the server until it has taken half of it.
|
|
836
|
+
if (ws.bufferedAmount > HIGH_WATER && !upstream.isPaused()) {
|
|
837
|
+
upstream.pause();
|
|
838
|
+
const wait = setInterval(() => {
|
|
839
|
+
if (closed || ws.bufferedAmount < HIGH_WATER / 2) {
|
|
840
|
+
clearInterval(wait);
|
|
841
|
+
upstream?.resume();
|
|
842
|
+
}
|
|
843
|
+
}, 50);
|
|
844
|
+
}
|
|
458
845
|
});
|
|
459
846
|
upstream.on('close', () => ws.close());
|
|
847
|
+
// The server closing its side is the end of the desktop: RDP has no use for a connection open one way.
|
|
848
|
+
// Through an SSH host that is all there may be to hear (the channel reports the end, and "closed" only
|
|
849
|
+
// once something more is sent into it), and a desktop waiting for "closed" stayed up, frozen.
|
|
850
|
+
upstream.on('end', () => {
|
|
851
|
+
ws.close();
|
|
852
|
+
upstream?.destroy();
|
|
853
|
+
});
|
|
460
854
|
upstream.on('error', (e) => { log(`upstream error: ${e.message}`); ws.close(); });
|
|
461
855
|
for (const d of early) {
|
|
462
856
|
stats.bytesOut += d.length;
|
|
@@ -465,21 +859,43 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
465
859
|
log(`RDCleanPath relay up to ${req.destination}`);
|
|
466
860
|
}
|
|
467
861
|
catch (e) {
|
|
468
|
-
|
|
862
|
+
if (!closed) {
|
|
863
|
+
fail(e?.message ?? String(e));
|
|
864
|
+
}
|
|
469
865
|
}
|
|
470
866
|
});
|
|
471
|
-
ws.on('close',
|
|
472
|
-
upstream?.destroy();
|
|
473
|
-
raw?.destroy();
|
|
474
|
-
});
|
|
867
|
+
ws.on('close', end);
|
|
475
868
|
});
|
|
476
869
|
const { port } = wss.address();
|
|
477
870
|
return {
|
|
478
|
-
url: `ws://127.0.0.1:${port}`,
|
|
871
|
+
url: `ws://127.0.0.1:${port}/${token}`,
|
|
479
872
|
token,
|
|
480
873
|
stats,
|
|
481
874
|
get failure() { return failure; },
|
|
482
|
-
close: () =>
|
|
875
|
+
close: () => {
|
|
876
|
+
// Stops listening, and ends the clients there are: close() alone waits for them.
|
|
877
|
+
wss.close();
|
|
878
|
+
for (const client of wss.clients) {
|
|
879
|
+
client.terminate();
|
|
880
|
+
}
|
|
881
|
+
// Nothing more goes to the server: what waited for room goes on, and finds the end.
|
|
882
|
+
shut = true;
|
|
883
|
+
owed = 0;
|
|
884
|
+
roomMade();
|
|
885
|
+
},
|
|
886
|
+
room: hasRoom,
|
|
887
|
+
// Counted only as a number of bytes: anything else would leave the room unknown, and every answer waiting.
|
|
888
|
+
spend: (bytes) => {
|
|
889
|
+
if (bytes > 0) {
|
|
890
|
+
owed += bytes;
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
refund: (bytes) => {
|
|
894
|
+
if (bytes > 0) {
|
|
895
|
+
owed = Math.max(0, owed - bytes);
|
|
896
|
+
roomMade();
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
whenRoom: () => hasRoom() ? Promise.resolve() : new Promise(resolve => roomWaiters.push(resolve)),
|
|
483
900
|
};
|
|
484
901
|
}
|
|
485
|
-
//# sourceMappingURL=rdcleanpath.js.map
|