tabby-rdp 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  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 +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  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 +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. package/dist/wake.js.map +0 -1
package/dist/rdpFile.js CHANGED
@@ -1,6 +1,40 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MICROPHONE_NOTE = void 0;
3
4
  exports.parseRdpFile = parseRdpFile;
5
+ /**
6
+ * The note for a file that asks for the microphone: not applied while the setting is off. With it on, every desktop
7
+ * gets the microphone, and the import says so instead (see RemoteDesktopService.importRdp).
8
+ */
9
+ exports.MICROPHONE_NOTE = 'the microphone (Settings › Remote Desktop › Microphone)';
10
+ /** What a setting in the file would mean here, when it isn't applied: by key, for the values that ask for something. */
11
+ const NOTES = {
12
+ 'audiomode': v => v === '1' ? 'sound left on the remote (sound plays here; Settings › Remote Desktop › Sound)' : v === '2' ? 'sound off (Settings › Remote Desktop › Sound)' : null,
13
+ 'audiocapturemode': v => v === '1' ? exports.MICROPHONE_NOTE : null,
14
+ 'redirectdrives': v => v === '1' ? 'its drives (share folders in Settings › Remote Desktop › Shared folders)' : null,
15
+ 'drivestoredirect': v => v ? 'its drives (share folders in Settings › Remote Desktop › Shared folders)' : null,
16
+ 'redirectprinters': v => v === '1' ? 'printer redirection (not supported)' : null,
17
+ 'redirectsmartcards': v => v === '1' ? 'smart card redirection (not supported)' : null,
18
+ 'redirectcomports': v => v === '1' ? 'serial port redirection (not supported)' : null,
19
+ 'usbdevicestoredirect': v => v ? 'USB redirection (not supported)' : null,
20
+ 'use multimon': v => v === '1' ? 'several monitors (one screen per desktop here)' : null,
21
+ 'screen mode id': (v, all) => v === '1' && (all.get('desktopwidth') || all.get('desktopheight'))
22
+ ? `a fixed ${all.get('desktopwidth') ?? '?'}×${all.get('desktopheight') ?? '?'} window (the desktop follows the pane; Settings › Remote Desktop › When the pane is resized)` : null,
23
+ 'gatewayhostname': (v, all) => v && !usesGateway(all) ? `the RD Gateway ${v}, which the file names but doesn't use (the profile's RD Gateway field takes it)` : null,
24
+ 'promptcredentialonce': (v, all) => v === '0' && usesGateway(all) ? 'an account of its own for the gateway (pick a saved account as the profile\'s Gateway account)' : null,
25
+ 'gatewaycredentialssource': (v, all) => (v === '1' || v === '5') && usesGateway(all) ? `a ${v === '1' ? 'smart card' : 'sign-in page'} at the gateway (not supported; it is signed in to with a user name and password)` : null,
26
+ 'remoteapplicationmode': v => v === '1' ? 'RemoteApp (not supported; the whole desktop opens)' : null,
27
+ 'alternate shell': v => v ? 'a program to start instead of the desktop (not supported)' : null,
28
+ 'enablecredsspsupport': v => v === '0' ? 'sign-in without Network Level Authentication (it is always used)' : null,
29
+ };
30
+ /**
31
+ * Whether the file connects through its gateway: it names one, and `gatewayusagemethod` doesn't say not to (0, or 4:
32
+ * never; 1 to 3: always, when a direct connection fails, by the user's defaults). mstsc always writes the method; a
33
+ * file written by hand that only names a gateway means it.
34
+ */
35
+ function usesGateway(settings) {
36
+ return !!settings.get('gatewayhostname') && !['0', '4'].includes(settings.get('gatewayusagemethod') ?? '');
37
+ }
4
38
  /** mstsc writes UTF-16LE with a byte order mark; other tools write UTF-8, with or without one. */
