tabby-rdp 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/README.md +216 -58
  3. package/TROUBLESHOOTING.md +77 -0
  4. package/dist/accountForm.js +0 -1
  5. package/dist/accountRevisions.js +46 -0
  6. package/dist/accounts.js +2 -2
  7. package/dist/audio.js +0 -1
  8. package/dist/authorities.js +331 -0
  9. package/dist/byteQueue.js +102 -0
  10. package/dist/clipboard.js +106 -0
  11. package/dist/connectionStatus.js +0 -1
  12. package/dist/desk.js +16 -47
  13. package/dist/deskScript.js +196 -6
  14. package/dist/desktop.service.js +1499 -269
  15. package/dist/desktopForm.js +53 -18
  16. package/dist/desktops.js +232 -24
  17. package/dist/diskStorage.js +212 -0
  18. package/dist/drives.js +271 -30
  19. package/dist/fileTransfer.js +960 -132
  20. package/dist/gateway.js +216 -57
  21. package/dist/header.js +76 -5
  22. package/dist/help.js +16 -47
  23. package/dist/hostCompat.js +86 -0
  24. package/dist/hyperv.js +65 -15
  25. package/dist/index.js +7 -4
  26. package/dist/keyboard.js +0 -1
  27. package/dist/microphone.js +57 -13
  28. package/dist/ntlm.js +0 -1
  29. package/dist/osd.js +56 -15
  30. package/dist/packageChangeDialog.js +186 -0
  31. package/dist/probe.js +15 -3
  32. package/dist/rdcleanpath.js +319 -44
  33. package/dist/rdpFile.js +22 -7
  34. package/dist/rdpProfile.js +39 -21
  35. package/dist/releaseSettings.js +72 -0
  36. package/dist/releases.js +408 -0
  37. package/dist/remoteSetup.js +103 -23
  38. package/dist/sessionLog.js +53 -0
  39. package/dist/settingsPage.js +148 -122
  40. package/dist/signin.js +478 -48
  41. package/dist/ssh.js +99 -18
  42. package/dist/sshChannelStream.js +40 -6
  43. package/dist/targets.js +194 -40
  44. package/dist/ui.js +88 -20
  45. package/dist/unshowable.js +41 -0
  46. package/dist/updates.js +79 -56
  47. package/dist/vms.js +17 -3
  48. package/dist/wake.js +15 -8
  49. package/docs/desk.md +157 -0
  50. package/package.json +14 -4
  51. package/remote/trd-pty.py +74 -13
  52. package/vendor/SHA256SUMS +26 -0
  53. package/vendor/ironrdp_web_bg.wasm +0 -0
  54. package/dist/accountForm.js.map +0 -1
  55. package/dist/accounts.js.map +0 -1
  56. package/dist/audio.js.map +0 -1
  57. package/dist/connectionStatus.js.map +0 -1
  58. package/dist/desk.js.map +0 -1
  59. package/dist/deskScript.js.map +0 -1
  60. package/dist/desktop.service.js.map +0 -1
  61. package/dist/desktopForm.js.map +0 -1
  62. package/dist/desktops.js.map +0 -1
  63. package/dist/drives.js.map +0 -1
  64. package/dist/fileTransfer.js.map +0 -1
  65. package/dist/gateway.js.map +0 -1
  66. package/dist/header.js.map +0 -1
  67. package/dist/help.js.map +0 -1
  68. package/dist/hyperv.js.map +0 -1
  69. package/dist/index.js.map +0 -1
  70. package/dist/keyboard.js.map +0 -1
  71. package/dist/microphone.js.map +0 -1
  72. package/dist/ntlm.js.map +0 -1
  73. package/dist/osd.js.map +0 -1
  74. package/dist/probe.js.map +0 -1
  75. package/dist/rdcleanpath.js.map +0 -1
  76. package/dist/rdpFile.js.map +0 -1
  77. package/dist/rdpProfile.js.map +0 -1
  78. package/dist/remoteSetup.js.map +0 -1
  79. package/dist/settingsPage.js.map +0 -1
  80. package/dist/signin.js.map +0 -1
  81. package/dist/ssh.js.map +0 -1
  82. package/dist/sshChannelStream.js.map +0 -1
  83. package/dist/targets.js.map +0 -1
  84. package/dist/ui.js.map +0 -1
  85. package/dist/updates.js.map +0 -1
  86. package/dist/vms.js.map +0 -1
  87. package/dist/wake.js.map +0 -1
package/dist/ssh.js CHANGED
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Output = exports.MAX_OUTPUT = void 0;
3
4
  exports.isSSHTab = isSSHTab;
4
5
  exports.isConnected = isConnected;
5
6
  exports.openTcpStream = openTcpStream;
6
7
  exports.execStream = execStream;
7
8
  exports.pingRemote = pingRemote;
