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/drives.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SharedDrives = void 0;
3
+ exports.SharedDrives = exports.WINDOW_OPEN_FILES = exports.MAX_OPEN_FILES = void 0;
4
4
  exports.sharedFolders = sharedFolders;
5
5
  exports.driveName = driveName;
6
+ exports.openFilesInWindow = openFilesInWindow;
6
7
  const fs = require("fs");
7
8
  const path = require("path");
8
9
  const fsp = fs.promises;
@@ -43,6 +44,69 @@ function fail(code, message) {
43
44
  }
44
45
  /** One read's worth: the server asks for 64 KiB or so; the proxy takes messages up to 32 MiB. */
45
46
  const READ_LIMIT = 4 * 1024 * 1024;
47
+ /**
48
+ * Files a desktop may have open at once. Each one holds a file descriptor of Tabby's window, which all its tabs and
49
+ * connections share, and the server decides how many it opens and when it closes them: with no limit, a hostile one
50
+ * could take them all, and the window could then open no file or connection, or crash. Programs on the remote keep
51
+ * far fewer open. More are refused (EMFILE) until the server closes some.
52
+ */
53
+ exports.MAX_OPEN_FILES = 1024;
54
+ /**
55
+ * Files all the desktops in a window may have open at once. The window's descriptors are one process's, so a limit per
56
+ * desktop alone would let several desktops together hold that many times more. The window may have 8192 open on
57
+ * Windows (the C runtime's most); on macOS, Node raises its soft limit to the hard one as it starts, which
58
+ * kern.maxfilesperproc caps (some 138,000 on a Mac measured with Electron 40). This many is a quarter of the smaller of
59
+ * those, which leaves most of them to everything else whatever the desktops do, and one desktop can't take it all
60
+ * (MAX_OPEN_FILES is half of it). On Linux the window's limit wasn't measured (the system's hard limit, often half a
61
+ * million or more, if Node raises it there as on macOS): where it is 4,096 or less, this many is half of it or more.
62
+ */
63
+ exports.WINDOW_OPEN_FILES = 2 * exports.MAX_OPEN_FILES;
64
+ /**
65
+ * Files open, being opened, or being closed, through every SharedDrives of this window (the plugin is loaded once per
66
+ * window). A file counts until its close has finished: closing waits for what is under way on the file (a slow disk or
67
+ * network folder), and the descriptor is the window's until then.
68
+ */
69
+ let openInWindow = 0;
70
+ /** Files open, being opened or being closed, on the desktops of this window (see WINDOW_OPEN_FILES). */
71
+ function openFilesInWindow() {
72
+ return openInWindow;
73
+ }
74
+ /**
75
+ * Space a desktop leaves free on the disk a shared file is on when it makes the file larger than the data it sends:
76
+ * a write past the end of a file, or a larger end of file. One request can ask for any size, and where files can't
77
+ * have holes (HFS+, NTFS, exFAT) the space is taken at once, on the whole disk; elsewhere the file looks petabytes
78
+ * long, and grows again with every request. Explorer sets a copy's size before writing it, so sizes aren't capped as
79
+ * such: the part of a file the server sent no data for (its size beyond what it has on disk, see room) may be no
80
+ * larger than the disk's free space less this much (1 GB, or a twentieth of a disk smaller than 20 GB). Data the
81
+ * server does send isn't checked: that takes as long as any copy.
82
+ */
83
+ function reserve(total) {
84
+ return Math.min(1024 ** 3, Math.floor(total / 20));
85
+ }
86
+ /**
87
+ * Growth on each disk (by device number) in this window, one at a time across its desktops: from the reading of the
88
+ * disk's free space (see room) until the file has grown. A check then never goes by free space another desktop's growth
89
+ * has taken since it was read, so two desktops can't each be told the same free space is theirs. The latest growth's
90
+ * end, which the next one on that disk waits for.
91
+ */
92
+ const growth = new Map();
93
+ /** Runs `grow` once the growth before it on disk `dev`, in any desktop of this window, has ended (see growth). */
94
+ async function inTurn(dev, grow) {
95
+ const before = growth.get(dev);
96
+ let done = () => { };
97
+ const mine = new Promise(resolve => { done = resolve; });
98
+ growth.set(dev, mine);
99
+ try {
100
+ await before;
101
+ return await grow();
102
+ }
103
+ finally {
104
+ done();
105
+ if (growth.get(dev) === mine) {
106
+ growth.delete(dev);
107
+ }
108
+ }
109
+ }
46
110
  /**
47
111
  * The shared folders as the remote desktop's file system (MS-RDPEFS drive redirection), on Tabby's Node side.
48
112
  * IronRDP asks one thing at a time and waits for the answer, so everything here is asynchronous (Node's thread
@@ -51,28 +115,59 @@ const READ_LIMIT = 4 * 1024 * 1024;
51
115
  * The server's paths are relative to the drive, with backslashes. Each resolves inside its folder, also once
52
116
  * symbolic links are followed: a path that leaves the folder, by `..` or through a link, doesn't exist as far as
53
117
  * the remote is concerned. Only regular files and folders are served (no devices, pipes or sockets, which could
54
- * block or do things on being opened). Read-only folders refuse anything that writes.
118
+ * block or do things on being opened). Read-only folders refuse anything that writes, and so does a file with more
119
+ * than one name (a hard link), which may be outside the folder too. What the server opens and how much space it
120
+ * takes without sending data are limited (MAX_OPEN_FILES and WINDOW_OPEN_FILES, reserve).
55
121
  */