5
39
  function decode(data) {
6
40
  const b = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
@@ -22,26 +56,41 @@ function decode(data) {
22
56
  /** The connection a .rdp file describes, or null without a usable `full address`. */
23
57
  function parseRdpFile(data) {
24
58
  const settings = new Map();
59
+ // A limit is kept whichever way the file writes it (`0`, `00`, `+0`), and wherever it is: a file that says it
60
+ // twice, differently, gets the narrower of the two rather than whichever line comes last.
61
+ let clipboardOff = false;
25
62
  for (const line of decode(data).split(/\r?\n/)) {
26
63
  const m = /^\s*([^:]+?)\s*:\s*([sib])\s*:(.*)$/i.exec(line);
27
64
  if (m) {
28
- settings.set(m[1].toLowerCase(), m[3].trim());
65
+ const key = m[1].toLowerCase();
66
+ const value = m[3].trim();
67
+ settings.set(key, value);
68
+ clipboardOff ||= key === 'redirectclipboard' && /^[+-]?\d+$/.test(value) && Number(value) === 0;
29
69
  }
30
70
  }
31
71
  const full = settings.get('full address') ?? '';
32
- // host, host:port, [v6]:port, or a bare IPv6 address (colons, no brackets).
33
- const m = /^\[([^\]]+)\](?::(\d+))?$|^([^:\s]+)(?::(\d+))?$/.exec(full);
72
+ // host, host:port, [v6]:port, or a bare IPv6 address (colons, no brackets). No '#': session and trust keys are
73
+ // split on it, so a host that carried one could be taken for another desktop's key (see keyParts).
74
+ const m = /^\[([^\]#]+)\](?::(\d+))?$|^([^:\s#]+)(?::(\d+))?$/.exec(full);
34
75
  const host = m?.[1] ?? m?.[3] ?? (/^[0-9a-f:.]+$/i.test(full) && (full.match(/:/g)?.length ?? 0) > 1 ? full : null);
35
76
  // A port in the address wins over `server port`, as in mstsc.
36
77
  const port = Number(m?.[2] ?? m?.[4] ?? (settings.get('server port') || 3389));
37
78
  if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
38
79
  return null;
39
80
  }
81
+ const scale = Number(settings.get('desktopscalefactor'));
82
+ // By own property only: a file whose key is `__proto__` or `constructor` names an inherited member of the NOTES
83
+ // object (Object.prototype, the Object constructor), not a note, and calling one would throw or inject its text.
84
+ const noteFor = (key) => Object.prototype.hasOwnProperty.call(NOTES, key) ? NOTES[key] : undefined;
85
+ const ignored = [...settings].map(([key, value]) => noteFor(key)?.(value, settings) ?? null).filter((n) => !!n);
40
86
  return {
41
87
  host,
42
88
  port,
43
89
  ...settings.get('username') ? { username: settings.get('username') } : {},
44
90
  ...settings.get('domain') ? { domain: settings.get('domain') } : {},
91
+ ...Number.isFinite(scale) && scale >= 100 ? { sharpness: scale >= 150 ? 'retina' : 'standard' } : {},
92
+ ...usesGateway(settings) ? { gateway: settings.get('gatewayhostname') } : {},
93
+ ...clipboardOff ? { clipboard: 'off' } : {},
94
+ ignored: [...new Set(ignored)],
45
95
  };
46
96
  }
47
- //# sourceMappingURL=rdpFile.js.map
@@ -18,8 +18,10 @@ const desktopForm_1 = require("./desktopForm");
18
18
  const help_1 = require("./help");
19
19
  const accounts_1 = require("./accounts");
20
20
  const accountForm_1 = require("./accountForm");
21
+ const clipboard_1 = require("./clipboard");
21
22
  const desktops_1 = require("./desktops");
22
- const signin_1 = require("./signin");
23
+ const gateway_1 = require("./gateway");
24
+ const osd_1 = require("./osd");
23
25
  const ssh_1 = require("./ssh");
24
26
  const targets_1 = require("./targets");
25
27
  /** Input of an SSH tab opened for an RDP profile with `via`: the desktop to show once SSH is connected. */
@@ -133,7 +135,7 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
133
135
  const o = this.profile.options;
134
136
  const account = () => `${o.username ?? ''}\n${o.domain ?? ''}\n${o.account ?? ''}`;
135
137
  // No id for a new profile (no address yet): it has nothing saved to take along.
136
- this.before = { id: o.host ? (0, desktops_1.desktopIdOf)(o) : '', account: account(), via: o.via ?? '' };
138
+ this.before = { id: o.host ? (0, desktops_1.desktopIdOf)(o) : '', account: account(), via: o.via ?? '', gateway: o.gateway ?? '' };
137
139
  const root = this.element.nativeElement;
138
140
  root.innerHTML = `
139
141
  <div class="mb-3">
@@ -167,6 +169,23 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
167
169
  <div class="mb-3" data-own-account>
168
170
  <label>Domain</label>
169
171
  <input class="form-control" name="domain" spellcheck="false" placeholder="Optional">
172
+ </div>
173
+ <div class="mb-3">
174
+ <label>RD Gateway</label>
175
+ <input class="form-control" name="gateway" spellcheck="false" placeholder="Optional: a Remote Desktop Gateway, e.g. rdgw.example.com">
176
+ <div class="text-danger small" data-for="gateway"></div>
177
+ <div class="text-muted small">The desktop is then reached through the gateway (HTTPS, port 443 unless given), and the address above is as the gateway sees it.</div>
178
+ </div>
179
+ <div class="mb-3" data-gateway-account>
180
+ <label>Gateway account</label>
181
+ <select class="form-control" name="gatewayAccount"></select>
182
+ <div class="text-muted small">A gateway that takes another account than the desktop: a saved account for it.</div>
183
+ </div>
184
+ <div class="mb-3">
185
+ <label>Clipboard</label>
186
+ <select class="form-control" name="clipboard"></select>
187
+ <div class="text-muted small">Which ways text, pictures and files copied on either side go between this computer and the desktop.
188
+ Applies on the next connection.</div>
170
189
  </div>`;
171
190
  const field = (name) => root.querySelector(`[name="${name}"]`);
172
191
  field('address').value = o.host ? (0, desktopForm_1.formatAddress)(o.host, o.port || 3389) : '';
@@ -208,6 +227,31 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
208
227
  ownAccount();
209
228
  field('username').addEventListener('input', () => { o.username = field('username').value.trim(); });
210
229
  field('domain').addEventListener('input', () => { o.domain = field('domain').value.trim(); });
230
+ // The gateway, and its account where that isn't the desktop's (offered once there is a gateway and a saved account).
231
+ const gateway = field('gateway');
232
+ const gatewayAccount = field('gatewayAccount');
233
+ gateway.value = o.gateway ?? '';
234
+ gatewayAccount.append(new Option('The same as the desktop\'s', ''), ...accounts.map(a => new Option(label(a), a.id)));
235
+ if (o.gatewayAccount && !accounts.some(a => a.id === o.gatewayAccount)) {
236
+ gatewayAccount.append(new Option('(a saved account that no longer exists)', o.gatewayAccount));
237
+ }
238
+ gatewayAccount.value = o.gatewayAccount ?? '';
239
+ const gatewayChanged = () => {
240
+ const text = gateway.value.trim();
241
+ root.querySelector('[data-for=gateway]').textContent = text && !(0, gateway_1.parseGateway)(text) ? 'A name or address, with :port if it isn\'t 443.' : '';
242
+ root.querySelector('[data-gateway-account]').hidden = !text || gatewayAccount.options.length < 2;
243
+ };
244
+ gateway.addEventListener('input', () => {
245
+ o.gateway = gateway.value.trim();
246
+ gatewayChanged();
247
+ });
248
+ gatewayAccount.addEventListener('change', () => { o.gatewayAccount = gatewayAccount.value; });
249
+ gatewayChanged();
250
+ // Its own clipboard sharing, or the setting's (named, as it is now).
251
+ const clipboard = field('clipboard');
252
+ clipboard.append(new Option(`As in Settings › Remote Desktop (${clipboard_1.CLIPBOARD_LABELS[desktop.settings().clipboard].toLowerCase()})`, ''), ...Object.entries(clipboard_1.CLIPBOARD_LABELS).map(([mode, text]) => new Option(text, mode)));
253
+ clipboard.value = (0, clipboard_1.ownClipboard)(o.clipboard) ?? '';
254
+ clipboard.addEventListener('change', () => { o.clipboard = clipboard.value; });
211
255
  const via = field('via');
212
256
  via.addEventListener('change', () => { o.via = via.value; });
213
257
  this.injector.get(tabby_core_1.ProfilesService).getProfiles().then(profiles => {
@@ -221,21 +265,22 @@ let RDPProfileSettingsComponent = class RDPProfileSettingsComponent {
221
265
  }
222
266
  save() {
223
267
  const o = this.profile.options;
224
- const { id, account, via } = this.before;
268
+ const { id, account, via, gateway } = this.before;
225
269
  if (!id) {
226
270
  return;
227
271
  }
228
272
  if ((o.via ?? '') !== via) {
229
- // Reached another way, it is another desktop. A direct one's saved account and certificate were its own; one
230
- // behind a host shares them with that host's desktop at the same address, so they stay.
273
+ // Reached another way, it is another desktop. A direct one's saved passwords (through any gateway, the ones
274
+ // it had before included) and certificate were its own; one behind a host shares them with that host's
275
+ // desktop at the same address, so they stay.
231
276
  if (!via) {
232
- (0, signin_1.forgetCredentials)(`${desktops_1.DIRECT_KEY}#${id}`);
233
- this.injector.get(desktop_service_1.RemoteDesktopService).forgetCertificatesFor(id, true);
277
+ this.injector.get(desktop_service_1.RemoteDesktopService).forgetDesktop(id, true, `"${this.profile.name}"`).catch(() => null);
234
278
  }
235
279
  return;
236
280
  }
281
+ // Its saved password goes with its way there (see desktopEdited): another gateway is asked for again.
237
282
  const accountChanged = `${o.username ?? ''}\n${o.domain ?? ''}\n${o.account ?? ''}` !== account;
238
- this.injector.get(desktop_service_1.RemoteDesktopService).desktopEdited(id, (0, desktops_1.desktopIdOf)(o), accountChanged, !via).catch(() => null);
283
+ this.injector.get(desktop_service_1.RemoteDesktopService).desktopEdited(id, (0, desktops_1.desktopIdOf)(o), accountChanged, !via, { from: gateway, to: o.gateway }).catch(() => null);
239
284
  }
240
285
  };
241
286
  exports.RDPProfileSettingsComponent = RDPProfileSettingsComponent;
@@ -257,17 +302,20 @@ let RDPProfilesService = class RDPProfilesService extends tabby_core_1.QuickConn
257
302
  this.name = 'Remote desktop (RDP)';
258
303
  this.settingsComponent = RDPProfileSettingsComponent;
259
304
  this.configDefaults = {
260
- options: { host: '', port: 3389, kind: 'windows', username: '', domain: '', account: '', via: '' },
305
+ options: { host: '', port: 3389, kind: 'windows', username: '', domain: '', account: '', via: '', gateway: '', gatewayAccount: '', clipboard: '' },
261
306
  clearServiceMessagesOnConnect: false,
262
307
  };
263
308
  }
264
309
  async getBuiltinProfiles() {
310
+ // No clipboard here: a new profile is made from a copy of this, and a value saved with it (even '', the
311
+ // setting's) would hide the clipboard its group's or the type's defaults give it, which may well be narrower.
312
+ // Left out, the profile follows those defaults (configDefaults has the key) until it is given one of its own.
265
313
  return [{
266
314
  id: `${targets_1.RDP_PROFILE_TYPE}:template`,
267
315
  type: targets_1.RDP_PROFILE_TYPE,
268
316
  name: 'Remote desktop (RDP)',
269
317
  icon: 'fas fa-desktop',
270
- options: { host: '', port: 3389, kind: 'windows', username: '', domain: '', account: '', via: '' },
318
+ options: { host: '', port: 3389, kind: 'windows', username: '', domain: '', account: '', via: '', gateway: '', gatewayAccount: '' },
271
319
  isBuiltin: true,
272
320
  isTemplate: true,
273
321
  }];
@@ -291,12 +339,19 @@ let RDPProfilesService = class RDPProfilesService extends tabby_core_1.QuickConn
291
339
  }
292
340
  getDescription(profile) {
293
341
  const o = profile.options ?? {};
294
- if (!o.host) {
342
+ // Each field as text whatever the config holds (see configText): Tabby asks this of every profile as it lists
343
+ // them in its profile selector, and one that throws keeps the selector from opening at all.
344
+ const host = (0, desktops_1.configText)(o.host);
345
+ if (!host) {
295
346
  return '';
296
347
  }
297
- const address = (0, desktopForm_1.formatAddress)(o.host, o.port || 3389);
298
- const via = o.via ? (this.config.store.profiles ?? []).find((p) => p.id === o.via)?.name ?? 'an SSH profile' : null;
299
- return via ? `${address} via ${via}` : address;
348
+ const address = (0, desktopForm_1.formatAddress)(host, (0, osd_1.configNumber)(o.port || 3389));
349
+ const gateway = (0, desktops_1.configText)(o.gateway);
350
+ const via = [
351
+ ...gateway ? [`the gateway ${gateway}`] : [],
352
+ ...o.via ? [(0, desktops_1.configText)((this.config.store.profiles ?? []).find((p) => p.id === o.via)?.name) || 'an SSH profile'] : [],
353
+ ];
354
+ return via.length ? `${address} via ${via.join(', via ')}` : address;
300
355
  }
301
356
  /** `[user@]host[:port]`, typed in Tabby's profile selector. */
302
357
  quickConnect(query) {
@@ -313,16 +368,15 @@ let RDPProfilesService = class RDPProfilesService extends tabby_core_1.QuickConn
313
368
  }
314
369
  intoQuickConnectString(profile) {
315
370
  const o = profile.options;
316
- return o.via ? null : `${o.username ? `${o.username}@` : ''}${(0, desktopForm_1.formatAddress)(o.host, o.port || 3389)}`;
371
+ // As text, as getDescription reads them: the profile selector asks this of every profile too.
372
+ const username = (0, desktops_1.configText)(o.username);
373
+ return o.via ? null : `${username ? `${username}@` : ''}${(0, desktopForm_1.formatAddress)((0, desktops_1.configText)(o.host), (0, osd_1.configNumber)(o.port || 3389))}`;
317
374
  }
318
375
  deleteProfile(profile) {
319
- // A direct desktop's saved account and certificate are its own; one behind a host shares them with that host's
320
- // desktop at the same address.
376
+ // A direct desktop's saved passwords (through any gateway: one it went through before too) and certificate are
377
+ // its own; one behind a host shares them with that host's desktop at the same address.
321
378
  if (!profile.options.via) {
322
- const id = (0, desktops_1.desktopIdOf)(profile.options);
323
- (0, signin_1.forgetCredentials)(`${desktops_1.DIRECT_KEY}#${id}`);
324
- this.injector.get(desktop_service_1.RemoteDesktopService).forgetCertificatesFor(id, true);
325
- this.config.save();
379
+ this.injector.get(desktop_service_1.RemoteDesktopService).forgetDesktop((0, desktops_1.desktopIdOf)(profile.options), true, `"${profile.name}"`).catch(() => null);
326
380
  }
327
381
  }
328
382
  };
@@ -392,4 +446,3 @@ exports.RDPProfileOpener = RDPProfileOpener = __decorate([
392
446
  (0, core_1.Injectable)(),
393
447
  __metadata("design:paramtypes", [desktop_service_1.RemoteDesktopService, core_1.NgZone])
394
448
  ], RDPProfileOpener);
395
- //# sourceMappingURL=rdpProfile.js.map
@@ -3,14 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.prepareRemoteDesktop = prepareRemoteDesktop;
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
+ const desktops_1 = require("./desktops");
6
7
  const rdcleanpath_1 = require("./rdcleanpath");
7
8
  // Runs as the SSH user; needs no root. Prints exactly one `RD_OK port=N user=U pass=P cert=SHA256`,
8
9
  // `RD_XRDP port=N user=U` (no GNOME, but xrdp), `RD_WINDOWS` (a Windows host's POSIX sh) or `RD_ERR <reason>` line;
9
- // with RD_OK, also an RD_XRDP line when xrdp runs besides GNOME, `RD_CLIENTS N`, the clients connected already, and
10
- // `RD_TAKEN_OVER <client> <seconds ago>` after a recent take-over.
10
+ // with RD_OK, also an RD_XRDP line when xrdp runs besides GNOME, `RD_CLIENTS N`, the clients connected already,
11
+ // `RD_TAKEN_OVER <client> <seconds ago>` after a recent take-over, `RD_DESK <key>` with 'desk' on, and an `RD_NOTE <text>`
12
+ // for each rc file it couldn't change.
11
13
  // The RDP password is generated once per remote user and kept in a 0600 file; grd reads credentials only
12
14
  // at startup, so grd is restarted only when its configuration actually changes (that drops live sessions).
13
- // (grdctl takes the credentials as arguments, so they are briefly visible in the remote's process list.)
15
+ // Secrets never go on a command line, which any user there can see in the process list, except where grdctl can't
16
+ // read the password from its input: then it is an argument once, when the credentials are set.
14
17
  const SETUP_SCRIPT = String.raw `
15
18
  say () { printf '%s\n' "$*"; }
16
19
  fail () { say "RD_ERR $*"; exit 0; }
@@ -59,12 +62,49 @@ OLD="$HOME/.local/share/tabby-remote-desktop"
59
62
  if [ -d "$OLD" ] && [ ! -e "$D" ]; then
60
63
  mv "$OLD" "$D" || fail "could not move $OLD to $D"
61
64
  fi
62
- for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
63
- if [ -f "$rc" ] && grep -qF '# tabby-remote-desktop' "$rc"; then
64
- sed -i.trd-bak '/# tabby-remote-desktop$/d' "$rc" && rm -f "$rc.trd-bak"
65
+ mkdir -p "$D" && chmod 700 "$D" || fail "could not create $D"
66
+ # The login hook ('desk'): this one line in ~/.bashrc and ~/.zshrc, added and removed exactly, so that a line of the
67
+ # user's own that only looks like it stays.
68
+ HOOK='[ -f "$HOME/.local/share/tabby-rdp/login.sh" ] && . "$HOME/.local/share/tabby-rdp/login.sh" # tabby-rdp'
69
+ # Takes the lines that are exactly $2 out of rc file $1 and changes nothing else. grep reads it byte by byte, as text
70
+ # (LC_ALL=C, -a): otherwise a NUL byte, or a byte the host's locale can't read, has it leave lines out or print "Binary
71
+ # file … matches" in their place. What it leaves must be the file less those lines, to the byte, and is written into
72
+ # the file itself, so that a symbolic link to it, another name for it (a hard link), its mode and its owner all stay.
73
+ # A file that can't be written (read-only) or doesn't add up is left as it is, and the setup says so (RD_NOTE).
74
+ unhook () {
75
+ [ -f "$1" ] || return 0
76
+ LC_ALL=C grep -a -q -xF -- "$2" "$1" 2>/dev/null
77
+ case $? in 0) ;; 1) return 0 ;; *) say "RD_NOTE desk's line may be in $1, which can't be read"; return 0 ;; esac
78
+ uh_tmp=$(mktemp "$D/rc.XXXXXX") || { say "RD_NOTE desk's line stays in $1: no temporary file could be made in $D"; return 0; }
79
+ LC_ALL=C grep -a -v -xF -- "$2" "$1" > "$uh_tmp"
80
+ # 1: no line left, the file held only the hook. Each line taken out goes with its newline, and grep ends a last line
81
+ # that has none with one: so what is left is shorter by exactly that, or one byte longer than that. A step that fails
82
+ # counts as a mismatch (rather than ending the script, as a failed sum would).
83
+ if [ $? -le 1 ] && uh_was=$(wc -c < "$1") && uh_lines=$(LC_ALL=C grep -a -c -xF -- "$2" "$1") && uh_now=$(wc -c < "$uh_tmp"); then
84
+ uh_more=$(( $uh_now - ($uh_was - $uh_lines * (${"$"}{#2} + 1)) ))
85
+ else
86
+ uh_more=-1
65
87
  fi
88
+ if [ "$uh_more" != 0 ] && [ "$uh_more" != 1 ]; then
89
+ say "RD_NOTE desk's line stays in $1: the rest of it didn't copy exactly"
90
+ elif ! cat "$uh_tmp" 2>/dev/null > "$1"; then
91
+ # Not opened (read-only), the file is as it was, its line in it. Otherwise writing it failed on the way (a full
92
+ # disk): what it is to hold stays where the user can find it.
93
+ if ! LC_ALL=C grep -a -q -xF -- "$2" "$1" 2>/dev/null; then
94
+ say "RD_NOTE writing $1 failed; what it should hold (without desk's line) is in $uh_tmp"
95
+ return 0
96
+ fi
97
+ say "RD_NOTE desk's line stays in $1: it can't be written (read-only?)"
98
+ fi
99
+ rm -f "$uh_tmp"
100
+ }
101
+ # The hook from before 0.2, as those versions wrote it, and what it sources, where the old folder is still there (it
102
+ # couldn't be moved over): so that a copy of the line that was changed since, and stays, does nothing either.
103
+ OLD_HOOK='[ -f "$HOME/.local/share/tabby-remote-desktop/login.sh" ] && . "$HOME/.local/share/tabby-remote-desktop/login.sh" # tabby-remote-desktop'
104
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
105
+ unhook "$rc" "$OLD_HOOK"
66
106
  done
67
- mkdir -p "$D" && chmod 700 "$D" || fail "could not create $D"
107
+ rm -f "$OLD/login.sh"
68
108
  NEW_CERT=0
69
109
  if [ ! -s "$D/tls.key" ]; then
70
110
  openssl req -new -newkey rsa:3072 -days 3650 -nodes -x509 -subj "/CN=$(hostname)" \
@@ -77,8 +117,29 @@ CERT=$(openssl x509 -in "$D/tls.crt" -noout -fingerprint -sha256 2>/dev/null | s
77
117
  # 'desk' support (setting, off by default): interactive SSH logins start inside a shareable session (trd-pty,
78
118
  # or tmux), so 'desk' can attach this exact console to a terminal on the desktop. Rewritten on every connect;
79
119
  # opt out per login with TABBY_NO_SESSION=1. When the setting is off, the hook and helpers are removed.
120
+ DESK_KEY=
80
121
  if [ "${"$"}{TRD_DESK:-0}" = 1 ]; then
81
122
  mkdir -p "$D/bin"
123
+ # 'desk' sends this key with each request, and the plugin (told it below) ignores requests without it: anything shown
124
+ # in a terminal could print the rest. Only this user can read it. Made once, like the RDP password: a key is a file of
125
+ # this user's own (not a link), private, holding 128 bits in hex.
126
+ desk_key () {
127
+ DESK_KEY=
128
+ [ -f "$D/desk-key" ] && [ ! -L "$D/desk-key" ] && [ -O "$D/desk-key" ] || return 1
129
+ DESK_KEY=$(cat "$D/desk-key")
130
+ case $DESK_KEY in *[!0-9a-f]*) DESK_KEY= ;; esac
131
+ [ ${"$"}{#DESK_KEY} = 32 ] || { DESK_KEY=; return 1; }
132
+ chmod 600 "$D/desk-key"
133
+ }
134
+ if ! desk_key; then
135
+ # Put in place in one step, where there is none: of two setups making one at once (two panes, or two computers),
136
+ # the first one's stays, and both report it.
137
+ T=$(mktemp "$D/desk-key.XXXXXX") && od -An -N16 -tx1 /dev/urandom | tr -d ' \n' > "$T" || fail "could not create a key for desk"
138
+ { [ -e "$D/desk-key" ] || [ -L "$D/desk-key" ]; } && ! desk_key && rm -f "$D/desk-key"
139
+ ln "$T" "$D/desk-key" 2>/dev/null || desk_key || mv -f "$T" "$D/desk-key"
140
+ rm -f "$T"
141
+ desk_key || fail "could not create a key for desk"
142
+ fi
82
143
  cat > "$D/login.sh" <<'TRD_EOF'
83
144
  # tabby-rdp ('desk' setting): interactive SSH logins run inside a shareable session, so 'desk' in Tabby can
84
145
  # bring this console to the remote desktop. Backend ("native" trd-pty or "tmux") is in $TRD_HOME/backend.
@@ -128,7 +189,9 @@ fi
128
189
  # Which machine this is: a shared home folder puts this script on others too, and the tab may be connected to another
129
190
  # machine than the one this runs on (ssh typed in its console).
130
191
  MACHINE=$(cat /etc/machine-id 2>/dev/null || hostname)
131
- SEQ=$(printf '\033]7777;desk;%s;%s;%s;%s;%s;%s\007' "$(b64 "$SESSION")" "$(b64 "$SOCKET")" "$(b64 "$PWD")" "$(b64 "$KIND")" "$(b64 "$MACHINE")" "$(b64 "$(hostname)")")
192
+ # What Tabby knows a real request by: other output can't read this.
193
+ KEY=$(cat "$HOME/.local/share/tabby-rdp/desk-key" 2>/dev/null)
194
+ SEQ=$(printf '\033]7777;desk;%s;%s;%s;%s;%s;%s;%s\007' "$(b64 "$SESSION")" "$(b64 "$SOCKET")" "$(b64 "$PWD")" "$(b64 "$KIND")" "$(b64 "$MACHINE")" "$(b64 "$(hostname)")" "$(b64 "$KEY")")
132
195
  if [ "$KIND" = tmux ]; then
133
196
  printf '\033Ptmux;\033%s\033\\' "$SEQ" # tmux only lets it through as passthrough
134
197
  else
@@ -143,19 +206,18 @@ chmod 755 "$D/bin/trd-pty"
143
206
  printf '%s\n' "${"$"}{TRD_BACKEND:-native}" > "$D/backend"
144
207
  for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
145
208
  [ -f "$rc" ] || continue
146
- grep -qF '# tabby-rdp' "$rc" && continue
147
- # Only one line: turning desk off deletes exactly it, so on/off cycles leave the file as it was.
148
- [ -z "$(tail -c1 "$rc")" ] || echo >> "$rc"
149
- printf '[ -f "$HOME/.local/share/tabby-rdp/login.sh" ] && . "$HOME/.local/share/tabby-rdp/login.sh" # tabby-rdp\n' >> "$rc"
209
+ LC_ALL=C grep -a -q -xF -- "$HOOK" "$rc" && continue
210
+ # Only one line: turning desk off deletes exactly it, so on/off cycles leave the file as it was. A line of its own:
211
+ # a newline first, unless the file is empty or its last byte is one (counted, as a NUL there reads as nothing).
212
+ { [ ! -s "$rc" ] || [ $(( $(tail -c1 "$rc" | wc -l) )) = 1 ] || echo; printf '%s\n' "$HOOK"; } 2>/dev/null >> "$rc" \
213
+ || say "RD_NOTE desk's line couldn't be added to $rc (read-only?): logins there don't start in a shared session"
150
214
  done
151
215
  else
152
216
  for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
153
- if [ -f "$rc" ] && grep -qF '# tabby-rdp' "$rc"; then
154
- sed -i.trd-bak '/# tabby-rdp$/d' "$rc" && rm -f "$rc.trd-bak"
155
- fi
217
+ unhook "$rc" "$HOOK"
156
218
  done
157
219
  # Sessions already running keep going; new logins are plain again.
158
- rm -f "$D/login.sh" "$D/tmux.conf" "$D/backend" "$D/bin/desk" "$D/bin/trd-pty"
220
+ rm -f "$D/login.sh" "$D/tmux.conf" "$D/backend" "$D/bin/desk" "$D/bin/trd-pty" "$D/desk-key"
159
221
  fi
160
222
  if [ ! -s "$D/rdp-password" ]; then
161
223
  ( umask 077; od -An -N18 -tx1 /dev/urandom | tr -d ' \n' > "$D/rdp-password" ) || fail "could not create credentials"
@@ -164,7 +226,11 @@ RD_USER=tabby
164
226
  RD_PASS=$(cat "$D/rdp-password")
165
227
 
166
228
  STATUS=$(grdctl --headless status --show-credentials 2>/dev/null)
167
- has () { printf '%s\n' "$STATUS" | grep -qxF "$(printf '\t%s' "$1")"; }
229
+ TAB=$(printf '\t') NL='
230
+ '
231
+ # Whether grd's status has this line. Compared in the shell: as a command's argument, the password would show in the
232
+ # process list on every connect.
233
+ has () { case "$NL$STATUS$NL" in *"$NL$TAB$1$NL"*) return 0 ;; esac; return 1; }
168
234
  g () { grdctl --headless rdp "$@" >/dev/null 2>&1; }
169
235
  RESTART=0
170
236
  # grd reads the certificate at startup: a new one at the same path needs a restart too, or grd would keep serving
@@ -176,7 +242,18 @@ fi
176
242
  if ! has "View-only: no"; then g disable-view-only || fail "grdctl could not disable view-only"; RESTART=1; fi
177
243
  if ! has "Status: enabled"; then g enable || fail "grdctl could not enable RDP"; RESTART=1; fi
178
244
  if ! has "Username: $RD_USER" || ! has "Password: $RD_PASS"; then
179
- g set-credentials "$RD_USER" "$RD_PASS" || fail "grdctl could not set credentials"
245
+ # Left off the command line, the password stays out of the process list: grdctl versions that ask for it then read
246
+ # it from their input. Where that didn't take, it goes as an argument. Without timeout(1) on the host, grdctl runs
247
+ # as it is: its input is a pipe that ends after the password, so it doesn't wait for more.
248
+ if command -v timeout >/dev/null 2>&1; then
249
+ printf '%s\n' "$RD_PASS" | timeout 10 grdctl --headless rdp set-credentials "$RD_USER" >/dev/null 2>&1
250
+ else
251
+ printf '%s\n' "$RD_PASS" | grdctl --headless rdp set-credentials "$RD_USER" >/dev/null 2>&1
252
+ fi
253
+ STATUS=$(grdctl --headless status --show-credentials 2>/dev/null)
254
+ if ! has "Username: $RD_USER" || ! has "Password: $RD_PASS"; then
255
+ g set-credentials "$RD_USER" "$RD_PASS" || fail "grdctl could not set credentials"
256
+ fi
180
257
  RESTART=1
181
258
  fi
182
259
 
@@ -309,6 +386,7 @@ if [ -s "$D/taken-over" ]; then
309
386
  fi
310
387
  # xrdp besides GNOME (on a port of its own; on grd's, one of them couldn't listen): offered as another desktop.
311
388
  [ -z "$XRDP_PORT" ] || [ "$XRDP_PORT" = "$PORT" ] || say "RD_XRDP port=$XRDP_PORT user=$(id -un)"
389
+ [ -z "$DESK_KEY" ] || say "RD_DESK $DESK_KEY"
312
390
  say "RD_OK port=$PORT user=$RD_USER pass=$RD_PASS cert=$CERT"
313
391
  `;
314
392
  let trdPtySource = null;
@@ -343,9 +421,10 @@ async function prepareRemoteDesktop(target, desk, backend = 'native', client = '
343
421
  // it worth asking, so a Linux host costs nothing extra.
344
422
  if (/^RD_WINDOWS$/m.test(out) || !/^RD_(OK|XRDP|ERR) /m.test(out) && await isWindows(target)) {
345
423
  // Its RDP server makes its own certificate: trusted on first use, like a desktop behind the host.
346
- return { kind: 'windows', port: 3389, username: target.key.replace(/@[^@]*$/, ''), password: '', certificate: '' };
424
+ return { kind: 'windows', port: 3389, username: (0, desktops_1.keyUser)(target.key), password: '', certificate: '' };
347
425
  }
348
- const err = /^RD_ERR (.*)$/m.exec(out);
426
+ // The host's reason, as much of it as a status line shows: the host decides how long the line is.
427
+ const err = /^RD_ERR (.{0,1000})/m.exec(out);
349
428
  if (err) {
350
429
  throw new Error(err[1]);
351
430
  }
@@ -354,13 +433,15 @@ async function prepareRemoteDesktop(target, desk, backend = 'native', client = '
354
433
  if (ok) {
355
434
  const certificate = (0, rdcleanpath_1.normalizeFingerprint)(ok[4]);
356
435
  if (!certificate) {
357
- throw new Error(`Remote setup reported no usable certificate fingerprint (${ok[4]})`);
436
+ // What the host put there, as long as it likes: as much of it as a fingerprint takes, for a status line.
437
+ throw new Error(`Remote setup reported no usable certificate fingerprint (${ok[4].slice(0, 100)})`);
358
438
  }
359
439
  const clients = Number(/^RD_CLIENTS (\d+)$/m.exec(out)?.[1] ?? 0);
360
440
  const taken = /^RD_TAKEN_OVER (\S+) (\d+)$/m.exec(out);
361
441
  return {
362
442
  kind: 'gnome', port: Number(ok[1]), username: ok[2], password: ok[3], certificate, xrdpPort: xrdp ? Number(xrdp[1]) : undefined, xrdpUser: xrdp?.[2],
363
- clients, takenOver: taken && taken[1] !== client ? Number(taken[2]) : undefined,
443
+ clients, takenOver: taken && taken[1] !== client ? Number(taken[2]) : undefined, deskKey: /^RD_DESK ([0-9a-f]{32})$/m.exec(out)?.[1],
444
+ notes: [...out.matchAll(/^RD_NOTE (.+)$/gm)].map(m => m[1]),
364
445
  };
365
446
  }
366
447
  if (xrdp) {
@@ -369,4 +450,3 @@ async function prepareRemoteDesktop(target, desk, backend = 'native', client = '
369
450
  }
370
451
  throw new Error('Remote setup gave no result');
371
452
  }
372
- //# sourceMappingURL=remoteSetup.js.map
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionLog = void 0;
4
+ /**
5
+ * A line as a string of its own. V8 keeps a part cut out of a string (a slice, a pattern's match; 13 characters or
6
+ * more) as a view of the string it came from, and joining strings as a list of them: a short line made from a host's
7
+ * answer of megabytes would keep all of it in memory for as long as the line is kept. Joined to a character and cut
8
+ * from it again: V8 copies a joined string's characters into one string before it cuts a part out of it, so the line
9
+ * is a part of that copy, every character as it was, unpaired surrogates included. A copy through UTF-16 and back
10
+ * does the same at several times the cost, which every line logged pays.
11
+ */
12
+ function copied(line) {
13
+ return (' ' + line).slice(1);
14
+ }
15
+ /**
16
+ * A desktop's connection log (DesktopSession.log): what the settings page's Copy log hands out. Much of it is of the
17
+ * remote's making (an RD Gateway's administrator's messages, a server's), which decides how many lines there are and
18
+ * when, for as long as the desktop stays open: kept whole, they could fill Tabby's memory and crash its window. So the
19
+ * log keeps the first lines (how the connection was set up) and the latest ones, with a line between them saying how
20
+ * many were left out, and each line is cut at MAX_LINE characters and kept as a copy of its own (see copied), which
21
+ * the text it was made from doesn't stay in memory with.
22
+ */
23
+ class SessionLog extends Array {
24
+ constructor() {
25
+ super(...arguments);
26
+ /** How many lines were left out so far. */
27
+ this.dropped = 0;
28
+ }
29
+ // What is made from the log (filter, slice, map) is a plain array.
30
+ static get [Symbol.species]() {
31
+ return Array;
32
+ }
33
+ push(...lines) {
34
+ const { FIRST, LATEST, MAX_LINE } = SessionLog;
35
+ super.push(...lines.map(line => typeof line === 'string' ? copied(line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…` : line) : line));
36
+ // Once the latest lines are twice as many as are kept, the older half of them goes in one step: each line then
37
+ // costs the same however many come.
38
+ const said = this.dropped ? 1 : 0;
39
+ const over = this.length - FIRST - said - LATEST;
40
+ if (over >= LATEST) {
41
+ this.dropped += over;
42
+ this.splice(FIRST, said + over, `(${this.dropped} lines left out here)`);
43
+ }
44
+ return this.length;
45
+ }
46
+ }
47
+ exports.SessionLog = SessionLog;
48
+ /** The first lines, kept whatever follows. */
49
+ SessionLog.FIRST = 200;
50
+ /** At least this many of the latest lines are kept (and at most twice as many, see push). */
51
+ SessionLog.LATEST = 2000;
52
+ /** The longest a line is kept. */
53
+ SessionLog.MAX_LINE = 4096;