tabby-rdp 0.5.0 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +490 -0
  2. package/README.md +177 -48
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1442 -244
  13. package/dist/desktopForm.js +51 -17
  14. package/dist/desktops.js +232 -24
  15. package/dist/drives.js +271 -30
  16. package/dist/fileTransfer.js +958 -83
  17. package/dist/gateway.js +216 -57
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +65 -15
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +0 -1
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +319 -44
  28. package/dist/rdpFile.js +22 -7
  29. package/dist/rdpProfile.js +39 -21
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +65 -29
  33. package/dist/signin.js +407 -40
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +40 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +88 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +17 -3
  41. package/dist/wake.js +15 -8
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/ironrdp_web_bg.wasm +0 -0
  46. package/dist/accountForm.js.map +0 -1
  47. package/dist/accounts.js.map +0 -1
  48. package/dist/audio.js.map +0 -1
  49. package/dist/connectionStatus.js.map +0 -1
  50. package/dist/desk.js.map +0 -1
  51. package/dist/deskScript.js.map +0 -1
  52. package/dist/desktop.service.js.map +0 -1
  53. package/dist/desktopForm.js.map +0 -1
  54. package/dist/desktops.js.map +0 -1
  55. package/dist/drives.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/gateway.js.map +0 -1
  58. package/dist/header.js.map +0 -1
  59. package/dist/help.js.map +0 -1
  60. package/dist/hyperv.js.map +0 -1
  61. package/dist/index.js.map +0 -1
  62. package/dist/keyboard.js.map +0 -1
  63. package/dist/microphone.js.map +0 -1
  64. package/dist/ntlm.js.map +0 -1
  65. package/dist/osd.js.map +0 -1
  66. package/dist/probe.js.map +0 -1
  67. package/dist/rdcleanpath.js.map +0 -1
  68. package/dist/rdpFile.js.map +0 -1
  69. package/dist/rdpProfile.js.map +0 -1
  70. package/dist/remoteSetup.js.map +0 -1
  71. package/dist/settingsPage.js.map +0 -1
  72. package/dist/signin.js.map +0 -1
  73. package/dist/ssh.js.map +0 -1
  74. package/dist/sshChannelStream.js.map +0 -1
  75. package/dist/targets.js.map +0 -1
  76. package/dist/ui.js.map +0 -1
  77. package/dist/updates.js.map +0 -1
  78. package/dist/vms.js.map +0 -1
  79. 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
- /** A `host` or `host:port` setting as a gateway (443 by default), or null if it isn't one. */
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
- const m = /^\s*(?:\[([0-9a-f:.]+)\]|([^\s:/]+))(?::(\d{1,5}))?\s*$/i.exec(String(value ?? ''));
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
- return m && port > 0 && port < 65536 ? { host: m[1] ?? m[2], port } : null;
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
- let pending = Buffer.alloc(0);
158
+ const pending = new byteQueue_1.ByteQueue();
106
159
  return chunk => {
107
- pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
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
- onPacket(pending.readUInt16LE(0), pending.subarray(8, length));
117
- pending = pending.subarray(length);
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
- let pending = Buffer.alloc(0);
224
+ const pending = new byteQueue_1.ByteQueue();
149
225
  return chunk => {
150
- pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
226
+ pending.push(chunk);
151
227
  for (;;) {
152
- if (pending.length < 2) {
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 = pending[0] & 0x0f;
156
- const masked = (pending[1] & 0x80) !== 0;
157
- let length = pending[1] & 0x7f;
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 (pending.length < 4) {
238
+ if (head.length < 4) {
161
239
  break;
162
240
  }
163
- length = pending.readUInt16BE(2);
241
+ length = head.readUInt16BE(2);
164
242
  at = 4;
165
243
  }
166
244
  else if (length === 127) {
167
- if (pending.length < 10) {
245
+ if (head.length < 10) {
168
246
  break;
169
247
  }
170
- const long = pending.readBigUInt64BE(2);
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
- let payload = pending.subarray(at, at + length);
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] ^= pending[maskAt + (i & 3)];
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
- /** One HTTP response's status and headers, read from the stream; its body (a 401's page) is read and dropped. */
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
- let pending = Buffer.alloc(0);
211
- let head = null;
212
- const done = (error, value) => {
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 (pending.length) {
220
- stream.unshift(pending);
315
+ if (rest?.length) {
316
+ stream.unshift(rest);
221
317
  }
222
- error ? reject(error) : resolve(value);
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
- pending = Buffer.concat([pending, chunk]);
229
- if (!head) {
230
- const end = pending.indexOf('\r\n\r\n');
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 (pending.length > 64 * 1024) {
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 = pending.subarray(0, end).toString('latin1').split('\r\n');
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
- if (!status) {
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
- const m = /^([^:]+):\s*(.*)$/.exec(line);
245
- if (m) {
246
- headers.set(m[1].toLowerCase(), [...headers.get(m[1].toLowerCase()) ?? [], m[2]]);
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
- pending = pending.subarray(end + 4);
250
- // A switch of protocols has no body; other answers say how long theirs is.
251
- head = { status, headers, body: status === 101 ? 0 : Number(headers.get('content-length')?.[0] ?? 0) };
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 (pending.length >= head.body) {
254
- pending = pending.subarray(head.body);
255
- done(null, { status: head.status, headers: head.headers });
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:…`) and whether the certificate is valid for the gateway's name by this computer's
348
- * certificate authorities, and throws to refuse.
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 = !/^[\d.]+$|:/.test(gateway.host);
359
- const socket = tls.connect({ socket: raw, host: gateway.host, rejectUnauthorized: false, ...named ? { servername: gateway.host } : {}, ...options.tls });
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
- await checkCertificate(certificate.fingerprint256, socket.authorized);
371
- await upgrade(socket, gateway, account, certificate.raw, timeoutMs);
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
- log(`gateway: its administrator's message: ${body.subarray(body.length >= 2 ? 2 : 0).toString('utf16le').replace(/\0+$/, '').slice(0, 300)}`);
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: payload => socket.write(wsFrame(payload, 0xa)),
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.group.append(this.toggleButton, this.disconnectButton);
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
- if (!this.ensureGroup()) {
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
- this.group.style.display = pane ? 'flex' : 'none';
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