9
+ exports.tooMuchOutput = tooMuchOutput;
8
10
  exports.execRemote = execRemote;
9
11
  const sshChannelStream_1 = require("./sshChannelStream");
10
12
  function isSSHTab(tab) {
@@ -80,50 +82,129 @@ async function pingRemote(tab) {
80
82
  }
81
83
  // Printed after a command's output, so that execRemote knows all of it arrived.
82
84
  const END = '__trd_exec_end__';
85
+ /**
86
+ * What a command's output may be at most. The plugin's commands print a few lines (a host's VMs: a line each); a host
87
+ * that sends more is sending what it likes, which would otherwise all be kept, and parsed, on the thread that runs
88
+ * Tabby's window.
89
+ */
90
+ exports.MAX_OUTPUT = 4 * 1024 * 1024;
91
+ /** What a command that printed more than MAX_OUTPUT fails with. */
92
+ function tooMuchOutput() {
93
+ return new Error(`Remote command printed more than ${exports.MAX_OUTPUT / 1024 / 1024} MB where a few lines were expected: stopped reading it`);
94
+ }
95
+ /**
96
+ * A command's output as it arrives, up to MAX_OUTPUT: copied into one buffer of its own, grown by doubling, rather than
97
+ * kept as the pieces it came in. A host can send its output a byte at a time, and millions of one-byte pieces take far
98
+ * more memory as pieces than as bytes.
99
+ */
100
+ class Output {
101
+ constructor() {
102
+ this.buffer = Buffer.alloc(0);
103
+ /** How much has come; past MAX_OUTPUT, more than it (the rest isn't kept). */
104
+ this.size = 0;
105
+ }
106
+ get overflowed() {
107
+ return this.size > exports.MAX_OUTPUT;
108
+ }
109
+ /** Adds what came; false once there is more than MAX_OUTPUT. */
110
+ add(piece) {
111
+ if (this.size + piece.byteLength > exports.MAX_OUTPUT) {
112
+ this.size = exports.MAX_OUTPUT + 1;
113
+ return false;
114
+ }
115
+ if (this.size + piece.byteLength > this.buffer.length) {
116
+ const grown = Buffer.allocUnsafe(Math.min(exports.MAX_OUTPUT, Math.max(this.buffer.length * 2, this.size + piece.byteLength, 64 * 1024)));
117
+ this.buffer.copy(grown, 0, 0, this.size);
118
+ this.buffer = grown;
119
+ }
120
+ this.buffer.set(piece, this.size);
121
+ this.size += piece.byteLength;
122
+ return true;
123
+ }
124
+ /** What has come so far (not past MAX_OUTPUT). */
125
+ bytes() {
126
+ return this.buffer.subarray(0, Math.min(this.size, exports.MAX_OUTPUT));
127
+ }
128
+ }
129
+ exports.Output = Output;
83
130
  /**
84
131
  * Picks up data for a closed channel that Tabby's russh binding received after the close. Its data, EOF and close
85
132
  * events come through separate callbacks, in no fixed order, and the close drops the channel's data subscription:
86
133
  * later data waits in a new buffer that nothing reads (seen with Tabby 1.0.237 on Windows).
87
134
  * Reported upstream: https://github.com/Eugeny/russh-napi/issues/3
88
135
  */
89
- function drainLateData(ssh, id, out) {
136
+ function drainLateData(ssh, id, take) {
90
137
  const data = ssh.events?.data$;
91
138
  if (typeof data?.subscribe !== 'function') {
92
139
  return;
93
140
  }
94
- data.subscribe(id).subscribe((d) => out.push(Buffer.from(d))).unsubscribe();
141
+ data.subscribe(id).subscribe(take).unsubscribe();
95
142
  data.closeChannel?.(id);
96
143
  }
97
144
  /** Runs `command` on the remote with `stdin`, returns stdout. No PTY, no exit status (russh doesn't expose it). */
98
145
  async function execRemote(tab, command, stdin = '', timeoutMs = 60000) {
99
146
  const ssh = client(tab);
100
- const channel = await ssh.activateChannel(await ssh.openSessionChannel());
101
- const out = [];
102
- const text = () => Buffer.concat(out).toString('utf8');
103
- const closed = new Promise(resolve => channel.closed$.subscribe(() => resolve()));
104
- channel.data$.subscribe((d) => out.push(Buffer.from(d)));
105
- await channel.requestExec(`${command}\necho ${END}`);
106
- if (stdin) {
107
- await channel.write(new Uint8Array(Buffer.from(stdin)));
108
- }
109
- await channel.eof();
147
+ // One limit on time for all of it, from opening the channel on: a server that never confirms the channel, or the
148
+ // command, would otherwise keep whatever waits for the answer (a VM scan, a desktop's setup) waiting for good.
110
149
  let timer;
111
150
  const timeout = new Promise((_, reject) => {
112
151
  timer = setTimeout(() => reject(new Error(`Remote command timed out after ${timeoutMs / 1000}s`)), timeoutMs);
113
152
  });
153
+ timeout.catch(() => null);
154
+ const opening = Promise.resolve().then(() => ssh.openSessionChannel()).then((c) => ssh.activateChannel(c));
155
+ const output = new Output();
156
+ // Whether END has come: looked for in what is new and the end of what came before, since it can straddle the two.
157
+ let ended = false;
158
+ let overflow = () => null;
159
+ const overflowed = new Promise((_, reject) => { overflow = reject; });
160
+ overflowed.catch(() => null);
161
+ let channel = null;
162
+ const take = (d) => {
163
+ if (output.overflowed) {
164
+ return;
165
+ }
166
+ const from = Math.max(0, output.size - (END.length - 1));
167
+ if (!output.add(d)) {
168
+ channel?.close().catch(() => null);
169
+ return overflow(tooMuchOutput());
170
+ }
171
+ ended ||= output.bytes().includes(END, from);
172
+ };
114
173
  try {
115
- await Promise.race([closed, timeout]);
174
+ channel = await Promise.race([opening, timeout]);
175
+ const closed = new Promise(resolve => channel.closed$.subscribe(() => resolve()));
176
+ channel.data$.subscribe(take);
177
+ // What the host sends meanwhile counts too: past MAX_OUTPUT it ends this, whatever step it is at.
178
+ const started = (async () => {
179
+ await channel.requestExec(`${command}\necho ${END}`);
180
+ if (stdin) {
181
+ await channel.write(new Uint8Array(Buffer.from(stdin)));
182
+ }
183
+ await channel.eof();
184
+ })();
185
+ started.catch(() => null);
186
+ await Promise.race([started, timeout, overflowed]);
187
+ await Promise.race([closed, timeout, overflowed]);
116
188
  }
117
189
  finally {
118
190
  clearTimeout(timer);
119
- channel.close().catch(() => null);
191
+ if (channel) {
192
+ channel.close().catch(() => null);
193
+ }
194
+ else {
195
+ // A channel that opens after the time is up is closed then.
196
+ opening.then((c) => c?.close(), () => null).catch(() => null);
197
+ }
120
198
  }
121
- for (let i = 0; i < 40 && !text().includes(END); i++) {
199
+ for (let i = 0; i < 40 && !ended && !output.overflowed; i++) {
122
200
  await new Promise(resolve => setTimeout(resolve, 50));
123
- drainLateData(ssh, channel.id, out);
201
+ drainLateData(ssh, channel.id, take);
202
+ }
203
+ if (output.overflowed) {
204
+ throw tooMuchOutput();
124
205
  }
125
- const result = text();
206
+ // Decoded once, all of it: a character's bytes can straddle two pieces.
207
+ const result = output.bytes().toString('utf8');
126
208
  const end = result.lastIndexOf(END);
127
209
  return end < 0 ? result : result.slice(0, end);
128
210
  }
129
- //# sourceMappingURL=ssh.js.map
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SSHChannelStream = void 0;
4
4
  const stream_1 = require("stream");
5
+ const byteQueue_1 = require("./byteQueue");
5
6
  /**
6
7
  * What may wait in the stream for its reader. The channel can't be told to slow down (russh hands the data over as
7
8
  * it arrives), so a reader that has stopped for good would otherwise have the whole of what the server sends pile up
@@ -13,22 +14,56 @@ class SSHChannelStream extends stream_1.Duplex {
13
14
  constructor(channel) {
14
15
  super();
15
16
  this.channel = channel;
17
+ /**
18
+ * What arrived while the reader wasn't reading, in one queue rather than as the pieces it came in: the server (or
19
+ * the host on the way) decides how small those are, and each one kept as it came costs far more than its bytes (a
20
+ * byte at a time, hundreds of times as much). Handed on in one piece once the reader asks again.
21
+ */
22
+ this.held = new byteQueue_1.ByteQueue();
23
+ /** The reader said to stop (push returned false): what comes is held until it asks again. */
24
+ this.waiting = false;
25
+ /** The channel ended while something was held: the end goes after it. */
26
+ this.ending = false;
16
27
  channel.data$.subscribe({
17
28
  next: d => {
18
- if (!this.push(Buffer.from(d.buffer, d.byteOffset, d.byteLength)) && this.readableLength > MAX_PENDING) {
29
+ const piece = Buffer.from(d.buffer, d.byteOffset, d.byteLength);
30
+ if (!this.waiting) {
31
+ this.waiting = !this.push(piece);
32
+ return;
33
+ }
34
+ this.held.push(piece);
35
+ if (this.held.length + this.readableLength > MAX_PENDING) {
19
36
  this.destroy(new Error('the desktop\'s data isn\'t being read: connection closed'));
20
37
  }
21
38
  },
22
39
  error: e => this.destroy(e instanceof Error ? e : new Error(String(e))),
23
40
  });
24
- channel.eof$.subscribe(() => this.push(null));
41
+ channel.eof$.subscribe(() => this.channelEnded());
25
42
  channel.closed$.subscribe(() => {
26
- this.push(null);
43
+ this.channelEnded();
27
44
  this.destroy();
28
45
  });
29
46
  }
30
- // The channel pushes whenever data arrives; there is no pull side to drive.
31
- _read() { }
47
+ /** The channel's end: after what is held, if anything is. */
48
+ channelEnded() {
49
+ if (this.held.length) {
50
+ this.ending = true;
51
+ }
52
+ else {
53
+ this.push(null);
54
+ }
55
+ }
56
+ // The channel pushes whenever data arrives; the reader asking again only takes what was held meanwhile.
57
+ _read() {
58
+ this.waiting = false;
59
+ if (this.held.length) {
60
+ this.waiting = !this.push(this.held.take(this.held.length));
61
+ }
62
+ if (this.ending && !this.held.length) {
63
+ this.ending = false;
64
+ this.push(null);
65
+ }
66
+ }
32
67
  _write(chunk, _encoding, callback) {
33
68
  this.channel.write(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)).then(() => callback(), callback);
34
69
  }
@@ -41,4 +76,3 @@ class SSHChannelStream extends stream_1.Duplex {
41
76
  }
42
77
  }
43
78
  exports.SSHChannelStream = SSHChannelStream;
44
- //# sourceMappingURL=sshChannelStream.js.map
package/dist/targets.js CHANGED
@@ -5,8 +5,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
7
  };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
