tabby-rdp 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +516 -0
- package/README.md +216 -58
- package/TROUBLESHOOTING.md +77 -0
- package/dist/accountForm.js +0 -1
- package/dist/accountRevisions.js +46 -0
- package/dist/accounts.js +2 -2
- package/dist/audio.js +0 -1
- package/dist/authorities.js +331 -0
- package/dist/byteQueue.js +102 -0
- package/dist/clipboard.js +106 -0
- package/dist/connectionStatus.js +0 -1
- package/dist/desk.js +16 -47
- package/dist/deskScript.js +196 -6
- package/dist/desktop.service.js +1499 -269
- package/dist/desktopForm.js +53 -18
- package/dist/desktops.js +232 -24
- package/dist/diskStorage.js +212 -0
- package/dist/drives.js +271 -30
- package/dist/fileTransfer.js +960 -132
- package/dist/gateway.js +216 -57
- package/dist/header.js +76 -5
- package/dist/help.js +16 -47
- package/dist/hostCompat.js +86 -0
- package/dist/hyperv.js +65 -15
- package/dist/index.js +7 -4
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +0 -1
- package/dist/osd.js +56 -15
- package/dist/packageChangeDialog.js +186 -0
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +319 -44
- package/dist/rdpFile.js +22 -7
- package/dist/rdpProfile.js +39 -21
- package/dist/releaseSettings.js +72 -0
- package/dist/releases.js +408 -0
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +148 -122
- package/dist/signin.js +478 -48
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +40 -6
- package/dist/targets.js +194 -40
- package/dist/ui.js +88 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +79 -56
- package/dist/vms.js +17 -3
- package/dist/wake.js +15 -8
- package/docs/desk.md +157 -0
- package/package.json +14 -4
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/ironrdp_web_bg.wasm +0 -0
- package/dist/accountForm.js.map +0 -1
- package/dist/accounts.js.map +0 -1
- package/dist/audio.js.map +0 -1
- package/dist/connectionStatus.js.map +0 -1
- package/dist/desk.js.map +0 -1
- package/dist/deskScript.js.map +0 -1
- package/dist/desktop.service.js.map +0 -1
- package/dist/desktopForm.js.map +0 -1
- package/dist/desktops.js.map +0 -1
- package/dist/drives.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/gateway.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/hyperv.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keyboard.js.map +0 -1
- package/dist/microphone.js.map +0 -1
- package/dist/ntlm.js.map +0 -1
- package/dist/osd.js.map +0 -1
- package/dist/probe.js.map +0 -1
- package/dist/rdcleanpath.js.map +0 -1
- package/dist/rdpFile.js.map +0 -1
- package/dist/rdpProfile.js.map +0 -1
- package/dist/remoteSetup.js.map +0 -1
- package/dist/settingsPage.js.map +0 -1
- package/dist/signin.js.map +0 -1
- package/dist/ssh.js.map +0 -1
- package/dist/sshChannelStream.js.map +0 -1
- package/dist/targets.js.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/updates.js.map +0 -1
- package/dist/vms.js.map +0 -1
- package/dist/wake.js.map +0 -1
package/dist/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,
|
|
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(
|
|
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
|
-
|
|
101
|
-
|
|
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([
|
|
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
|
|
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 && !
|
|
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,
|
|
201
|
+
drainLateData(ssh, channel.id, take);
|
|
202
|
+
}
|
|
203
|
+
if (output.overflowed) {
|
|
204
|
+
throw tooMuchOutput();
|
|
124
205
|
}
|
|
125
|
-
|
|
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
|
package/dist/sshChannelStream.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
41
|
+
channel.eof$.subscribe(() => this.channelEnded());
|
|
25
42
|
channel.closed$.subscribe(() => {
|
|
26
|
-
this.
|
|
43
|
+
this.channelEnded();
|
|
27
44
|
this.destroy();
|
|
28
45
|
});
|
|
29
46
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
+
const stdout = new ssh_1.Output();
|
|
134
140
|
let stderr = '';
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
322
|
-
return found.filter((f, i, all) => all.findIndex(g => g.cmd.destination === f.cmd.destination) === i);
|
|
419
|
+
return found;
|
|
323
420
|
}
|
|
324
|
-
/**
|
|
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(
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
children.
|
|
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
|