tabby-rdp 0.5.0 → 0.5.2
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 +516 -0
- package/README.md +216 -58
- package/TROUBLESHOOTING.md +77 -0
- package/dist/accountForm.js +0 -1
- package/dist/accountRevisions.js +46 -0
- package/dist/accounts.js +2 -2
- 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 +1499 -269
- package/dist/desktopForm.js +53 -18
- package/dist/desktops.js +232 -24
- package/dist/diskStorage.js +212 -0
- package/dist/drives.js +271 -30
- package/dist/fileTransfer.js +960 -132
- package/dist/gateway.js +216 -57
- package/dist/header.js +76 -5
- package/dist/help.js +16 -47
- package/dist/hostCompat.js +86 -0
- package/dist/hyperv.js +65 -15
- package/dist/index.js +7 -4
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +0 -1
- package/dist/osd.js +56 -15
- package/dist/packageChangeDialog.js +186 -0
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +319 -44
- package/dist/rdpFile.js +22 -7
- package/dist/rdpProfile.js +39 -21
- package/dist/releaseSettings.js +72 -0
- package/dist/releases.js +408 -0
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +148 -122
- package/dist/signin.js +478 -48
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +40 -6
- package/dist/targets.js +194 -40
- package/dist/ui.js +88 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +79 -56
- package/dist/vms.js +17 -3
- package/dist/wake.js +15 -8
- package/docs/desk.md +157 -0
- package/package.json +14 -4
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- 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/drives.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/gateway.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/hyperv.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/ntlm.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/gateway.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GatewaySignInError = void 0;
|
|
4
4
|
exports.parseGateway = parseGateway;
|
|
5
|
+
exports.formatGateway = formatGateway;
|
|
5
6
|
exports.handshakeRequest = handshakeRequest;
|
|
6
7
|
exports.tunnelCreate = tunnelCreate;
|
|
7
8
|
exports.tunnelAuth = tunnelAuth;
|
|
@@ -10,18 +11,55 @@ exports.dataPacket = dataPacket;
|
|
|
10
11
|
exports.gatewayError = gatewayError;
|
|
11
12
|
exports.packetReader = packetReader;
|
|
12
13
|
exports.wsFrame = wsFrame;
|
|
14
|
+
exports.pongs = pongs;
|
|
13
15
|
exports.wsReader = wsReader;
|
|
14
16
|
exports.endPointBinding = endPointBinding;
|
|
15
17
|
exports.openThroughGateway = openThroughGateway;
|
|
16
18
|
const crypto_1 = require("crypto");
|
|
19
|
+
const net_1 = require("net");
|
|
17
20
|
const stream_1 = require("stream");
|
|
18
21
|
const tls = require("tls");
|
|
22
|
+
const url_1 = require("url");
|
|
23
|
+
const authorities_1 = require("./authorities");
|
|
24
|
+
const byteQueue_1 = require("./byteQueue");
|
|
19
25
|
const ntlm_1 = require("./ntlm");
|
|
20
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* A gateway's host in one canonical form, so its trust key, its credential scope, its SNI and the way it is shown all
|
|
28
|
+
* agree on it and an alternate spelling of the same gateway isn't taken for another one: an IPv6 literal compressed
|
|
29
|
+
* (so `[0:0::1]` and `[::1]` are one), any other host as the name it resolves to (lower case, international letters as
|
|
30
|
+
* punycode, one trailing dot dropped). Null for what names no host: an IPv6 literal that isn't one, an empty label
|
|
31
|
+
* (`gw..example`, or a second trailing dot), a label over 63 characters or a name over 253, and a name with a
|
|
32
|
+
* character no host name has (`#`, `?`, `@`, `%`, quotes, …), which the URL parser behind domainToASCII would otherwise
|
|
33
|
+
* read as the end of the name or decode (`gw.example#x` as `gw.example`, `a%41` as `aa`) rather than refuse.
|
|
34
|
+
*/
|
|
35
|
+
function canonicalGatewayHost(bracketed, bare) {
|
|
36
|
+
if (bracketed !== undefined) {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(`http://[${bracketed}]`).hostname.replace(/^\[|\]$/g, '');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Letters of any script go to IDNA, which refuses what isn't a name; of ASCII, only what a host name is made of.
|
|
45
|
+
if (/[^A-Za-z0-9._-]/.test((bare ?? '').replace(/[^\x00-\x7f]/g, ''))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const host = (0, url_1.domainToASCII)((bare ?? '').replace(/\.$/, ''));
|
|
49
|
+
return host && host.length <= 253 && host.split('.').every(label => label.length > 0 && label.length <= 63) ? host : null;
|
|
50
|
+
}
|
|
51
|
+
/** A `host` or `host:port` setting as a gateway (443 by default), or null if it isn't one. The host is canonical. */
|
|
21
52
|
function parseGateway(value) {
|
|
22
|
-
|
|
53
|
+
// Text only, as specOf reads a gateway: String() of an object from the config runs its own conversions, which can throw.
|
|
54
|
+
const m = /^\s*(?:\[([0-9a-f:.]+)\]|([^\s:/]+))(?::(\d{1,5}))?\s*$/i.exec(typeof value === 'string' ? value : '');
|
|
23
55
|
const port = Number(m?.[3] ?? 443);
|
|
24
|
-
|
|
56
|
+
const host = m && canonicalGatewayHost(m[1], m[2]);
|
|
57
|
+
return host && port > 0 && port < 65536 ? { host, port } : null;
|
|
58
|
+
}
|
|
59
|
+
/** A gateway as a setting writes it: `host`, or `host:port` when that isn't 443 (an IPv6 address in brackets). */
|
|
60
|
+
function formatGateway(gateway) {
|
|
61
|
+
const host = gateway.host.includes(':') ? `[${gateway.host}]` : gateway.host;
|
|
62
|
+
return gateway.port === 443 ? host : `${host}:${gateway.port}`;
|
|
25
63
|
}
|
|
26
64
|
/** The gateway refused the account (its HTTP sign-in, or its connection authorization policy). */
|
|
27
65
|
class GatewaySignInError extends Error {
|
|
@@ -47,6 +85,11 @@ const CAPABILITY_MESSAGING_CONSENT_SIGN = 0x4;
|
|
|
47
85
|
const MAX_DATA = 32 * 1024;
|
|
48
86
|
/** What an incoming packet may be at most (a data packet's 64 KiB, and room for a certificate in the tunnel response). */
|
|
49
87
|
const MAX_PACKET = 256 * 1024;
|
|
88
|
+
/**
|
|
89
|
+
* How many of the gateway's administrator's messages are logged: it decides how many it sends, and when, for as long
|
|
90
|
+
* as the connection lasts. An administrator has one to show, as the connection starts.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_SERVICE_MESSAGES = 10;
|
|
50
93
|
function packet(type, body) {
|
|
51
94
|
const header = Buffer.alloc(8);
|
|
52
95
|
header.writeUInt16LE(type, 0);
|
|
@@ -100,23 +143,32 @@ function gatewayError(code, target) {
|
|
|
100
143
|
default: return `the gateway refused the connection (0x${(code >>> 0).toString(16)})`;
|
|
101
144
|
}
|
|
102
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The start of a gateway's service message (a length, then UTF-16 text), for the log: up to its terminator, 300
|
|
148
|
+
* characters at most. Only that much is read: the gateway decides how long it is, up to a packet's size, and looking
|
|
149
|
+
* through all of it for where it ends could take the square of that.
|
|
150
|
+
*/
|
|
151
|
+
function serviceMessage(body) {
|
|
152
|
+
const text = body.subarray(body.length >= 2 ? 2 : 0).subarray(0, 600).toString('utf16le');
|
|
153
|
+
const nul = text.indexOf('\0');
|
|
154
|
+
return nul < 0 ? text : text.slice(0, nul);
|
|
155
|
+
}
|
|
103
156
|
/** Splits a byte stream into the gateway's packets (8 bytes of header: type, reserved, total length). */
|
|
104
157
|
function packetReader(onPacket) {
|
|
105
|
-
|
|
158
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
106
159
|
return chunk => {
|
|
107
|
-
pending
|
|
160
|
+
pending.push(chunk);
|
|
108
161
|
while (pending.length >= 8) {
|
|
109
|
-
const length = pending.readUInt32LE(4);
|
|
162
|
+
const length = pending.peek(8).readUInt32LE(4);
|
|
110
163
|
if (length < 8 || length > MAX_PACKET) {
|
|
111
164
|
throw new Error(`a gateway packet of ${length} bytes`);
|
|
112
165
|
}
|
|
113
166
|
if (pending.length < length) {
|
|
114
167
|
break;
|
|
115
168
|
}
|
|
116
|
-
|
|
117
|
-
|
|
169
|
+
const packet = pending.take(length);
|
|
170
|
+
onPacket(packet.readUInt16LE(0), packet.subarray(8));
|
|
118
171
|
}
|
|
119
|
-
pending = Buffer.from(pending);
|
|
120
172
|
};
|
|
121
173
|
}
|
|
122
174
|
// ---- WebSocket frames (RFC 6455), client side -----------------------------------------------------
|
|
@@ -143,37 +195,67 @@ function wsFrame(payload, opcode = 0x2) {
|
|
|
143
195
|
}
|
|
144
196
|
return Buffer.concat([header, mask, masked]);
|
|
145
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Answers pings with pongs: one waiting to go out at a time, for the latest ping, as RFC 6455 allows (5.5.3). A gateway
|
|
200
|
+
* that sends pings and doesn't read what comes back would otherwise have the pongs pile up here as fast as it sends.
|
|
201
|
+
*/
|
|
202
|
+
function pongs(socket, ended) {
|
|
203
|
+
let waiting = false;
|
|
204
|
+
let latest = null;
|
|
205
|
+
const pong = (payload) => {
|
|
206
|
+
if (waiting) {
|
|
207
|
+
latest = Buffer.from(payload);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
waiting = true;
|
|
211
|
+
socket.write(wsFrame(payload, 0xa), () => {
|
|
212
|
+
waiting = false;
|
|
213
|
+
const next = latest;
|
|
214
|
+
latest = null;
|
|
215
|
+
if (next && !ended()) {
|
|
216
|
+
pong(next);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
return pong;
|
|
221
|
+
}
|
|
146
222
|
/** Splits the server's bytes into frames: data frames' payloads (fragments as they come), pings, and a close. */
|
|
147
223
|
function wsReader(on) {
|
|
148
|
-
|
|
224
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
149
225
|
return chunk => {
|
|
150
|
-
pending
|
|
226
|
+
pending.push(chunk);
|
|
151
227
|
for (;;) {
|
|
152
|
-
|
|
228
|
+
// The header: 2 bytes, then up to 8 of length (and 4 of mask, read with the frame).
|
|
229
|
+
const head = pending.peek(10);
|
|
230
|
+
if (head.length < 2) {
|
|
153
231
|
break;
|
|
154
232
|
}
|
|
155
|
-
const opcode =
|
|
156
|
-
const masked = (
|
|
157
|
-
let length =
|
|
233
|
+
const opcode = head[0] & 0x0f;
|
|
234
|
+
const masked = (head[1] & 0x80) !== 0;
|
|
235
|
+
let length = head[1] & 0x7f;
|
|
158
236
|
let at = 2;
|
|
159
237
|
if (length === 126) {
|
|
160
|
-
if (
|
|
238
|
+
if (head.length < 4) {
|
|
161
239
|
break;
|
|
162
240
|
}
|
|
163
|
-
length =
|
|
241
|
+
length = head.readUInt16BE(2);
|
|
164
242
|
at = 4;
|
|
165
243
|
}
|
|
166
244
|
else if (length === 127) {
|
|
167
|
-
if (
|
|
245
|
+
if (head.length < 10) {
|
|
168
246
|
break;
|
|
169
247
|
}
|
|
170
|
-
const long =
|
|
248
|
+
const long = head.readBigUInt64BE(2);
|
|
171
249
|
if (long > BigInt(MAX_PACKET * 4)) {
|
|
172
250
|
throw new Error('a WebSocket frame too large to be the gateway\'s');
|
|
173
251
|
}
|
|
174
252
|
length = Number(long);
|
|
175
253
|
at = 10;
|
|
176
254
|
}
|
|
255
|
+
// Close, ping and pong carry 125 bytes at most (RFC 6455, 5.5): each ping is answered with as much.
|
|
256
|
+
if (opcode >= 0x8 && length > 125) {
|
|
257
|
+
throw new Error('a WebSocket control frame too large to be the gateway\'s');
|
|
258
|
+
}
|
|
177
259
|
const maskAt = at;
|
|
178
260
|
if (masked) {
|
|
179
261
|
at += 4;
|
|
@@ -181,14 +263,14 @@ function wsReader(on) {
|
|
|
181
263
|
if (pending.length < at + length) {
|
|
182
264
|
break;
|
|
183
265
|
}
|
|
184
|
-
|
|
266
|
+
const frame = pending.take(at + length);
|
|
267
|
+
let payload = frame.subarray(at);
|
|
185
268
|
if (masked) {
|
|
186
269
|
payload = Buffer.from(payload);
|
|
187
270
|
for (let i = 0; i < length; i++) {
|
|
188
|
-
payload[i] ^=
|
|
271
|
+
payload[i] ^= frame[maskAt + (i & 3)];
|
|
189
272
|
}
|
|
190
273
|
}
|
|
191
|
-
pending = pending.subarray(at + length);
|
|
192
274
|
if (opcode === 0x8) {
|
|
193
275
|
on.close();
|
|
194
276
|
}
|
|
@@ -200,60 +282,92 @@ function wsReader(on) {
|
|
|
200
282
|
on.data(payload);
|
|
201
283
|
}
|
|
202
284
|
}
|
|
203
|
-
pending = Buffer.from(pending);
|
|
204
285
|
};
|
|
205
286
|
}
|
|
206
287
|
// ---- the HTTP upgrade, with NTLM --------------------------------------------------------------------
|
|
207
|
-
/**
|
|
288
|
+
/** What a response's status line and headers may be at most. */
|
|
289
|
+
const MAX_HEAD = 64 * 1024;
|
|
290
|
+
/** What its body may be at most: a 401's page, a kilobyte or so, which isn't kept anyway. */
|
|
291
|
+
const MAX_BODY = 256 * 1024;
|
|
292
|
+
/** A header's name: an HTTP token (RFC 9110, 5.6.2). */
|
|
293
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
294
|
+
/**
|
|
295
|
+
* One HTTP response's status and headers, read from the stream. A 401's body (its page) is counted off as it comes, not
|
|
296
|
+
* kept; a length (Content-Length) that isn't a number, or is over MAX_BODY, is refused. What follows is left for the
|
|
297
|
+
* next reader.
|
|
298
|
+
*/
|
|
208
299
|
function readResponse(stream, timeoutMs) {
|
|
209
300
|
return new Promise((resolve, reject) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
301
|
+
// The status line and headers, copied in as they come.
|
|
302
|
+
const head = Buffer.alloc(MAX_HEAD);
|
|
303
|
+
let filled = 0;
|
|
304
|
+
let response = null;
|
|
305
|
+
// How much of the body is still to come, and what came after it.
|
|
306
|
+
let body = 0;
|
|
307
|
+
let rest = null;
|
|
308
|
+
const done = (error) => {
|
|
213
309
|
clearTimeout(timer);
|
|
214
310
|
stream.off('data', onData);
|
|
215
311
|
stream.off('error', onError);
|
|
216
312
|
stream.off('close', onClose);
|
|
217
313
|
// Paused before anything left over goes back, so that it waits for whoever reads next.
|
|
218
314
|
stream.pause();
|
|
219
|
-
if (
|
|
220
|
-
stream.unshift(
|
|
315
|
+
if (rest?.length) {
|
|
316
|
+
stream.unshift(rest);
|
|
221
317
|
}
|
|
222
|
-
error ? reject(error) : resolve(
|
|
318
|
+
error ? reject(error) : resolve(response);
|
|
223
319
|
};
|
|
224
320
|
const onError = (e) => done(e);
|
|
225
321
|
const onClose = () => done(new Error('the gateway closed the connection'));
|
|
226
322
|
const timer = setTimeout(() => done(new Error('the gateway didn\'t answer in time')), timeoutMs);
|
|
227
323
|
const onData = (chunk) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
324
|
+
if (!response) {
|
|
325
|
+
// Only what is new is searched, from three bytes back: the blank line can straddle two pieces.
|
|
326
|
+
const from = Math.max(0, filled - 3);
|
|
327
|
+
const start = filled;
|
|
328
|
+
filled += chunk.copy(head, filled);
|
|
329
|
+
const end = head.subarray(0, filled).indexOf('\r\n\r\n', from);
|
|
231
330
|
if (end < 0) {
|
|
232
|
-
if (
|
|
331
|
+
if (filled === MAX_HEAD) {
|
|
233
332
|
done(new Error('the gateway\'s answer isn\'t HTTP'));
|
|
234
333
|
}
|
|
235
334
|
return;
|
|
236
335
|
}
|
|
237
|
-
const lines =
|
|
336
|
+
const lines = head.subarray(0, end).toString('latin1').split('\r\n');
|
|
238
337
|
const status = Number(/^HTTP\/1\.[01] (\d{3})/.exec(lines[0])?.[1]);
|
|
239
|
-
|
|
338
|
+
// A CR or LF that doesn't end a line is no HTTP a gateway sends.
|
|
339
|
+
if (!status || lines.some(line => line.includes('\r') || line.includes('\n'))) {
|
|
240
340
|
return done(new Error('the gateway\'s answer isn\'t HTTP'));
|
|
241
341
|
}
|
|
242
342
|
const headers = new Map();
|
|
243
343
|
for (const line of lines.slice(1)) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
344
|
+
// Split at the first colon, the value trimmed: no pattern whose parts can take the same characters,
|
|
345
|
+
// which a long line can make cost the square of its length.
|
|
346
|
+
const colon = line.indexOf(':');
|
|
347
|
+
const name = colon > 0 ? line.slice(0, colon).toLowerCase() : '';
|
|
348
|
+
if (HEADER_NAME.test(name)) {
|
|
349
|
+
const values = headers.get(name) ?? [];
|
|
350
|
+
values.push(line.slice(colon + 1).trim());
|
|
351
|
+
headers.set(name, values);
|
|
247
352
|
}
|
|
248
353
|
}
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
|
|
354
|
+
// Only a 401's page has to be got past: a sign-in round follows it on the same connection. Any other
|
|
355
|
+
// answer ends the sign-in, connection and all (and a switch of protocols has no body).
|
|
356
|
+
const length = status === 401 ? (headers.get('content-length')?.[0] ?? '0').trim() : '0';
|
|
357
|
+
if (!/^\d{1,9}$/.test(length) || Number(length) > MAX_BODY) {
|
|
358
|
+
return done(new Error('the gateway\'s answer is malformed or too large'));
|
|
359
|
+
}
|
|
360
|
+
response = { status, headers };
|
|
361
|
+
body = Number(length);
|
|
362
|
+
// What this piece has after the blank line.
|
|
363
|
+
chunk = chunk.subarray(end + 4 - start);
|
|
252
364
|
}
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
365
|
+
if (chunk.length < body) {
|
|
366
|
+
body -= chunk.length;
|
|
367
|
+
return;
|
|
256
368
|
}
|
|
369
|
+
rest = chunk.subarray(body);
|
|
370
|
+
done(null);
|
|
257
371
|
};
|
|
258
372
|
stream.on('data', onData);
|
|
259
373
|
stream.on('error', onError);
|
|
@@ -295,7 +409,7 @@ function endPointBinding(certificate) {
|
|
|
295
409
|
return Buffer.concat([Buffer.from('tls-server-end-point:', 'latin1'), (0, crypto_1.createHash)(hash).update(certificate).digest()]);
|
|
296
410
|
}
|
|
297
411
|
/** Upgrades the connection to the gateway's WebSocket, signing in with NTLM on the way. */
|
|
298
|
-
async function upgrade(stream, gateway, account, certificate, timeoutMs) {
|
|
412
|
+
async function upgrade(stream, gateway, account, certificate, timeoutMs, proceed = () => { }) {
|
|
299
413
|
const key = (0, crypto_1.randomBytes)(16).toString('base64');
|
|
300
414
|
const connection = `{${(0, crypto_1.randomUUID)().toUpperCase()}}`;
|
|
301
415
|
const request = (authorization) => [
|
|
@@ -310,6 +424,7 @@ async function upgrade(stream, gateway, account, certificate, timeoutMs) {
|
|
|
310
424
|
'', '',
|
|
311
425
|
].join('\r\n');
|
|
312
426
|
const negotiate = (0, ntlm_1.negotiateMessage)();
|
|
427
|
+
proceed();
|
|
313
428
|
stream.write(request(negotiate));
|
|
314
429
|
const first = await readResponse(stream, timeoutMs);
|
|
315
430
|
if (first.status !== 401) {
|
|
@@ -323,6 +438,7 @@ async function upgrade(stream, gateway, account, certificate, timeoutMs) {
|
|
|
323
438
|
: `the gateway doesn't offer an NTLM sign-in (it offers: ${offered.map(v => v.split(' ')[0]).join(', ') || 'nothing'})`);
|
|
324
439
|
}
|
|
325
440
|
const challengeMessage = Buffer.from(challenge, 'base64');
|
|
441
|
+
proceed();
|
|
326
442
|
stream.write(request((0, ntlm_1.authenticateMessage)(negotiate, challengeMessage, {
|
|
327
443
|
...account, channelBinding: endPointBinding(certificate), targetName: `HTTP/${gateway.host}`,
|
|
328
444
|
})));
|
|
@@ -344,36 +460,63 @@ async function upgrade(stream, gateway, account, certificate, timeoutMs) {
|
|
|
344
460
|
/**
|
|
345
461
|
* Opens a stream to target.host:target.port through the gateway. `open` gives a TCP stream to the gateway (directly, or
|
|
346
462
|
* through an SSH host). `checkCertificate` decides on the gateway's certificate before the account is used: it gets
|
|
347
|
-
* the SHA-256 fingerprint (`AB:CD:…`)
|
|
348
|
-
* certificate authorities, and
|
|
463
|
+
* the SHA-256 fingerprint (`AB:CD:…`), whether the certificate is valid for the gateway's name by this computer's
|
|
464
|
+
* certificate authorities (see certificateAuthorities), why not (Node's code for it, '' when it is), and what the
|
|
465
|
+
* certificate says of itself (see CertificateDetails), and throws to refuse.
|
|
349
466
|
*/
|
|
350
467
|
async function openThroughGateway(open, gateway, account, target, checkCertificate, options = {}) {
|
|
351
468
|
const log = options.log ?? (() => null);
|
|
352
469
|
const timeoutMs = options.timeoutMs ?? 20000;
|
|
353
470
|
const targetName = `${target.host}:${target.port}`;
|
|
471
|
+
const signal = options.signal;
|
|
472
|
+
// Nothing more goes to the gateway once what it is for has gone: checked before each step and message of the
|
|
473
|
+
// sign-in, and the connection ends then, which also ends what waits on it (the TLS handshake, an answer).
|
|
474
|
+
const proceed = () => {
|
|
475
|
+
if (signal?.aborted) {
|
|
476
|
+
throw new Error('the desktop was closed meanwhile');
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
proceed();
|
|
354
480
|
const raw = await open();
|
|
355
481
|
let secure = null;
|
|
482
|
+
const abort = () => {
|
|
483
|
+
secure?.destroy();
|
|
484
|
+
raw.destroy();
|
|
485
|
+
};
|
|
486
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
356
487
|
try {
|
|
488
|
+
proceed();
|
|
357
489
|
// A name is sent (SNI), an address isn't; either is what the certificate is checked for.
|
|
358
|
-
const named =
|
|
359
|
-
const socket = tls.connect({
|
|
490
|
+
const named = !(0, net_1.isIP)(gateway.host);
|
|
491
|
+
const socket = tls.connect({
|
|
492
|
+
socket: raw, host: gateway.host, rejectUnauthorized: false, ca: (0, authorities_1.certificateAuthorities)(),
|
|
493
|
+
...named ? { servername: gateway.host } : {}, ...options.tls,
|
|
494
|
+
});
|
|
360
495
|
secure = socket;
|
|
496
|
+
// A step like the others: a gateway that takes the connection and never answers TLS would keep it for good.
|
|
497
|
+
let timer;
|
|
361
498
|
await new Promise((resolve, reject) => {
|
|
499
|
+
timer = setTimeout(() => reject(new Error('the gateway didn\'t answer in time (TLS)')), timeoutMs);
|
|
362
500
|
socket.once('secureConnect', resolve);
|
|
363
501
|
socket.once('error', reject);
|
|
364
502
|
socket.once('close', () => reject(new Error('the gateway closed the connection during TLS')));
|
|
365
|
-
});
|
|
366
|
-
const certificate = socket.getPeerCertificate();
|
|
503
|
+
}).finally(() => clearTimeout(timer));
|
|
504
|
+
const certificate = socket.getPeerCertificate(true);
|
|
367
505
|
if (!certificate?.fingerprint256) {
|
|
368
506
|
throw new Error('the gateway sent no certificate');
|
|
369
507
|
}
|
|
370
|
-
|
|
371
|
-
|
|
508
|
+
// Where its chain stands and whether it is for the gateway's name, which Node's code for why it isn't valid can
|
|
509
|
+
// hide (see certificateDetails).
|
|
510
|
+
const reason = socket.authorized ? '' : String(socket.authorizationError ?? '');
|
|
511
|
+
const checked = socket.authorized ? undefined : { name: gateway.host, authorities: options.tls?.ca ?? (0, authorities_1.certificateAuthorities)(), reason };
|
|
512
|
+
await checkCertificate(certificate.fingerprint256, socket.authorized, reason, (0, authorities_1.certificateDetails)(certificate, checked));
|
|
513
|
+
await upgrade(socket, gateway, account, certificate.raw, timeoutMs, proceed);
|
|
372
514
|
log(`gateway: signed in to ${gateway.host}`);
|
|
373
515
|
// From here on: WebSocket frames, the gateway's packets inside.
|
|
374
516
|
let expecting = null;
|
|
375
517
|
let stream = null;
|
|
376
518
|
let ended = false;
|
|
519
|
+
let messages = 0;
|
|
377
520
|
const end = (error) => {
|
|
378
521
|
if (ended) {
|
|
379
522
|
return;
|
|
@@ -395,7 +538,12 @@ async function openThroughGateway(open, gateway, account, target, checkCertifica
|
|
|
395
538
|
// nothing to answer: the gateway only checks the line
|
|
396
539
|
}
|
|
397
540
|
else if (type === PKT_SERVICE_MESSAGE) {
|
|
398
|
-
|
|
541
|
+
if (++messages <= MAX_SERVICE_MESSAGES) {
|
|
542
|
+
log(`gateway: its administrator's message: ${serviceMessage(body)}`);
|
|
543
|
+
}
|
|
544
|
+
else if (messages === MAX_SERVICE_MESSAGES + 1) {
|
|
545
|
+
log(`gateway: more messages from its administrator; past ${MAX_SERVICE_MESSAGES}, they aren't logged`);
|
|
546
|
+
}
|
|
399
547
|
}
|
|
400
548
|
else if (type === PKT_CLOSE_CHANNEL) {
|
|
401
549
|
socket.write(wsFrame(packet(PKT_CLOSE_CHANNEL_RESPONSE, Buffer.alloc(8))));
|
|
@@ -409,7 +557,7 @@ async function openThroughGateway(open, gateway, account, target, checkCertifica
|
|
|
409
557
|
});
|
|
410
558
|
const frames = wsReader({
|
|
411
559
|
data: payload => packets(payload),
|
|
412
|
-
ping:
|
|
560
|
+
ping: pongs(socket, () => ended),
|
|
413
561
|
close: () => end(),
|
|
414
562
|
});
|
|
415
563
|
socket.on('data', (chunk) => {
|
|
@@ -434,6 +582,14 @@ async function openThroughGateway(open, gateway, account, target, checkCertifica
|
|
|
434
582
|
resolve: body => { clearTimeout(timer); resolve(body); },
|
|
435
583
|
reject: e => { clearTimeout(timer); reject(e); },
|
|
436
584
|
};
|
|
585
|
+
try {
|
|
586
|
+
proceed();
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
expecting = null;
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
return reject(e);
|
|
592
|
+
}
|
|
437
593
|
socket.write(wsFrame(send));
|
|
438
594
|
});
|
|
439
595
|
const refused = (code, step) => {
|
|
@@ -488,5 +644,8 @@ async function openThroughGateway(open, gateway, account, target, checkCertifica
|
|
|
488
644
|
raw.destroy();
|
|
489
645
|
throw e;
|
|
490
646
|
}
|
|
647
|
+
finally {
|
|
648
|
+
// Only for the sign-in and the tunnel's setup: the tunnel itself ends with the client's connection (see track).
|
|
649
|
+
signal?.removeEventListener('abort', abort);
|
|
650
|
+
}
|
|
491
651
|
}
|
|
492
|
-
//# sourceMappingURL=gateway.js.map
|
package/dist/header.js
CHANGED
|
@@ -23,10 +23,28 @@ const ui_1 = require("./ui");
|
|
|
23
23
|
const ICON_DESKTOP = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 352c0 35.3 28.7 64 64 64l176 0-10.7 32L160 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-69.3 0L336 416l176 0c35.3 0 64-28.7 64-64l0-288c0-35.3-28.7-64-64-64L64 0zM512 64l0 224L64 288 64 64l448 0z"/></svg>';
|
|
24
24
|
const ICON_TERMINAL = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M9.4 86.6C-3.1 74.1-3.1 53.9 9.4 41.4s32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L178.7 256 9.4 86.6zM256 416l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/></svg>';
|
|
25
25
|
const ICON_DISCONNECT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80zM0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm175 79c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>';
|
|
26
|
+
/** What the microphone indicator says about the desktops sent the microphone. */
|
|
27
|
+
function microphoneTitle(users) {
|
|
28
|
+
const names = users.map(u => u.name);
|
|
29
|
+
const which = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are` : `${names[0]} is`;
|
|
30
|
+
return `Microphone in use: ${which} receiving it. Click to show the desktop, or to stop sending the microphone there.`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The microphone over the window (see HeaderControls.float): like the dot on a desktop, but one that can be clicked, so
|
|
34
|
+
* it takes the clicks on what it covers of the desktop in view. At the top in the middle, where a maximized window's
|
|
35
|
+
* title bar has its title (GNOME) or only room to drag it by (Windows), and GNOME's top bar its clock, all wider than
|
|
36
|
+
* it, rather than in the corner, where they have their close button and system menu. Windows' own Remote Desktop
|
|
37
|
+
* client puts its bar there in full screen too.
|
|
38
|
+
*/
|
|
39
|
+
const FLOATING_MICROPHONE_STYLE = 'position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 1040; width: 26px; height: 26px; padding: 0; ' +
|
|
40
|
+
'border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; ' +
|
|
41
|
+
'background: rgba(220, 38, 38, 0.9); color: #fff; font-size: 12px; cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);';
|
|
26
42
|
/**
|
|
27
43
|
* Desktop/console switch and Disconnect in Tabby's header, left of the settings gear, for the active
|
|
28
44
|
* tab's SSH pane. Tabby builds its own header buttons once at startup, so these are plain DOM kept in
|
|
29
|
-
* sync with the active tab and the desktop state.
|
|
45
|
+
* sync with the active tab and the desktop state. Also a red microphone while any desktop is sent the
|
|
46
|
+
* microphone, whatever tab is in front: the dot on a desktop is out of sight while it is hidden. Without
|
|
47
|
+
* a header (full screen hides Tabby's tab bar), that one shows over the window instead, at its top in the middle.
|
|
30
48
|
*/
|
|
31
49
|
let HeaderControls = class HeaderControls {
|
|
32
50
|
constructor(app, desktop, targets, platform, keyboard, zone, help) {
|
|
@@ -38,6 +56,8 @@ let HeaderControls = class HeaderControls {
|
|
|
38
56
|
this.zone = zone;
|
|
39
57
|
this.help = help;
|
|
40
58
|
this.group = null;
|
|
59
|
+
/** The microphone over the window, while it shows (see float). */
|
|
60
|
+
this.floatingMicrophone = null;
|
|
41
61
|
this.scheduled = false;
|
|
42
62
|
}
|
|
43
63
|
// All watching and DOM syncing runs outside Angular's zone: inside it, every observer callback and
|
|
@@ -137,21 +157,33 @@ let HeaderControls = class HeaderControls {
|
|
|
137
157
|
});
|
|
138
158
|
this.disconnectButton.title = 'Disconnect remote desktop';
|
|
139
159
|
this.disconnectButton.innerHTML = ICON_DISCONNECT;
|
|
140
|
-
this.
|
|
160
|
+
this.microphoneButton = button('trd-header-mic', () => this.microphoneMenu());
|
|
161
|
+
this.microphoneButton.innerHTML = '<i class="fas fa-microphone"></i>';
|
|
162
|
+
// Inline: Tabby's header button color is scoped to its component and would win over a plugin style.
|
|
163
|
+
this.microphoneButton.style.color = 'rgb(220, 38, 38)';
|
|
164
|
+
this.group.append(this.microphoneButton, this.toggleButton, this.disconnectButton);
|
|
141
165
|
right.insertBefore(this.group, right.firstChild);
|
|
142
166
|
return true;
|
|
143
167
|
}
|
|
144
168
|
refresh() {
|
|
145
|
-
|
|
169
|
+
const users = this.desktop.microphoneUsers();
|
|
170
|
+
const inHeader = this.ensureGroup();
|
|
171
|
+
this.float(inHeader ? [] : users.filter(u => !u.inView));
|
|
172
|
+
if (!inHeader) {
|
|
146
173
|
return;
|
|
147
174
|
}
|
|
175
|
+
this.microphoneButton.style.display = users.length ? '' : 'none';
|
|
176
|
+
this.microphoneButton.title = users.length ? microphoneTitle(users) : '';
|
|
148
177
|
let pane = (0, targets_1.desktopPaneOf)(this.app.activeTab);
|
|
149
178
|
// SSH tabs always qualify; local terminals only while they run ssh (see detect()).
|
|
150
179
|
if (pane && !(0, ssh_1.isSSHTab)(pane) && !this.desktop.has(pane) && !this.targets.cached(pane)) {
|
|
151
180
|
pane = null;
|
|
152
181
|
}
|
|
153
|
-
|
|
182
|
+
// The microphone shows from any tab, also one with nothing to switch.
|
|
183
|
+
this.group.style.display = pane || users.length ? 'flex' : 'none';
|
|
154
184
|
if (!pane) {
|
|
185
|
+
this.toggleButton.style.display = 'none';
|
|
186
|
+
this.disconnectButton.style.display = 'none';
|
|
155
187
|
return;
|
|
156
188
|
}
|
|
157
189
|
const visible = this.desktop.isVisible(pane);
|
|
@@ -164,6 +196,46 @@ let HeaderControls = class HeaderControls {
|
|
|
164
196
|
}
|
|
165
197
|
this.disconnectButton.style.display = this.desktop.has(pane) ? '' : 'none';
|
|
166
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The microphone over the window, for desktops sent it that aren't in view (`users`), while there is no header to
|
|
201
|
+
* show it in: Tabby leaves its tab bar out in full screen unless Appearance › Show tabs in fullscreen mode is on. A
|
|
202
|
+
* desktop in view has its dot. Gone again once the header is back or nothing out of view gets the microphone.
|
|
203
|
+
*/
|
|
204
|
+
float(users) {
|
|
205
|
+
// The note on desktops that typing goes into all of them, at their top in the middle, moves clear of it meanwhile
|
|
206
|
+
// (see the service's STYLE).
|
|
207
|
+
document.body.classList.toggle('trd-floating-mic-on', users.length > 0);
|
|
208
|
+
if (!users.length) {
|
|
209
|
+
this.floatingMicrophone?.remove();
|
|
210
|
+
this.floatingMicrophone = null;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!this.floatingMicrophone?.isConnected) {
|
|
214
|
+
const button = document.createElement('button');
|
|
215
|
+
button.className = 'trd-floating-mic';
|
|
216
|
+
button.style.cssText = FLOATING_MICROPHONE_STYLE;
|
|
217
|
+
button.innerHTML = '<i class="fas fa-microphone"></i>';
|
|
218
|
+
button.addEventListener('click', () => this.zone.run(() => this.microphoneMenu()));
|
|
219
|
+
document.body.appendChild(button);
|
|
220
|
+
this.floatingMicrophone = button;
|
|
221
|
+
}
|
|
222
|
+
const title = microphoneTitle(users);
|
|
223
|
+
if (this.floatingMicrophone.title !== title) {
|
|
224
|
+
this.floatingMicrophone.title = title;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** The microphone's menu: for each desktop sent it, show that desktop, or stop sending the microphone there. */
|
|
228
|
+
microphoneMenu() {
|
|
229
|
+
const users = this.desktop.microphoneUsers();
|
|
230
|
+
if (!users.length) {
|
|
231
|
+
return; // stopped meanwhile
|
|
232
|
+
}
|
|
233
|
+
const items = [{ label: 'Microphone in use', enabled: false }];
|
|
234
|
+
for (const { pane, name } of users) {
|
|
235
|
+
items.push({ type: 'separator' }, { label: `Show ${name}`, click: () => this.desktop.bringForward(pane) }, { label: `Stop sending the microphone to ${name}`, click: () => this.desktop.stopMicrophone(pane) });
|
|
236
|
+
}
|
|
237
|
+
this.platform.popupContextMenu(items);
|
|
238
|
+
}
|
|
167
239
|
};
|
|
168
240
|
exports.HeaderControls = HeaderControls;
|
|
169
241
|
exports.HeaderControls = HeaderControls = __decorate([
|
|
@@ -176,4 +248,3 @@ exports.HeaderControls = HeaderControls = __decorate([
|
|
|
176
248
|
core_1.NgZone,
|
|
177
249
|
help_1.RemoteDesktopHelp])
|
|
178
250
|
], HeaderControls);
|
|
179
|
-
//# sourceMappingURL=header.js.map
|