8
11
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.RemoteTargets = exports.RDP_PROFILE_TYPE = void 0;
12
+ exports.RemoteTargets = exports.SystemSSHTarget = exports.RDP_PROFILE_TYPE = void 0;
10
13
  exports.isRDPTab = isRDPTab;
11
14
  exports.desktopPaneOf = desktopPaneOf;
12
15
  exports.parseSSHCommand = parseSSHCommand;
@@ -19,6 +22,8 @@ const desktops_1 = require("./desktops");
19
22
  const desktopForm_1 = require("./desktopForm");
20
23
  const deskScript_1 = require("./deskScript");
21
24
  const probe_1 = require("./probe");
25
+ const signin_1 = require("./signin");
26
+ const unshowable_1 = require("./unshowable");
22
27
  const ssh_1 = require("./ssh");
23
28
  /** Tabby profile type of remote desktop tabs (see rdpProfile.ts). */
24
29
  exports.RDP_PROFILE_TYPE = 'rdp';
@@ -90,14 +95,13 @@ function parseSSHCommand(tokens) {
90
95
  const destination = tokens[i];
91
96
  return destination ? { options, destination } : null;
92
97
  }
93
- /** `user@hostname:port` from `ssh -G` output (config aliases, defaults resolved). */
94
- function keyFromConfig(out, destination) {
95
- const get = (k) => new RegExp(`^${k} (.+)$`, 'm').exec(out)?.[1]?.trim();
96
- return `${get('user') ?? ''}@${(get('hostname') ?? destination).toLowerCase()}:${get('port') ?? '22'}`;
97
- }
98
- /** `user@hostname:port` as ssh itself resolves it (config aliases, defaults). */
98
+ /**
99
+ * `user@hostname:port` as ssh itself resolves it (config aliases, defaults; see keyFromConfig), and the key the plugin
100
+ * made of the same answer up to 0.5.0 (see RemoteTargets.keyChanged).
101
+ */
99
102
  async function resolveKey(options, destination) {
100
- return keyFromConfig(await run('ssh', [...options, '-G', destination]), destination);
103
+ const out = await run('ssh', [...options, '-G', destination]);
104
+ return { key: (0, desktops_1.keyFromConfig)(out, destination), former: (0, desktops_1.legacyKeyFromConfig)(out, destination) };
101
105
  }