56
122
  class SharedDrives {
57
- constructor(folders, log = () => null) {
123
+ /** `flow`: the room for what is read for the server (the desktop's proxy, see ServerFlow and read). */
124
+ constructor(folders, log = () => null, flow) {
58
125
  this.log = log;
126
+ this.flow = flow;
59
127
  this.byId = new Map();
60
128
  this.roots = new Map();
61
129
  this.handles = new Map();
62
130
  this.nextHandle = 1;
131
+ /** Opens under way, which count towards MAX_OPEN_FILES too. */
132
+ this.opening = 0;
133
+ /** Closes under way, which count too: the descriptor is still open until the close has finished. */
134
+ this.closing = 0;
135
+ /** A limit on open files was reached (and logged, once). */
136
+ this.full = false;
137
+ /** The connection ended (see dispose): nothing more is opened, and what was still opening is closed again. */
138
+ this.disposed = false;
63
139
  // RDPDR device ids are the session's: the audio device, when there is one, takes 0 in IronRDP.
64
140
  this.drives = folders.map((folder, i) => {
65
141
  this.byId.set(i + 1, folder);
66
142
  return { id: i + 1, name: folder.name };
67
143
  });
68
144
  }
69
- /** Closes whatever the server left open (the connection ended). */
145
+ /**
146
+ * Closes whatever the server left open (the connection ended), and what it was still opening once that is open.
147
+ * Each counts towards the limits until its close has finished (see closeFile), not before.
148
+ */
70
149
  dispose() {
150
+ this.disposed = true;
71
151
  for (const handle of this.handles.values()) {
72
- handle.file.close().catch(() => null);
152
+ this.closeFile(handle.file).catch(() => null);
73
153
  }
74
154
  this.handles.clear();
75
155
  }
156
+ /**
157
+ * Closes a file that was counted as open (or being opened), and stops counting it once the close has finished,
158
+ * whether it worked or not: closing waits for what is under way on the file, and a slot handed on before then would
159
+ * let more descriptors be open than the limits allow.
160
+ */
161
+ async closeFile(file) {
162
+ this.closing++;
163
+ try {
164
+ await file.close();
165
+ }
166
+ finally {
167
+ this.closing--;
168
+ openInWindow--;
169
+ }
170
+ }
76
171
  /** The folder's own real path (it may be reached through a link itself, as /tmp is on macOS). */
77
172
  rootOf(folder) {
78
173
  let root = this.roots.get(folder);
@@ -139,6 +234,54 @@ class SharedDrives {
139
234
  handle(id) {
140
235
  return this.handles.get(id) ?? fail('EBADF', `no open file ${id}`);
141
236
  }
237
+ /**
238
+ * Makes sure the disk of an open file (`now`: its stat) can take the file being `end` bytes long without the server
239
+ * sending data for what that adds (see reserve). What counts is the part of the file that isn't on disk, from what
240
+ * the file has there, not from its size: on file systems with holes, growing a file takes no space, so growing it
241
+ * again and again would pass a check of each growth alone. Called in the disk's turn (see grow).
242
+ */
243
+ async room(handle, now, end) {
244
+ const space = await this.spaceOf(handle, now);
245
+ // 512-byte blocks, as POSIX counts them (and libuv on Windows); a file system that doesn't say has its size.
246
+ const onDisk = Number.isFinite(now.blocks) ? now.blocks * 512 : now.size;
247
+ const unsent = Math.max(0, end - onDisk);
248
+ if (unsent > space.bsize * space.bavail - reserve(space.bsize * space.blocks)) {
249
+ fail('ENOSPC', `${handle.folder.name}: not enough free space for a file ${end} bytes long`);
250
+ }
251
+ }
252
+ /**
253
+ * Makes an open file larger than the data the server sends for it, with `change`: `end` bytes long, with no data
254
+ * from `from` (past the end of the file now) on. In its disk's turn (see growth), once room() has checked the space
255
+ * against the file as it is then: another request may have changed it while this one waited its turn.
256
+ */
257
+ grow(handle, dev, from, end, change) {
258
+ return inTurn(dev, async () => {
259
+ const now = await handle.file.stat();
260
+ if (from > now.size) {
261
+ await this.room(handle, now, end);
262
+ }
263
+ return change();
264
+ });
265
+ }
266
+ /**
267
+ * The free space of the disk an open file (`now`) is on: that of the folder it was opened in, or else of the shared
268
+ * folder, whichever is on that disk (the same device). A disk mounted somewhere in a shared folder (a USB stick in
269
+ * a shared /Volumes, a disk image in a shared home) has its own free space, which the shared folder's doesn't say.
270
+ * When neither is on it (the file's folder was moved away meanwhile), the space isn't known, and the file isn't
271
+ * grown (ENOSPC).
272
+ */
273
+ async spaceOf(handle, now) {
274
+ for (const dir of [path.dirname(handle.local), await this.rootOf(handle.folder).catch(() => null)]) {
275
+ if (dir === null) {
276
+ continue;
277
+ }
278
+ const [at, space] = await Promise.all([fsp.stat(dir), fsp.statfs(dir)]).catch(() => [null, null]);
279
+ if (at && space && at.dev === now.dev) {
280
+ return space;
281
+ }
282
+ }
283
+ return fail('ENOSPC', `${handle.folder.name}: the free space of this file's disk isn't known`);
284
+ }
142
285
  async stat(deviceId, remote) {
143
286
  let s;
144
287
  let folder;
@@ -163,7 +306,8 @@ class SharedDrives {
163
306
  created: s.birthtimeMs || s.ctimeMs,
164
307
  accessed: s.atimeMs,
165
308
  modified: s.mtimeMs,
166
- readonly: folder.readOnly || (s.mode & 0o200) === 0,
309
+ // A file with other names (hard links) isn't changed through the share (see open), so it shows read-only.
310
+ readonly: folder.readOnly || (s.mode & 0o200) === 0 || (s.isFile() && s.nlink > 1),
167
311
  };
168
312
  }
169
313
  async list(deviceId, remote) {
@@ -171,6 +315,43 @@ class SharedDrives {
171
315
  return fsp.readdir(local);
172
316
  }
173
317
  async open(deviceId, remote, mode) {
318
+ if (this.disposed) {
319
+ fail('EBADF', `${remote}: the connection has ended`);
320
+ }
321
+ const mine = this.handles.size + this.opening + this.closing >= exports.MAX_OPEN_FILES;
322
+ if (mine || openInWindow >= exports.WINDOW_OPEN_FILES) {
323
+ if (!this.full) {
324
+ this.full = true;
325
+ this.log(mine
326
+ ? `drives: the remote has ${exports.MAX_OPEN_FILES} files open, the most a desktop may; more are refused until it closes some`
327
+ : `drives: the desktops in this window have ${exports.WINDOW_OPEN_FILES} files open, the most they may together; more are refused until some are closed`);
328
+ }
329
+ fail('EMFILE', `${remote}: ${mine ? exports.MAX_OPEN_FILES : exports.WINDOW_OPEN_FILES} files are open already`);
330
+ }
331
+ this.opening++;
332
+ openInWindow++;
333
+ let handle;
334
+ try {
335
+ handle = await this.openFile(deviceId, remote, mode);
336
+ }
337
+ catch (e) {
338
+ openInWindow--;
339
+ throw e;
340
+ }
341
+ finally {
342
+ this.opening--;
343
+ }
344
+ // The connection ended while the file was being opened: dispose() has closed the others, and nothing would
345
+ // close this one.
346
+ if (this.disposed) {
347
+ await this.closeFile(handle.file).catch(() => null);
348
+ fail('EBADF', `${remote}: the connection has ended`);
349
+ }
350
+ const id = this.nextHandle++;
351
+ this.handles.set(id, handle);
352
+ return id;
353
+ }
354
+ async openFile(deviceId, remote, mode) {
174
355
  const { folder, local } = await this.locate(deviceId, remote, true);
175
356
  if (mode.write || mode.create || mode.truncate) {
176
357
  this.writable(folder, `can't write ${remote}`);
@@ -180,51 +361,109 @@ class SharedDrives {
180
361
  fail(existing.isDirectory() ? 'EISDIR' : 'EACCES', `${remote}: not a file`);
181
362
  }
182
363
  // The path was resolved just now: a link (or a pipe) put in its place meanwhile isn't followed (or waited on).
183
- const { O_RDONLY, O_RDWR, O_CREAT, O_EXCL, O_TRUNC } = fs.constants;
364
+ // Truncating waits until the file is known to be one the remote may change (below).
365
+ const { O_RDONLY, O_RDWR, O_CREAT, O_EXCL } = fs.constants;
184
366
  const guard = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
185
- const flags = (mode.write ? O_RDWR : O_RDONLY) | (mode.create ? O_CREAT : 0) | (mode.exclusive ? O_EXCL : 0) | (mode.truncate ? O_TRUNC : 0) | guard;
367
+ const flags = (mode.write || mode.truncate ? O_RDWR : O_RDONLY) | (mode.create ? O_CREAT : 0) | (mode.exclusive ? O_EXCL : 0) | guard;
186
368
  const file = await fsp.open(local, flags, 0o644);
187
- if (!(await file.stat()).isFile()) {
188
- await file.close();
189
- fail('EACCES', `${remote}: not a file`);
369
+ try {
370
+ const s = await file.stat();
371
+ if (!s.isFile()) {
372
+ fail('EACCES', `${remote}: not a file`);
373
+ }
374
+ // A file with another name (a hard link) is the same file there, which may be outside the shared folder:
375
+ // what the remote writes would change it there too. Reading it is reading what is in the folder.
376
+ if ((mode.write || mode.truncate) && s.nlink > 1) {
377
+ fail('EACCES', `${remote}: has more than one name (a hard link), so it isn't changed from the remote`);
378
+ }
379
+ if (mode.truncate) {
380
+ await file.truncate(0);
381
+ }
190
382
  }
191
- const id = this.nextHandle++;
192
- this.handles.set(id, { file, folder });
193
- return id;
383
+ catch (e) {
384
+ await file.close().catch(() => null);
385
+ throw e;
386
+ }
387
+ return { file, folder, local };
194
388
  }
389
+ /**
390
+ * A read of the server's: up to READ_LIMIT, as much as it asks for. Its answer is handed to the connection, where
391
+ * it stays, in the window's memory, until the proxy has read it, as the server takes what is sent: a server that
392
+ * keeps asking and reads slowly, or not at all, would otherwise have every answer kept there. So it takes room for
393
+ * it first (see ServerFlow), waiting while there is none, and gives back what it didn't read.
394
+ *
395
+ * How far the room holds the answers back: see ServerFlow (the proxy reading the connection's other messages makes
396
+ * room too).
397
+ */
195
398
  async read(handle, offset, length) {
399
+ const size = Math.min(length, READ_LIMIT);
400
+ while (this.flow && !this.flow.room()) {
401
+ await this.flow.whenRoom();
402
+ }
196
403
  const { file } = this.handle(handle);
197
- const buffer = Buffer.allocUnsafe(Math.min(length, READ_LIMIT));
404
+ this.flow?.spend(size);
405
+ const buffer = Buffer.allocUnsafe(size);
198
406
  let read = 0;
199
- // A read can come back short before the end of the file: fill what was asked.
200
- while (read < buffer.length) {
201
- const { bytesRead } = await file.read(buffer, read, buffer.length - read, offset + read);
202
- if (bytesRead === 0) {
203
- break;
407
+ try {
408
+ // A read can come back short before the end of the file: fill what was asked.
409
+ while (read < buffer.length) {
410
+ const { bytesRead } = await file.read(buffer, read, buffer.length - read, offset + read);
411
+ if (bytesRead === 0) {
412
+ break;
413
+ }
414
+ read += bytesRead;
204
415
  }
205
- read += bytesRead;
206
416
  }
417
+ catch (e) {
418
+ this.flow?.refund(size);
419
+ throw e;
420
+ }
421
+ this.flow?.refund(size - read);
207
422
  return new Uint8Array(buffer.buffer, buffer.byteOffset, read);
208
423
  }
209
424
  async write(handle, offset, data) {
210
- const { file, folder } = this.handle(handle);
425
+ const entry = this.handle(handle);
426
+ const { file, folder } = entry;
211
427
  this.writable(folder, 'can\'t write');
212
- let written = 0;
213
- while (written < data.length) {
214
- written += (await file.write(data, written, data.length - written, offset + written)).bytesWritten;
428
+ const end = offset + data.length;
429
+ if (!(offset >= 0 && Number.isSafeInteger(offset) && Number.isSafeInteger(end))) {
430
+ fail('ENOSPC', `can't write at ${offset}: past what a file can hold`);
215
431
  }
216
- return written;
432
+ const writeAll = async () => {
433
+ let written = 0;
434
+ while (written < data.length) {
435
+ written += (await file.write(data, written, data.length - written, offset + written)).bytesWritten;
436
+ }
437
+ return written;
438
+ };
439
+ // Past the end of the file, the gap up to the data is made as well, with nothing sent for it (see room).
440
+ if (offset > 0) {
441
+ const now = await file.stat();
442
+ if (offset > now.size) {
443
+ return this.grow(entry, now.dev, offset, end, writeAll);
444
+ }
445
+ }
446
+ return writeAll();
217
447
  }
218
448
  async truncate(handle, size) {
219
- const { file, folder } = this.handle(handle);
449
+ const entry = this.handle(handle);
450
+ const { file, folder } = entry;
220
451
  this.writable(folder, 'can\'t resize');
452
+ if (!(size >= 0 && Number.isSafeInteger(size))) {
453
+ fail('ENOSPC', `can't make a file ${size} bytes long`);
454
+ }
455
+ // A larger end of file: what it adds has no data sent for it (see room).
456
+ const now = await file.stat();
457
+ if (size > now.size) {
458
+ return this.grow(entry, now.dev, size, size, () => file.truncate(size));
459
+ }
221
460
  await file.truncate(size);
222
461
  }
223
462
  async close(handle) {
224
463
  const entry = this.handles.get(handle);
225
464
  if (entry) {
226
465
  this.handles.delete(handle);
227
- await entry.file.close();
466
+ await this.closeFile(entry.file);
228
467
  }
229
468
  }
230
469
  async mkdir(deviceId, remote) {
@@ -272,7 +511,10 @@ class SharedDrives {
272
511
  const folder = this.byId.get(deviceId) ?? fail('ENOENT', `no drive ${deviceId}`);
273
512
  try {
274
513
  const s = await fsp.statfs(await this.rootOf(folder));
275
- return { total: s.bsize * s.blocks, free: s.bsize * s.bavail, label: folder.name };
514
+ const total = s.bsize * s.blocks;
515
+ // Free space less the reserve, which the remote can't take by making files larger (see room): Explorer
516
+ // checks a copy fits before starting it.
517
+ return { total, free: Math.max(0, s.bsize * s.bavail - reserve(total)), label: folder.name };
276
518
  }
277
519
  catch (e) {
278
520
  this.log(`drives: ${folder.name}: no volume information: ${e?.message ?? e}`);
@@ -281,4 +523,3 @@ class SharedDrives {
281
523
  }
282
524
  }
283
525
  exports.SharedDrives = SharedDrives;
284
- //# sourceMappingURL=drives.js.map