102
106
  /** A target reached with the system `ssh`, non-interactively (keys/agent/control master only). */
103
107
  class SystemSSHTarget {
@@ -111,8 +115,10 @@ class SystemSSHTarget {
111
115
  }
112
116
  async openTcp(host, port) {
113
117
  const child = (0, child_process_1.spawn)('ssh', this.args('-o', 'ExitOnForwardFailure=yes', '-W', `${host.includes(':') ? `[${host}]` : host}:${port}`), { stdio: ['pipe', 'pipe', 'pipe'] });
118
+ // Only the end, whose last line says why ssh stopped, as for exec: ssh prints what the server sends while
119
+ // signing in (its banners), for as long as the tunnel lasts, and the server decides how much.
114
120
  let stderr = '';
115
- child.stderr.on('data', d => { stderr += d; });
121
+ child.stderr.on('data', d => { stderr = (stderr + d).slice(-4096); });
116
122
  const stream = stream_1.Duplex.from({ readable: child.stdout, writable: child.stdin });
117
123
  stream.on('close', () => child.kill());
118
124
  child.on('exit', () => stream.destroy());
@@ -130,22 +136,52 @@ class SystemSSHTarget {
130
136
  exec(command, stdin) {
131
137
  return new Promise((resolve, reject) => {
132
138
  const child = (0, child_process_1.spawn)('ssh', [...this.args('-T'), command], { stdio: ['pipe', 'pipe', 'pipe'] });
133
- let stdout = '';
139
+ const stdout = new ssh_1.Output();
134
140
  let stderr = '';
135
- child.stdout.on('data', d => { stdout += d; });
136
- child.stderr.on('data', d => { stderr += d; });
137
- const timer = setTimeout(() => child.kill(), 60000);
141
+ // Why ssh was stopped, if it was: what it printed is then no answer.
142
+ let stopped = null;
143
+ const stop = (e) => {
144
+ stopped ??= e;
145
+ child.kill();
146
+ };
147
+ // As much as execRemote takes (see MAX_OUTPUT), and of stderr only the end, whose last line says why.
148
+ child.stdout.on('data', (d) => {
149
+ if (!stdout.add(d)) {
150
+ stop((0, ssh_1.tooMuchOutput)());
151
+ }
152
+ });
153
+ child.stderr.on('data', d => { stderr = (stderr + d).slice(-4096); });
154
+ const timer = setTimeout(() => stop(new Error('Remote command timed out after 60s')), 60000);
138
155
  child.on('error', reject);
139
- child.on('exit', code => {
156
+ // ssh can exit before it reads stdin, or the command may not use it. Its status and stderr say why:
157
+ // EPIPE on the input pipe must not escape as an uncaught error or replace ssh's own explanation.
158
+ child.stdin.on('error', (e) => {
159
+ if (e.code !== 'EPIPE') {
160
+ stop(e);
161
+ }
162
+ });
163
+ // All output, and any input pipe error, has arrived only once the process's streams have closed.
164
+ child.on('close', code => {
140
165
  clearTimeout(timer);
141
- code === 255 ? reject(new Error(sshFailure(this.label, stderr, code))) : resolve(stdout);
166
+ if (stopped) {
167
+ reject(stopped);
168
+ }
169
+ else if (code === 255) {
170
+ reject(new Error(sshFailure(this.label, stderr, code)));
171
+ }
172
+ else {
173
+ resolve(stdout.bytes().toString('utf8'));
174
+ }
142
175
  });
143
176
  child.stdin.end(stdin);
144
177
  });
145
178
  }
146
179
  }
180
+ exports.SystemSSHTarget = SystemSSHTarget;
147
181
  function sshFailure(label, stderr, code) {
148
- const last = stderr.trim().split('\n').pop() ?? '';
182
+ // What the host said last (a refused channel's reason, its banner): as much of it as a host's error is shown
183
+ // elsewhere.
184
+ const last = (stderr.trim().split('\n').pop() ?? '').slice(0, 1000);
149
185
  if (/permission denied|password|keyboard-interactive/i.test(last)) {
150
186
  return `Couldn't open a second SSH connection to ${label} without a password. Use an SSH key or agent (or a ControlMaster), or open the host as a Tabby SSH profile.`;
151
187
  }
@@ -186,6 +222,34 @@ for p in $(pgrep -u "$(id -u)" -x ssh 2>/dev/null); do
186
222
  printf '\033]7777;probe;%s-%s\007' "$PROBE" "$n" > "/dev/$1" 2>/dev/null || true
187
223
  done
188
224
  `;
225
+ /**
226
+ * How many `ssh` clients in a pane nestedSSH() reports at most: a terminal runs one in its foreground (a jump host's
227
+ * makes two), and each is a menu item, or a command to run, there.
228
+ */
229
+ const MAX_NESTED = 16;
230
+ const HOP = '\n__trd_hop ';
231
+ /**
232
+ * A command's output through the host's ssh, and how that ssh ended: the line NestedSSHTarget.exec adds at the end,
233
+ * `__trd_hop <exit status> <ssh's last line of errors>`. Found from the end and taken apart by hand, not with a pattern
234
+ * whose parts can take the same characters, which a long last line of the host's making could make cost the square of
235
+ * its length. The error keeps any CR in it (OpenSSH ends its messages with CR LF). Null without that line.
236
+ */
237
+ function hopResult(out) {
238
+ const at = out.lastIndexOf(HOP);
239
+ if (at < 0) {
240
+ return null;
241
+ }
242
+ let line = out.slice(at + HOP.length);
243
+ if (line.endsWith('\n')) {
244
+ line = line.slice(0, -1);
245
+ }
246
+ const space = line.indexOf(' ');
247
+ const status = space < 0 ? line : line.slice(0, space);
248
+ if (!/^\d+$/.test(status) || line.includes('\n')) {
249
+ return null;
250
+ }
251
+ return { output: out.slice(0, at), status, error: space < 0 ? '' : line.slice(space + 1) };
252
+ }
189
253
  /**
190
254
  * A machine reached from an SSH tab's host with the `ssh` typed there: each command and tunnel runs that host's own
191
255
  * ssh, non-interactively (its keys, or a forwarded agent). Like SystemSSHTarget, one hop further.
@@ -208,13 +272,15 @@ class NestedSSHTarget {
208
272
  // ssh's exit status and last error line come after the output, so a failed login says why.
209
273
  const out = await this.outer.exec(`e=$(mktemp); ${this.ssh('-T')} ${(0, deskScript_1.shq)(command)} 2>"$e"; r=$?; ` +
210
274
  `printf '\n__trd_hop %s %s\n' "$r" "$(tail -n 1 "$e")"; rm -f "$e"`, stdin);
211
- const m = /\n__trd_hop (\d+) ?(.*)\n?$/.exec(out);
212
- if (m?.[1] === '255') {
213
- throw new Error(/permission denied|password|keyboard-interactive|host key/i.test(m[2])
214
- ? `${this.outer.label} couldn't log in to ${this.label} by itself (${m[2].trim()}). The desktop goes through ${this.outer.label}'s own ssh, which needs a key for ${this.label} there, or agent forwarding.`
215
- : `ssh from ${this.outer.label} to ${this.label} failed${m[2] ? `: ${m[2].trim()}` : ''}`);
275
+ const hop = hopResult(out);
276
+ if (hop?.status === '255') {
277
+ // The host's ssh's words, as long as it likes: as much as a status line shows.
278
+ const why = hop.error.trim().slice(0, 1000);
279
+ throw new Error(/permission denied|password|keyboard-interactive|host key/i.test(why)
280
+ ? `${this.outer.label} couldn't log in to ${this.label} by itself (${why}). The desktop goes through ${this.outer.label}'s own ssh, which needs a key for ${this.label} there, or agent forwarding.`
281
+ : `ssh from ${this.outer.label} to ${this.label} failed${why ? `: ${why}` : ''}`);
216
282
  }
217
- return m ? out.slice(0, m.index) : out;
283
+ return hop ? hop.output : out;
218
284
  }
219
285
  isOpen() { return this.outer.isOpen(); }
220
286
  ping() { return this.outer.ping(); }
@@ -249,12 +315,23 @@ class DirectTarget {
249
315
  isOpen() { return true; }
250
316
  }
251
317
  // ---- detection ---------------------------------------------------------------------------------
318
+ /**
319
+ * Whether a name a host gave can be shown as it is: none of the characters that could hide part of it or show it in
320
+ * another order without showing themselves (UNSHOWABLE: control and format characters, the other default-ignorable
321
+ * ones such as variation selectors and Hangul fillers, separators, blanks other than the space). The .rdp import
322
+ * refuses the same in the addresses a file names.
323
+ */
324
+ function shownAsIs(name) {
325
+ return !unshowable_1.UNSHOWABLE.test(name);
326
+ }
252
327
  /**
253
328
  * Works out which remote a pane is logged into. SSH tabs: their profile. Local terminals: an `ssh`
254
329
  * process running under the shell. Results are cached so the header can be updated synchronously.
255
330
  */
256
331
  let RemoteTargets = class RemoteTargets {
257
- constructor() {
332
+ constructor(config, profiles) {
333
+ this.config = config;
334
+ this.profiles = profiles;
258
335
  this.cache = new WeakMap();
259
336
  this.keys = new Map();
260
337
  }
@@ -267,10 +344,12 @@ let RemoteTargets = class RemoteTargets {
267
344
  let pid;
268
345
  try {
269
346
  if (isRDPTab(pane)) {
270
- // Made afresh each time (it holds no state), so that an edited profile applies on the next connection.
271
- const profile = pane.profile;
272
- // No `wake` for a direct one: starting a machine takes an SSH host to do it from.
273
- const spec = (0, desktops_1.specOf)({ ...profile.options, name: profile.name, wake: undefined });
347
+ // Made afresh each time (it holds no state), from the profile as saved now, so that an edited profile
348
+ // applies on the next connection.
349
+ const profile = this.savedProfile(pane);
350
+ // No `wake` for a direct one: starting a machine takes an SSH host to do it from. None for a profile that
351
+ // goes through an SSH profile (`via`): its address is as that host sees it, and it opens over that tab.
352
+ const spec = profile.options?.via ? null : (0, desktops_1.specOf)({ ...profile.options, name: profile.name, wake: undefined });
274
353
  target = spec && new DirectTarget((0, desktopForm_1.formatAddress)(spec.host, spec.port), spec);
275
354
  }
276
355
  else if ((0, ssh_1.isSSHTab)(pane)) {
@@ -298,6 +377,17 @@ let RemoteTargets = class RemoteTargets {
298
377
  this.cache.set(pane, { target, pid });
299
378
  return target;
300
379
  }
380
+ /**
381
+ * A remote desktop tab's profile as saved now, with its type's and group's defaults as Tabby resolves a profile
382
+ * when opening it. Not the tab's own: Tabby's proxy of that keeps the options of when the tab opened (saving the
383
+ * profile editor puts new ones in their place), and a restored tab has a copy. The tab's own where it isn't a saved
384
+ * profile (quick connect).
385
+ */
386
+ savedProfile(pane) {
387
+ const own = pane.profile;
388
+ const saved = own.id ? (this.config.store.profiles ?? []).find((p) => p?.id === own.id && p.type === exports.RDP_PROFILE_TYPE) : null;
389
+ return saved ? { name: saved.name, options: this.profiles.getConfigProxyForProfile(saved).options } : own;
390
+ }
301
391
  /**
302
392
  * `ssh` clients typed at a prompt in this pane, on its SSH tab's host: only those whose terminal there is this pane's
303
393
  * (see probe.ts), since other panes and tabs can be on the same host. Empty elsewhere.
@@ -309,37 +399,99 @@ let RemoteTargets = class RemoteTargets {
309
399
  }
310
400
  const probe = `trd${Math.random().toString(36).slice(2, 10)}`;
311
401
  const { result: out, ids } = await (0, probe_1.probesIn)(pane, probe, () => outer.exec('sh -s', nestedSSHScript(probe)));
402
+ // One per destination (the host says what its lines are, and can repeat them), MAX_NESTED at most. A destination
403
+ // is shown as the machine's name (in the question which desktop to open, in the sign-in form): one that can't be
404
+ // shown as it is, no ssh a user typed would have, isn't offered.
312
405
  const found = [];
406
+ const destinations = new Set();
313
407
  for (const line of out.split('\n')) {
314
408
  const m = /^TRD_SSH (\d+)\|([^|]*)\|(.*)$/.exec(line.trim());
315
409
  const args = m?.[3].split('\x1f').filter(Boolean) ?? [];
316
410
  const cmd = /(^|\/)ssh$/.test(args[0] ?? '') ? parseSSHCommand(args.slice(1)) : null;
317
- if (m && cmd && ids.has(`${probe}-${m[1]}`)) {
411
+ if (m && cmd && ids.has(`${probe}-${m[1]}`) && !destinations.has(cmd.destination) && shownAsIs(cmd.destination)) {
412
+ destinations.add(cmd.destination);
318
413
  found.push({ cmd, tty: m[2] });
414
+ if (found.length === MAX_NESTED) {
415
+ break;
416
+ }
319
417
  }
320
418
  }
321
- // One per destination.
322
- return found.filter((f, i, all) => all.findIndex(g => g.cmd.destination === f.cmd.destination) === i);
419
+ return found;
323
420
  }
324
- /** A target for an `ssh` found by nestedSSH(), going through the pane's SSH connection. */
421
+ /**
422
+ * A target for an `ssh` found by nestedSSH(), going through the pane's SSH connection. Its key is under the pane's
423
+ * (see nestedKey): what the host there says about the machine, `ssh -G` included, is only that host's word.
424
+ */
325
425
  async nestedTarget(pane, nested) {
326
426
  const outer = await this.targetOf(pane);
327
427
  if (!(outer instanceof TabbySSHTarget)) {
328
428
  return null;
329
429
  }
330
430
  const config = await outer.exec(`${['ssh', ...nested.cmd.options, '-G', nested.cmd.destination].map(deskScript_1.shq).join(' ')} 2>/dev/null`, '');
331
- return new NestedSSHTarget(keyFromConfig(config, nested.cmd.destination), nested.cmd.destination, outer, nested.cmd);
431
+ return new NestedSSHTarget((0, desktops_1.nestedKey)(outer.key, config, nested.cmd.destination), nested.cmd.destination, outer, nested.cmd);
332
432
  }
333
433
  key(options, destination) {
334
434
  const id = JSON.stringify([options, destination]);
335
435
  let key = this.keys.get(id);
336
436
  if (!key) {
337
- key = resolveKey(options, destination);
437
+ key = resolveKey(options, destination).then(resolved => {
438
+ this.keyChanged(resolved.former, resolved.key);
439
+ return resolved.key;
440
+ });
338
441
  key.catch(() => this.keys.delete(id));
339
442
  this.keys.set(id, key);
340
443
  }
341
444
  return key;
342
445
  }
446
+ /**
447
+ * A target whose key reads otherwise than up to 0.5.0: one of its fields has a character keyFromConfig now writes
448
+ * as `%xx` (the `%` of an IPv6 address's zone, a `#` in a user name). It is the same target, resolved here by ssh
449
+ * from this computer's own config, so what was kept under its former key moves to the new one: the certificates
450
+ * remembered for its desktops and their sharpness now, a saved password the first time it is looked for (see
451
+ * renameCredentials). An entry already kept under the new key stays as it is. What else was kept by its key (desk's
452
+ * key, a "from now on" choice of a machine reached through it) stays behind, and is asked about again: neither
453
+ * gives anything away. Nothing moves where the former key could be another target's key now (see isTargetKey):
454
+ * what is kept under it may then be that one's, and this target's desktops ask again instead.
455
+ *
456
+ * Nor do the saved passwords of a former key with a `#` in it (a user name's): a desktop's key splits at its first
457
+ * `#` (see keyParts), so its desktops' passwords there read as no desktop's, and nothing that forgets or moves a
458
+ * desktop's passwords by its address (removing it, a new user name, gateway or address, the gateway passwords 0.5.0
459
+ * kept, see RemoteDesktopService.gatewayPasswords) has touched them. Moved now, a password forgotten that way would
460
+ * come back. That host's desktops ask for their passwords again instead, and those passwords are forgotten (see
461
+ * forgetFormerKey): no key a target has now has a `#` there, so what is kept under that one can only be this
462
+ * target's, and would otherwise stay kept for good, unused.
463
+ */
464
+ keyChanged(from, to) {
465
+ if (from === to || (0, desktops_1.isTargetKey)(from)) {
466
+ return;
467
+ }
468
+ if (!from.includes('#')) {
469
+ (0, signin_1.renameCredentials)(from, to);
470
+ }
471
+ else {
472
+ (0, signin_1.forgetFormerKey)(from);
473
+ }
474
+ const store = this.config.store.remoteDesktop;
475
+ let moved = false;
476
+ // The permission to sign in without NLA with them: it holds for one certificate (see holdWithoutNla), which moves.
477
+ // (None under a former key has one, which it needs: 0.5.0 never saved them, and builds since gave them one only
478
+ // under the new keys. Moved all the same, they count for nothing.)
479
+ for (const list of ['trustedCertificates', 'desktopSharpness', 'withoutNla']) {
480
+ const entries = Array.isArray(store?.[list]) ? store[list] : [];
481
+ const taken = new Set(entries.map(e => e?.desktop));
482
+ const renamed = entries.map(e => {
483
+ const desktop = typeof e?.desktop === 'string' ? (0, desktops_1.rekeyed)(e.desktop, from, to) : null;
484
+ return desktop && !taken.has(desktop) ? { ...e, desktop } : e;
485
+ });
486
+ if (renamed.some((e, i) => e !== entries[i])) {
487
+ store[list] = renamed;
488
+ moved = true;
489
+ }
490
+ }
491
+ if (moved) {
492
+ this.config.save();
493
+ }
494
+ }
343
495
  /**
344
496
  * The nearest `ssh` client running in the pane: a descendant of the terminal's own process.
345
497
  *
@@ -359,11 +511,13 @@ let RemoteTargets = class RemoteTargets {
359
511
  // pid, ppid, full command line (ps loses quoting; ssh arguments rarely need it)
360
512
  const children = new Map();
361
513
  for (const line of (await run('ps', ['-ax', '-ww', '-o', 'pid=,ppid=,args='])).split('\n')) {
362
- const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
363
- if (m) {
364
- const list = children.get(Number(m[2])) ?? [];
365
- list.push({ pid: Number(m[1]), args: m[3].trim().split(/\s+/) });
366
- children.set(Number(m[2]), list);
514
+ // Split at whitespace, not matched with a pattern whose parts can take the same spaces: any program here
515
+ // decides what its arguments are, and a long run of spaces in them could take the square of its length.
516
+ const [pid, ppid, ...args] = line.trim().split(/\s+/);
517
+ if (/^\d+$/.test(pid) && /^\d+$/.test(ppid ?? '')) {
518
+ const list = children.get(Number(ppid)) ?? [];
519
+ list.push({ pid: Number(pid), args });
520
+ children.set(Number(ppid), list);
367
521
  }
368
522
  }
369
523
  // Breadth first, so the nearest ssh wins (e.g. the one typed at the prompt, not one it spawned).
@@ -388,6 +542,6 @@ let RemoteTargets = class RemoteTargets {
388
542
  };
389
543
  exports.RemoteTargets = RemoteTargets;
390
544
  exports.RemoteTargets = RemoteTargets = __decorate([
391
- (0, core_1.Injectable)({ providedIn: 'root' })
545
+ (0, core_1.Injectable)({ providedIn: 'root' }),
546
+ __metadata("design:paramtypes", [tabby_core_1.ConfigService, tabby_core_1.ProfilesService])
392
547
  ], RemoteTargets);
393
- //# sourceMappingURL=targets.js.map