ai-remote 0.4.13 → 0.4.15
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/README.md +117 -1
- package/SKILL.md +114 -10
- package/dist/cli-chunk-7UHHVT7Q.mjs +483 -0
- package/dist/cli-chunk-EYQCDSPT.mjs +1567 -0
- package/dist/cli-chunk-K2DYJXC5.mjs +63 -0
- package/dist/cli-chunk-RBCU3NX4.mjs +173 -0
- package/dist/cli-chunk-XN3E5SLX.mjs +51 -0
- package/dist/cli-chunk-XT2FISR5.mjs +201 -0
- package/dist/cli-copy-NRXMTHID.mjs +718 -0
- package/dist/cli-daemon-LU4LHDCH.mjs +8418 -0
- package/dist/cli-identities-ZILT4XCR.mjs +412 -0
- package/dist/cli-secrets-26D3F6EN.mjs +218 -0
- package/dist/cli-shell-TE6DLBIB.mjs +10 -0
- package/dist/cli-window-LN32ONC4.mjs +15 -0
- package/dist/cli.mjs +332 -11080
- package/dist/index.d.ts +10 -1
- package/dist/index.js +1 -1
- package/dist/protocols.d.ts +18 -1
- package/dist/protocols.js +14 -14
- package/package.json +1 -1
|
@@ -0,0 +1,1567 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SshReader,
|
|
3
|
+
SshWriter,
|
|
4
|
+
concatBytes,
|
|
5
|
+
decodeUtf8,
|
|
6
|
+
encodeUtf8,
|
|
7
|
+
padStart,
|
|
8
|
+
toBase64,
|
|
9
|
+
toBase64Url
|
|
10
|
+
} from "./cli-chunk-XT2FISR5.mjs";
|
|
11
|
+
|
|
12
|
+
// src/cli/transport.ts
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
var TcpTransport = class extends EventTarget {
|
|
15
|
+
static CONNECTING = 0;
|
|
16
|
+
static OPEN = 1;
|
|
17
|
+
static CLOSING = 2;
|
|
18
|
+
static CLOSED = 3;
|
|
19
|
+
readyState = 0;
|
|
20
|
+
/** Set by the engines; this transport always delivers ArrayBuffers. */
|
|
21
|
+
binaryType = "arraybuffer";
|
|
22
|
+
#socket;
|
|
23
|
+
#closeSent = false;
|
|
24
|
+
constructor(host, port) {
|
|
25
|
+
super();
|
|
26
|
+
this.#socket = net.connect({ host, port, noDelay: true });
|
|
27
|
+
this.#socket.on("connect", () => {
|
|
28
|
+
this.readyState = 1;
|
|
29
|
+
this.dispatchEvent(new Event("open"));
|
|
30
|
+
});
|
|
31
|
+
this.#socket.on("data", (chunk) => {
|
|
32
|
+
const bytes = new Uint8Array(chunk.byteLength);
|
|
33
|
+
bytes.set(chunk);
|
|
34
|
+
this.dispatchEvent(new MessageEvent("message", { data: bytes.buffer }));
|
|
35
|
+
});
|
|
36
|
+
this.#socket.on("error", (error) => {
|
|
37
|
+
this.dispatchEvent(new Event("error"));
|
|
38
|
+
this.#reportClose(1006, error.message, false);
|
|
39
|
+
});
|
|
40
|
+
this.#socket.on("close", () => {
|
|
41
|
+
this.#reportClose(1006, "The host closed the TCP connection.", false);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
send(data) {
|
|
45
|
+
if (typeof data === "string") return;
|
|
46
|
+
if (this.readyState !== 1) return;
|
|
47
|
+
this.#socket.write(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
48
|
+
}
|
|
49
|
+
close(code = 1e3, reason = "") {
|
|
50
|
+
if (this.readyState >= 2) return;
|
|
51
|
+
this.readyState = 2;
|
|
52
|
+
this.#socket.destroy();
|
|
53
|
+
this.#reportClose(code, reason, code === 1e3);
|
|
54
|
+
}
|
|
55
|
+
#reportClose(code, reason, wasClean) {
|
|
56
|
+
if (this.#closeSent) return;
|
|
57
|
+
this.#closeSent = true;
|
|
58
|
+
this.readyState = 3;
|
|
59
|
+
const event = new Event("close");
|
|
60
|
+
event.code = code;
|
|
61
|
+
event.reason = reason;
|
|
62
|
+
event.wasClean = wasClean;
|
|
63
|
+
this.dispatchEvent(event);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/protocols/ssh/messages.ts
|
|
68
|
+
var MSG = {
|
|
69
|
+
DISCONNECT: 1,
|
|
70
|
+
IGNORE: 2,
|
|
71
|
+
UNIMPLEMENTED: 3,
|
|
72
|
+
DEBUG: 4,
|
|
73
|
+
SERVICE_REQUEST: 5,
|
|
74
|
+
SERVICE_ACCEPT: 6,
|
|
75
|
+
EXT_INFO: 7,
|
|
76
|
+
KEXINIT: 20,
|
|
77
|
+
NEWKEYS: 21,
|
|
78
|
+
KEX_ECDH_INIT: 30,
|
|
79
|
+
KEX_ECDH_REPLY: 31,
|
|
80
|
+
USERAUTH_REQUEST: 50,
|
|
81
|
+
USERAUTH_FAILURE: 51,
|
|
82
|
+
USERAUTH_SUCCESS: 52,
|
|
83
|
+
USERAUTH_BANNER: 53,
|
|
84
|
+
USERAUTH_INFO_REQUEST: 60,
|
|
85
|
+
/** The same number, in a publickey exchange: "that key would be accepted". */
|
|
86
|
+
USERAUTH_PK_OK: 60,
|
|
87
|
+
USERAUTH_INFO_RESPONSE: 61,
|
|
88
|
+
GLOBAL_REQUEST: 80,
|
|
89
|
+
REQUEST_SUCCESS: 81,
|
|
90
|
+
REQUEST_FAILURE: 82,
|
|
91
|
+
CHANNEL_OPEN: 90,
|
|
92
|
+
CHANNEL_OPEN_CONFIRMATION: 91,
|
|
93
|
+
CHANNEL_OPEN_FAILURE: 92,
|
|
94
|
+
CHANNEL_WINDOW_ADJUST: 93,
|
|
95
|
+
CHANNEL_DATA: 94,
|
|
96
|
+
CHANNEL_EXTENDED_DATA: 95,
|
|
97
|
+
CHANNEL_EOF: 96,
|
|
98
|
+
CHANNEL_CLOSE: 97,
|
|
99
|
+
CHANNEL_REQUEST: 98,
|
|
100
|
+
CHANNEL_SUCCESS: 99,
|
|
101
|
+
CHANNEL_FAILURE: 100
|
|
102
|
+
};
|
|
103
|
+
var EXTENDED_DATA_STDERR = 1;
|
|
104
|
+
var DISCONNECT_REASON = {
|
|
105
|
+
1: "the host reported a protocol error",
|
|
106
|
+
2: "the host could not agree on a key exchange method",
|
|
107
|
+
3: "the key exchange failed",
|
|
108
|
+
5: "the host ran out of resources",
|
|
109
|
+
6: "the connection was lost below SSH",
|
|
110
|
+
7: "the host application ended the connection",
|
|
111
|
+
8: "too many connections",
|
|
112
|
+
9: "authentication was cancelled by the user",
|
|
113
|
+
10: "no more authentication methods are available",
|
|
114
|
+
11: "the host closed the connection",
|
|
115
|
+
12: "authentication was cancelled by the host",
|
|
116
|
+
14: "the host refused the authentication attempt",
|
|
117
|
+
15: "the host closed the connection while authenticating"
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// src/protocols/ssh/kex.ts
|
|
121
|
+
var subtle = globalThis.crypto?.subtle;
|
|
122
|
+
var KEX_ALGORITHMS = {
|
|
123
|
+
"curve25519-sha256": { type: "x25519", hash: "SHA-256" },
|
|
124
|
+
"curve25519-sha256@libssh.org": { type: "x25519", hash: "SHA-256" },
|
|
125
|
+
"ecdh-sha2-nistp256": { type: "ecdh", curve: "P-256", hash: "SHA-256" }
|
|
126
|
+
};
|
|
127
|
+
var HOST_KEY_ALGORITHMS = {
|
|
128
|
+
"ssh-ed25519": { keyType: "ssh-ed25519", kind: "ed25519" },
|
|
129
|
+
"rsa-sha2-512": { keyType: "ssh-rsa", kind: "rsa", hash: "SHA-512" },
|
|
130
|
+
"rsa-sha2-256": { keyType: "ssh-rsa", kind: "rsa", hash: "SHA-256" },
|
|
131
|
+
"ecdsa-sha2-nistp256": { keyType: "ecdsa-sha2-nistp256", kind: "ecdsa", curve: "P-256", hash: "SHA-256" }
|
|
132
|
+
};
|
|
133
|
+
async function supports(algorithm, usages) {
|
|
134
|
+
if (!subtle) return false;
|
|
135
|
+
try {
|
|
136
|
+
await subtle.generateKey(algorithm, false, usages);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var cachedOffer = null;
|
|
143
|
+
async function negotiableAlgorithms() {
|
|
144
|
+
if (cachedOffer) return cachedOffer;
|
|
145
|
+
const [x25519, ed25519] = await Promise.all([
|
|
146
|
+
supports({ name: "X25519" }, ["deriveBits"]),
|
|
147
|
+
supports({ name: "Ed25519" }, ["sign", "verify"])
|
|
148
|
+
]);
|
|
149
|
+
const kex = [];
|
|
150
|
+
if (x25519) kex.push("curve25519-sha256", "curve25519-sha256@libssh.org");
|
|
151
|
+
kex.push("ecdh-sha2-nistp256");
|
|
152
|
+
const hostKey = [];
|
|
153
|
+
if (ed25519) hostKey.push("ssh-ed25519");
|
|
154
|
+
hostKey.push("rsa-sha2-512", "rsa-sha2-256", "ecdsa-sha2-nistp256");
|
|
155
|
+
cachedOffer = { kex, hostKey, x25519, ed25519 };
|
|
156
|
+
return cachedOffer;
|
|
157
|
+
}
|
|
158
|
+
async function createKexClient(algorithmName) {
|
|
159
|
+
const algorithm = KEX_ALGORITHMS[algorithmName];
|
|
160
|
+
if (!algorithm) throw new Error(`SSH: unsupported key exchange ${algorithmName}`);
|
|
161
|
+
if (algorithm.type === "x25519") {
|
|
162
|
+
const pair2 = await subtle.generateKey({ name: "X25519" }, false, ["deriveBits"]);
|
|
163
|
+
const publicKey2 = new Uint8Array(await subtle.exportKey("raw", pair2.publicKey));
|
|
164
|
+
return {
|
|
165
|
+
hash: algorithm.hash,
|
|
166
|
+
publicKey: publicKey2,
|
|
167
|
+
async sharedSecret(serverPublicKey) {
|
|
168
|
+
if (serverPublicKey.length !== 32) throw new Error("SSH: the host sent a malformed X25519 key.");
|
|
169
|
+
const peer = await subtle.importKey("raw", serverPublicKey, { name: "X25519" }, false, []);
|
|
170
|
+
const secret = new Uint8Array(await subtle.deriveBits({ name: "X25519", public: peer }, pair2.privateKey, 256));
|
|
171
|
+
if (secret.every((byte) => byte === 0)) {
|
|
172
|
+
throw new Error("SSH: the host sent a degenerate X25519 key.");
|
|
173
|
+
}
|
|
174
|
+
return secret;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const pair = await subtle.generateKey({ name: "ECDH", namedCurve: algorithm.curve }, false, ["deriveBits"]);
|
|
179
|
+
const publicKey = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
180
|
+
return {
|
|
181
|
+
hash: algorithm.hash,
|
|
182
|
+
publicKey,
|
|
183
|
+
async sharedSecret(serverPublicKey) {
|
|
184
|
+
const peer = await subtle.importKey(
|
|
185
|
+
"raw",
|
|
186
|
+
serverPublicKey,
|
|
187
|
+
{ name: "ECDH", namedCurve: algorithm.curve },
|
|
188
|
+
false,
|
|
189
|
+
[]
|
|
190
|
+
);
|
|
191
|
+
return new Uint8Array(await subtle.deriveBits({ name: "ECDH", public: peer }, pair.privateKey, 256));
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function hostKeyType(keyBlob) {
|
|
196
|
+
return new SshReader(keyBlob).string();
|
|
197
|
+
}
|
|
198
|
+
async function hostKeyFingerprint(keyBlob) {
|
|
199
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", keyBlob));
|
|
200
|
+
return `SHA256:${toBase64(digest).replace(/=+$/, "")}`;
|
|
201
|
+
}
|
|
202
|
+
async function importHostKey(algorithmName, keyBlob) {
|
|
203
|
+
const algorithm = HOST_KEY_ALGORITHMS[algorithmName];
|
|
204
|
+
if (!algorithm) throw new Error(`SSH: unsupported host key algorithm ${algorithmName}`);
|
|
205
|
+
const reader = new SshReader(keyBlob);
|
|
206
|
+
const keyType = reader.string();
|
|
207
|
+
if (keyType !== algorithm.keyType) {
|
|
208
|
+
throw new Error(`SSH: the host key is a ${keyType} but was announced as ${algorithmName}.`);
|
|
209
|
+
}
|
|
210
|
+
if (algorithm.kind === "ed25519") {
|
|
211
|
+
return subtle.importKey("raw", reader.stringBytes(), { name: "Ed25519" }, false, ["verify"]);
|
|
212
|
+
}
|
|
213
|
+
if (algorithm.kind === "rsa") {
|
|
214
|
+
const exponent = reader.mpint();
|
|
215
|
+
const modulus = reader.mpint();
|
|
216
|
+
return subtle.importKey(
|
|
217
|
+
"jwk",
|
|
218
|
+
{ kty: "RSA", n: toBase64Url(modulus), e: toBase64Url(exponent), ext: true },
|
|
219
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: algorithm.hash },
|
|
220
|
+
false,
|
|
221
|
+
["verify"]
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
reader.string();
|
|
225
|
+
return subtle.importKey(
|
|
226
|
+
"raw",
|
|
227
|
+
reader.stringBytes(),
|
|
228
|
+
{ name: "ECDSA", namedCurve: algorithm.curve },
|
|
229
|
+
false,
|
|
230
|
+
["verify"]
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
function signatureBytes(algorithmName, signatureBlob) {
|
|
234
|
+
const reader = new SshReader(signatureBlob);
|
|
235
|
+
const named = reader.string();
|
|
236
|
+
if (named !== algorithmName) {
|
|
237
|
+
throw new Error(`SSH: the host signed with ${named} after agreeing on ${algorithmName}.`);
|
|
238
|
+
}
|
|
239
|
+
const raw = reader.stringBytes();
|
|
240
|
+
if (HOST_KEY_ALGORITHMS[algorithmName]?.kind !== "ecdsa") return raw;
|
|
241
|
+
const parts = new SshReader(raw);
|
|
242
|
+
const r = parts.mpint();
|
|
243
|
+
const s = parts.mpint();
|
|
244
|
+
return concatBytes(padStart(r, 32), padStart(s, 32));
|
|
245
|
+
}
|
|
246
|
+
async function verifyHostKeySignature({ algorithm, keyBlob, signatureBlob, exchangeHash: exchangeHash2 }) {
|
|
247
|
+
const { kind, hash } = HOST_KEY_ALGORITHMS[algorithm];
|
|
248
|
+
const key = await importHostKey(algorithm, keyBlob);
|
|
249
|
+
const signature = signatureBytes(algorithm, signatureBlob);
|
|
250
|
+
const parameters = kind === "ecdsa" ? { name: "ECDSA", hash } : kind === "rsa" ? { name: "RSASSA-PKCS1-v1_5" } : { name: "Ed25519" };
|
|
251
|
+
return subtle.verify(parameters, key, signature, exchangeHash2);
|
|
252
|
+
}
|
|
253
|
+
async function exchangeHash({
|
|
254
|
+
hash,
|
|
255
|
+
clientVersion,
|
|
256
|
+
serverVersion,
|
|
257
|
+
clientKexInit,
|
|
258
|
+
serverKexInit,
|
|
259
|
+
hostKeyBlob,
|
|
260
|
+
clientPublicKey,
|
|
261
|
+
serverPublicKey,
|
|
262
|
+
sharedSecret
|
|
263
|
+
}) {
|
|
264
|
+
const writer = new SshWriter(1024);
|
|
265
|
+
writer.string(clientVersion);
|
|
266
|
+
writer.string(serverVersion);
|
|
267
|
+
writer.string(clientKexInit);
|
|
268
|
+
writer.string(serverKexInit);
|
|
269
|
+
writer.string(hostKeyBlob);
|
|
270
|
+
writer.string(clientPublicKey);
|
|
271
|
+
writer.string(serverPublicKey);
|
|
272
|
+
writer.mpint(sharedSecret);
|
|
273
|
+
return new Uint8Array(await subtle.digest(hash, writer.take()));
|
|
274
|
+
}
|
|
275
|
+
async function deriveKey({ hash, sharedSecret, exchangeHash: h, letter, sessionId, length }) {
|
|
276
|
+
const prefix = new SshWriter(64 + h.length).mpint(sharedSecret).take();
|
|
277
|
+
const seed = concatBytes(prefix, h, new Uint8Array([letter.charCodeAt(0)]), sessionId);
|
|
278
|
+
let material = new Uint8Array(await subtle.digest(hash, seed));
|
|
279
|
+
while (material.length < length) {
|
|
280
|
+
const extension = new Uint8Array(await subtle.digest(hash, concatBytes(prefix, h, material)));
|
|
281
|
+
material = concatBytes(material, extension);
|
|
282
|
+
}
|
|
283
|
+
return material.subarray(0, length);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/shared/signals.ts
|
|
287
|
+
var GATEWAY_READY_SIGNAL = "VNC_GATEWAY_READY_v1";
|
|
288
|
+
var GATEWAY_KEEPALIVE_SIGNAL = "VNC_GATEWAY_KEEPALIVE_v1";
|
|
289
|
+
var KEEPALIVE_INTERVAL_MS = 25e3;
|
|
290
|
+
|
|
291
|
+
// src/protocols/ssh/transport.ts
|
|
292
|
+
var CLIENT_VERSION = "SSH-2.0-CloudflareGateway_1.0";
|
|
293
|
+
var CIPHERS = {
|
|
294
|
+
"aes256-gcm@openssh.com": { keyLength: 32 },
|
|
295
|
+
"aes128-gcm@openssh.com": { keyLength: 16 }
|
|
296
|
+
};
|
|
297
|
+
var CIPHER_NAMES = Object.keys(CIPHERS);
|
|
298
|
+
var MAC_NAMES = ["hmac-sha2-256", "hmac-sha2-512"];
|
|
299
|
+
var COMPRESSION_NAMES = ["none"];
|
|
300
|
+
var GCM_BLOCK = 16;
|
|
301
|
+
var GCM_TAG_BYTES = 16;
|
|
302
|
+
var GCM_IV_BYTES = 12;
|
|
303
|
+
var PLAIN_BLOCK = 8;
|
|
304
|
+
var MIN_PADDING = 4;
|
|
305
|
+
var MAX_PACKET_BYTES = 262144;
|
|
306
|
+
var VERSION_LINE_LIMIT = 8192;
|
|
307
|
+
var subtle2 = globalThis.crypto?.subtle;
|
|
308
|
+
function negotiate(clientNames, serverNames) {
|
|
309
|
+
return clientNames.find((name) => serverNames.includes(name)) || null;
|
|
310
|
+
}
|
|
311
|
+
var GcmCipher = class _GcmCipher {
|
|
312
|
+
key;
|
|
313
|
+
fixed;
|
|
314
|
+
counter;
|
|
315
|
+
constructor(key, iv) {
|
|
316
|
+
this.key = key;
|
|
317
|
+
this.fixed = iv.slice(0, 4);
|
|
318
|
+
this.counter = iv.slice(4, 12);
|
|
319
|
+
}
|
|
320
|
+
static async create(keyBytes, iv) {
|
|
321
|
+
const key = await subtle2.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
322
|
+
return new _GcmCipher(key, iv);
|
|
323
|
+
}
|
|
324
|
+
/** The 12-byte nonce for this packet, then step the 64-bit counter. */
|
|
325
|
+
nextIv() {
|
|
326
|
+
const iv = concatBytes(this.fixed, this.counter);
|
|
327
|
+
for (let index = this.counter.length - 1; index >= 0; index--) {
|
|
328
|
+
this.counter[index] = this.counter[index] + 1 & 255;
|
|
329
|
+
if (this.counter[index] !== 0) break;
|
|
330
|
+
}
|
|
331
|
+
return iv;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var SshTransport = class extends EventTarget {
|
|
335
|
+
url;
|
|
336
|
+
verifyHost;
|
|
337
|
+
log;
|
|
338
|
+
socket;
|
|
339
|
+
openTransport;
|
|
340
|
+
closed;
|
|
341
|
+
buffer;
|
|
342
|
+
serverVersion;
|
|
343
|
+
greeting;
|
|
344
|
+
sendSequence;
|
|
345
|
+
receiveSequence;
|
|
346
|
+
outgoing;
|
|
347
|
+
incoming;
|
|
348
|
+
sessionId;
|
|
349
|
+
kex;
|
|
350
|
+
kexQueue;
|
|
351
|
+
keepaliveTimer;
|
|
352
|
+
bytesSent;
|
|
353
|
+
bytesReceived;
|
|
354
|
+
encryptChain;
|
|
355
|
+
newKeysBarrier;
|
|
356
|
+
releaseNewKeys;
|
|
357
|
+
pendingServerKexInit;
|
|
358
|
+
established;
|
|
359
|
+
/**
|
|
360
|
+
* @param {string} url gateway WebSocket URL
|
|
361
|
+
* @param {object} options
|
|
362
|
+
* @param {(info: object) => Promise<boolean>} options.verifyHost decides
|
|
363
|
+
* whether an unknown or changed host key may be used.
|
|
364
|
+
*/
|
|
365
|
+
constructor(url, { verifyHost, log, openTransport } = {}) {
|
|
366
|
+
super();
|
|
367
|
+
this.url = url;
|
|
368
|
+
this.verifyHost = verifyHost || (() => true);
|
|
369
|
+
this.log = log || (() => {
|
|
370
|
+
});
|
|
371
|
+
this.openTransport = openTransport || null;
|
|
372
|
+
this.socket = null;
|
|
373
|
+
this.closed = false;
|
|
374
|
+
this.buffer = new Uint8Array(0);
|
|
375
|
+
this.serverVersion = "";
|
|
376
|
+
this.greeting = [];
|
|
377
|
+
this.sendSequence = 0;
|
|
378
|
+
this.receiveSequence = 0;
|
|
379
|
+
this.outgoing = null;
|
|
380
|
+
this.incoming = null;
|
|
381
|
+
this.sessionId = null;
|
|
382
|
+
this.kex = null;
|
|
383
|
+
this.kexQueue = [];
|
|
384
|
+
this.keepaliveTimer = null;
|
|
385
|
+
this.bytesSent = 0;
|
|
386
|
+
this.bytesReceived = 0;
|
|
387
|
+
}
|
|
388
|
+
// --- connection ----------------------------------------------------------
|
|
389
|
+
connect() {
|
|
390
|
+
this.socket = this.openTransport ? this.openTransport(this.url) : new WebSocket(this.url);
|
|
391
|
+
this.socket.binaryType = "arraybuffer";
|
|
392
|
+
this.socket.addEventListener("open", () => {
|
|
393
|
+
this.socket.send(GATEWAY_READY_SIGNAL);
|
|
394
|
+
this.#write(encodeUtf8(`${CLIENT_VERSION}\r
|
|
395
|
+
`));
|
|
396
|
+
this.keepaliveTimer = setInterval(() => {
|
|
397
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
398
|
+
this.socket.send(GATEWAY_KEEPALIVE_SIGNAL);
|
|
399
|
+
if (this.sessionId) {
|
|
400
|
+
try {
|
|
401
|
+
this.#sendPacket(new SshWriter(64).u8(MSG.GLOBAL_REQUEST).string("keepalive@openssh.com").u8(1).take());
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
406
|
+
});
|
|
407
|
+
this.socket.addEventListener("message", (event) => {
|
|
408
|
+
if (typeof event.data === "string") return;
|
|
409
|
+
this.#receive(new Uint8Array(event.data));
|
|
410
|
+
});
|
|
411
|
+
this.socket.addEventListener("close", (event) => {
|
|
412
|
+
this.#finish({
|
|
413
|
+
clean: event.code === 1e3,
|
|
414
|
+
code: event.code,
|
|
415
|
+
message: event.reason || (event.code === 1006 ? "The gateway rejected or could not reach this SSH target. Confirm that SSH is published for this host." : "")
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
this.socket.addEventListener("error", () => {
|
|
419
|
+
this.log("The SSH gateway WebSocket reported a transport error; waiting for its close reason.");
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
disconnect(message = "Client disconnected") {
|
|
423
|
+
if (this.closed) return;
|
|
424
|
+
try {
|
|
425
|
+
const payload = new SshWriter(64).u8(MSG.DISCONNECT).u32(11).string(message).string("").take();
|
|
426
|
+
this.#sendPacket(payload);
|
|
427
|
+
} catch {
|
|
428
|
+
}
|
|
429
|
+
const socket = this.socket;
|
|
430
|
+
void Promise.resolve(this.encryptChain).finally(() => {
|
|
431
|
+
try {
|
|
432
|
+
socket?.close(1e3, "Client disconnected");
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
this.#finish({ clean: true, code: 1e3, message: "" });
|
|
437
|
+
}
|
|
438
|
+
#finish(detail) {
|
|
439
|
+
if (this.closed) return;
|
|
440
|
+
this.closed = true;
|
|
441
|
+
clearInterval(this.keepaliveTimer);
|
|
442
|
+
this.keepaliveTimer = null;
|
|
443
|
+
this.dispatchEvent(new CustomEvent("close", { detail }));
|
|
444
|
+
}
|
|
445
|
+
fail(error) {
|
|
446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
447
|
+
if (this.closed) return;
|
|
448
|
+
this.dispatchEvent(new CustomEvent("error", { detail: { message } }));
|
|
449
|
+
this.#finish({ clean: false, code: 4e3, message });
|
|
450
|
+
try {
|
|
451
|
+
this.socket?.close(4e3, message.slice(0, 100));
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
#write(bytes) {
|
|
456
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
457
|
+
this.bytesSent += bytes.length;
|
|
458
|
+
this.socket.send(bytes);
|
|
459
|
+
}
|
|
460
|
+
// --- framing -------------------------------------------------------------
|
|
461
|
+
#receive(chunk) {
|
|
462
|
+
this.bytesReceived += chunk.length;
|
|
463
|
+
this.buffer = this.buffer.length ? concatBytes(this.buffer, chunk) : chunk;
|
|
464
|
+
this.drainChain = (this.drainChain || Promise.resolve()).then(() => this.#drain()).catch((error) => this.fail(error));
|
|
465
|
+
}
|
|
466
|
+
async #drain() {
|
|
467
|
+
if (this.closed) return;
|
|
468
|
+
if (!this.serverVersion && !this.#readVersion()) return;
|
|
469
|
+
while (!this.closed) {
|
|
470
|
+
if (this.newKeysBarrier) {
|
|
471
|
+
const barrier = this.newKeysBarrier;
|
|
472
|
+
this.newKeysBarrier = null;
|
|
473
|
+
await barrier;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!this.incoming) {
|
|
477
|
+
const payload = this.#readPlainPacket();
|
|
478
|
+
if (!payload) return;
|
|
479
|
+
this.#dispatchPacket(payload);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const frame = this.#takeEncryptedFrame();
|
|
483
|
+
if (!frame) return;
|
|
484
|
+
this.#dispatchPacket(await this.#decrypt(frame));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The host may print any number of lines before its version line, and RFC
|
|
489
|
+
* 4253 says to show them: this is where a "banned by fail2ban" notice or a
|
|
490
|
+
* legal banner arrives.
|
|
491
|
+
*/
|
|
492
|
+
#readVersion() {
|
|
493
|
+
while (true) {
|
|
494
|
+
const newline = this.buffer.indexOf(10);
|
|
495
|
+
if (newline === -1) {
|
|
496
|
+
if (this.buffer.length > VERSION_LINE_LIMIT) {
|
|
497
|
+
throw new Error("SSH: the host sent no version line.");
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
let end = newline;
|
|
502
|
+
if (end > 0 && this.buffer[end - 1] === 13) end--;
|
|
503
|
+
const line = new TextDecoder().decode(this.buffer.subarray(0, end));
|
|
504
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
505
|
+
if (!line.startsWith("SSH-")) {
|
|
506
|
+
if (line.trim()) this.greeting.push(line);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (!line.startsWith("SSH-2.0-") && !line.startsWith("SSH-1.99-")) {
|
|
510
|
+
throw new Error(`SSH: this host speaks ${line.split("-").slice(0, 2).join("-")}, which is too old.`);
|
|
511
|
+
}
|
|
512
|
+
this.serverVersion = line;
|
|
513
|
+
this.log("version", { server: line, greeting: this.greeting });
|
|
514
|
+
this.dispatchEvent(new CustomEvent("greeting", { detail: { lines: this.greeting.slice() } }));
|
|
515
|
+
void this.#startKex();
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** A packet from before the keys exist. Returns null when more bytes are due. */
|
|
520
|
+
#readPlainPacket() {
|
|
521
|
+
if (this.buffer.length < 5) return null;
|
|
522
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
523
|
+
const packetLength = view.getUint32(0, false);
|
|
524
|
+
if (packetLength < 8 || packetLength > MAX_PACKET_BYTES) {
|
|
525
|
+
throw new Error("SSH: the host sent a packet of an impossible size.");
|
|
526
|
+
}
|
|
527
|
+
if (this.buffer.length < 4 + packetLength) return null;
|
|
528
|
+
const paddingLength = this.buffer[4];
|
|
529
|
+
if (paddingLength + 1 > packetLength) throw new Error("SSH: a packet claimed more padding than it had.");
|
|
530
|
+
const payload = this.buffer.slice(5, 4 + packetLength - paddingLength);
|
|
531
|
+
this.buffer = this.buffer.subarray(4 + packetLength);
|
|
532
|
+
this.receiveSequence = this.receiveSequence + 1 >>> 0;
|
|
533
|
+
return payload;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* With AES-GCM the length is authenticated but not encrypted, so the frame
|
|
537
|
+
* can be measured before anything is decrypted (RFC 5647).
|
|
538
|
+
*/
|
|
539
|
+
#takeEncryptedFrame() {
|
|
540
|
+
if (this.buffer.length < 4) return null;
|
|
541
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
542
|
+
const packetLength = view.getUint32(0, false);
|
|
543
|
+
if (packetLength < GCM_BLOCK || packetLength % GCM_BLOCK !== 0 || packetLength > MAX_PACKET_BYTES) {
|
|
544
|
+
throw new Error("SSH: the host sent a packet of an impossible size.");
|
|
545
|
+
}
|
|
546
|
+
const total = 4 + packetLength + GCM_TAG_BYTES;
|
|
547
|
+
if (this.buffer.length < total) return null;
|
|
548
|
+
const frame = this.buffer.slice(0, total);
|
|
549
|
+
this.buffer = this.buffer.subarray(total);
|
|
550
|
+
return frame;
|
|
551
|
+
}
|
|
552
|
+
async #decrypt(frame) {
|
|
553
|
+
let plain;
|
|
554
|
+
try {
|
|
555
|
+
plain = new Uint8Array(await subtle2.decrypt(
|
|
556
|
+
{
|
|
557
|
+
name: "AES-GCM",
|
|
558
|
+
iv: this.incoming.nextIv(),
|
|
559
|
+
additionalData: frame.subarray(0, 4),
|
|
560
|
+
tagLength: GCM_TAG_BYTES * 8
|
|
561
|
+
},
|
|
562
|
+
this.incoming.key,
|
|
563
|
+
frame.subarray(4)
|
|
564
|
+
));
|
|
565
|
+
} catch {
|
|
566
|
+
throw new Error("SSH: a packet failed its integrity check, so the session was dropped.");
|
|
567
|
+
}
|
|
568
|
+
const paddingLength = plain[0];
|
|
569
|
+
if (paddingLength + 1 > plain.length) throw new Error("SSH: a packet claimed more padding than it had.");
|
|
570
|
+
this.receiveSequence = this.receiveSequence + 1 >>> 0;
|
|
571
|
+
return plain.slice(1, plain.length - paddingLength);
|
|
572
|
+
}
|
|
573
|
+
#dispatchPacket(payload) {
|
|
574
|
+
if (payload.length === 0) return;
|
|
575
|
+
const type = payload[0];
|
|
576
|
+
switch (type) {
|
|
577
|
+
case MSG.DISCONNECT: {
|
|
578
|
+
const reader = new SshReader(payload, 1);
|
|
579
|
+
const code = reader.u32();
|
|
580
|
+
const description = reader.string();
|
|
581
|
+
const reason = description || DISCONNECT_REASON[code] || `reason ${code}`;
|
|
582
|
+
this.dispatchEvent(new CustomEvent("hostdisconnect", { detail: { code, reason } }));
|
|
583
|
+
this.#finish({ clean: code === 11, code: 1e3, message: reason });
|
|
584
|
+
try {
|
|
585
|
+
this.socket?.close(1e3, "Host disconnected");
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
case MSG.IGNORE:
|
|
591
|
+
case MSG.UNIMPLEMENTED:
|
|
592
|
+
case MSG.EXT_INFO:
|
|
593
|
+
return;
|
|
594
|
+
case MSG.DEBUG: {
|
|
595
|
+
const reader = new SshReader(payload, 1);
|
|
596
|
+
const alwaysDisplay = reader.boolean();
|
|
597
|
+
const message = reader.string();
|
|
598
|
+
this.log("debug", { message, alwaysDisplay });
|
|
599
|
+
if (alwaysDisplay) {
|
|
600
|
+
this.dispatchEvent(new CustomEvent("banner", { detail: { text: `${message}\r
|
|
601
|
+
` } }));
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
case MSG.KEXINIT:
|
|
606
|
+
void this.#onKexInit(payload);
|
|
607
|
+
return;
|
|
608
|
+
case MSG.NEWKEYS:
|
|
609
|
+
this.#onNewKeys();
|
|
610
|
+
return;
|
|
611
|
+
case MSG.GLOBAL_REQUEST: {
|
|
612
|
+
const reader = new SshReader(payload, 1);
|
|
613
|
+
reader.string();
|
|
614
|
+
if (reader.boolean()) this.#sendPacket(new Uint8Array([MSG.REQUEST_FAILURE]));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// The answer to the keepalive global request. Either one means the host
|
|
618
|
+
// is still there, which is the whole point of having asked.
|
|
619
|
+
case MSG.REQUEST_SUCCESS:
|
|
620
|
+
case MSG.REQUEST_FAILURE:
|
|
621
|
+
return;
|
|
622
|
+
default:
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
if (this.kex && type >= 30 && type <= 49) {
|
|
626
|
+
this.#deliverKexPacket(payload);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.dispatchEvent(new CustomEvent("packet", { detail: { type, payload } }));
|
|
630
|
+
}
|
|
631
|
+
// --- sending -------------------------------------------------------------
|
|
632
|
+
/** Application packets wait while a key exchange is in flight (RFC 4253 7.1). */
|
|
633
|
+
send(payload) {
|
|
634
|
+
if (this.closed) return false;
|
|
635
|
+
if (this.kex?.running) {
|
|
636
|
+
this.kexQueue.push(payload);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
this.#sendPacket(payload);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
#sendPacket(payload) {
|
|
643
|
+
if (this.closed || this.socket?.readyState !== WebSocket.OPEN) return;
|
|
644
|
+
if (!this.outgoing) {
|
|
645
|
+
this.#write(framePlain(payload));
|
|
646
|
+
this.sendSequence = this.sendSequence + 1 >>> 0;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
void this.#encrypt(payload);
|
|
650
|
+
}
|
|
651
|
+
async #encrypt(payload) {
|
|
652
|
+
const previous = this.encryptChain || Promise.resolve();
|
|
653
|
+
this.encryptChain = previous.then(async () => {
|
|
654
|
+
if (this.closed || !this.outgoing) return;
|
|
655
|
+
const paddingLength = gcmPaddingLength(payload.length);
|
|
656
|
+
const plain = new Uint8Array(1 + payload.length + paddingLength);
|
|
657
|
+
plain[0] = paddingLength;
|
|
658
|
+
plain.set(payload, 1);
|
|
659
|
+
crypto.getRandomValues(plain.subarray(1 + payload.length));
|
|
660
|
+
const header = new SshWriter(4).u32(plain.length).take();
|
|
661
|
+
const sealed = new Uint8Array(await subtle2.encrypt(
|
|
662
|
+
{ name: "AES-GCM", iv: this.outgoing.nextIv(), additionalData: header, tagLength: GCM_TAG_BYTES * 8 },
|
|
663
|
+
this.outgoing.key,
|
|
664
|
+
plain
|
|
665
|
+
));
|
|
666
|
+
this.#write(concatBytes(header, sealed));
|
|
667
|
+
this.sendSequence = this.sendSequence + 1 >>> 0;
|
|
668
|
+
}).catch((error) => this.fail(error));
|
|
669
|
+
return this.encryptChain;
|
|
670
|
+
}
|
|
671
|
+
// --- key exchange --------------------------------------------------------
|
|
672
|
+
async #startKex() {
|
|
673
|
+
if (this.kex?.running) return;
|
|
674
|
+
this.kex = { running: true, clientKexInit: null, pending: null, inbox: [] };
|
|
675
|
+
try {
|
|
676
|
+
const offer = await negotiableAlgorithms();
|
|
677
|
+
const clientKexInit = buildKexInit(offer);
|
|
678
|
+
this.kex.clientKexInit = clientKexInit;
|
|
679
|
+
this.#sendPacket(clientKexInit);
|
|
680
|
+
const serverKexInit = this.pendingServerKexInit || await this.#nextKexPacket(MSG.KEXINIT);
|
|
681
|
+
this.pendingServerKexInit = null;
|
|
682
|
+
await this.#runKex(offer, clientKexInit, serverKexInit);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
this.fail(error);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/** The host may ask for new keys at any point in a session. */
|
|
688
|
+
async #onKexInit(payload) {
|
|
689
|
+
if (this.kex?.running) {
|
|
690
|
+
this.#deliverKexPacket(payload);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
this.log("rekey", { reason: "the host asked for new keys" });
|
|
694
|
+
this.pendingServerKexInit = payload;
|
|
695
|
+
await this.#startKex();
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Key exchange packets can arrive before the step that wants them: the host
|
|
699
|
+
* sends its reply and NEWKEYS back to back, while this side is still
|
|
700
|
+
* deriving a secret or waiting for the user to accept a host key. They are
|
|
701
|
+
* queued rather than dropped.
|
|
702
|
+
*/
|
|
703
|
+
#deliverKexPacket(payload) {
|
|
704
|
+
if (!this.kex) return;
|
|
705
|
+
if (this.kex.pending) {
|
|
706
|
+
const pending = this.kex.pending;
|
|
707
|
+
this.kex.pending = null;
|
|
708
|
+
pending(payload);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
this.kex.inbox.push(payload);
|
|
712
|
+
}
|
|
713
|
+
#nextKexPacket(expectedType) {
|
|
714
|
+
return new Promise((resolve, reject) => {
|
|
715
|
+
const check = (payload) => {
|
|
716
|
+
if (expectedType !== void 0 && payload[0] !== expectedType) {
|
|
717
|
+
reject(new Error(`SSH: expected message ${expectedType} during key exchange but got ${payload[0]}.`));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
resolve(payload);
|
|
721
|
+
};
|
|
722
|
+
const queued = this.kex.inbox.shift();
|
|
723
|
+
if (queued) {
|
|
724
|
+
check(queued);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.kex.pending = (payload) => {
|
|
728
|
+
this.kex.pending = null;
|
|
729
|
+
check(payload);
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async #runKex(offer, clientKexInit, serverKexInit) {
|
|
734
|
+
const server = parseKexInit(serverKexInit);
|
|
735
|
+
const kexAlgorithm = negotiate(offer.kex, server.kex);
|
|
736
|
+
const hostKeyAlgorithm = negotiate(offer.hostKey, server.hostKey);
|
|
737
|
+
const cipherToServer = negotiate(CIPHER_NAMES, server.cipherClientToServer);
|
|
738
|
+
const cipherToClient = negotiate(CIPHER_NAMES, server.cipherServerToClient);
|
|
739
|
+
if (!kexAlgorithm) throw new Error(`SSH: no shared key exchange method. The host offers ${server.kex.join(", ")}.`);
|
|
740
|
+
if (!hostKeyAlgorithm) throw new Error(`SSH: no host key type this browser can verify. The host offers ${server.hostKey.join(", ")}.`);
|
|
741
|
+
if (!cipherToServer || !cipherToClient) {
|
|
742
|
+
throw new Error(`SSH: the host does not offer AES-GCM, which is the only cipher this client can use. It offers ${server.cipherServerToClient.join(", ")}.`);
|
|
743
|
+
}
|
|
744
|
+
this.log("negotiated", {
|
|
745
|
+
kex: kexAlgorithm,
|
|
746
|
+
hostKey: hostKeyAlgorithm,
|
|
747
|
+
cipher: cipherToClient,
|
|
748
|
+
rekey: Boolean(this.sessionId)
|
|
749
|
+
});
|
|
750
|
+
const client = await createKexClient(kexAlgorithm);
|
|
751
|
+
this.#sendPacket(new SshWriter(128).u8(MSG.KEX_ECDH_INIT).string(client.publicKey).take());
|
|
752
|
+
const reply = new SshReader(await this.#nextKexPacket(MSG.KEX_ECDH_REPLY), 1);
|
|
753
|
+
const hostKeyBlob = reply.stringBytes();
|
|
754
|
+
const serverPublicKey = reply.stringBytes();
|
|
755
|
+
const signatureBlob = reply.stringBytes();
|
|
756
|
+
const sharedSecret = await client.sharedSecret(serverPublicKey);
|
|
757
|
+
const hash = await exchangeHash({
|
|
758
|
+
hash: client.hash,
|
|
759
|
+
clientVersion: CLIENT_VERSION,
|
|
760
|
+
serverVersion: this.serverVersion,
|
|
761
|
+
clientKexInit,
|
|
762
|
+
serverKexInit,
|
|
763
|
+
hostKeyBlob,
|
|
764
|
+
clientPublicKey: client.publicKey,
|
|
765
|
+
serverPublicKey,
|
|
766
|
+
sharedSecret
|
|
767
|
+
});
|
|
768
|
+
const signatureValid = await verifyHostKeySignature({
|
|
769
|
+
algorithm: hostKeyAlgorithm,
|
|
770
|
+
keyBlob: hostKeyBlob,
|
|
771
|
+
signatureBlob,
|
|
772
|
+
exchangeHash: hash
|
|
773
|
+
});
|
|
774
|
+
if (!signatureValid) {
|
|
775
|
+
throw new Error("SSH: the host key signature is invalid, so this is not the machine it claims to be.");
|
|
776
|
+
}
|
|
777
|
+
const fingerprint = await hostKeyFingerprint(hostKeyBlob);
|
|
778
|
+
if (!this.sessionId) {
|
|
779
|
+
const accepted = await this.verifyHost({
|
|
780
|
+
fingerprint,
|
|
781
|
+
keyType: hostKeyType(hostKeyBlob),
|
|
782
|
+
algorithm: hostKeyAlgorithm
|
|
783
|
+
});
|
|
784
|
+
if (!accepted) throw new Error("SSH: the host key was not accepted, so the connection was stopped.");
|
|
785
|
+
this.sessionId = hash;
|
|
786
|
+
}
|
|
787
|
+
this.#sendPacket(new Uint8Array([MSG.NEWKEYS]));
|
|
788
|
+
await this.#nextKexPacket(MSG.NEWKEYS);
|
|
789
|
+
const parameters = {
|
|
790
|
+
hash: client.hash,
|
|
791
|
+
sharedSecret,
|
|
792
|
+
exchangeHash: hash,
|
|
793
|
+
sessionId: this.sessionId
|
|
794
|
+
};
|
|
795
|
+
const [ivToServer, ivToClient, keyToServer, keyToClient] = await Promise.all([
|
|
796
|
+
deriveKey({ ...parameters, letter: "A", length: GCM_IV_BYTES }),
|
|
797
|
+
deriveKey({ ...parameters, letter: "B", length: GCM_IV_BYTES }),
|
|
798
|
+
deriveKey({ ...parameters, letter: "C", length: CIPHERS[cipherToServer].keyLength }),
|
|
799
|
+
deriveKey({ ...parameters, letter: "D", length: CIPHERS[cipherToClient].keyLength })
|
|
800
|
+
]);
|
|
801
|
+
this.outgoing = await GcmCipher.create(keyToServer, ivToServer);
|
|
802
|
+
this.incoming = await GcmCipher.create(keyToClient, ivToClient);
|
|
803
|
+
this.kex.running = false;
|
|
804
|
+
this.releaseNewKeys?.();
|
|
805
|
+
this.releaseNewKeys = null;
|
|
806
|
+
const queued = this.kexQueue;
|
|
807
|
+
this.kexQueue = [];
|
|
808
|
+
for (const payload of queued) this.#sendPacket(payload);
|
|
809
|
+
const firstTime = !this.established;
|
|
810
|
+
this.established = true;
|
|
811
|
+
this.dispatchEvent(new CustomEvent("keys", {
|
|
812
|
+
detail: { fingerprint, kexAlgorithm, hostKeyAlgorithm, cipher: cipherToClient, firstTime }
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
#onNewKeys() {
|
|
816
|
+
if (!this.kex) return;
|
|
817
|
+
this.newKeysBarrier = new Promise((resolve) => {
|
|
818
|
+
this.releaseNewKeys = resolve;
|
|
819
|
+
});
|
|
820
|
+
this.#deliverKexPacket(new Uint8Array([MSG.NEWKEYS]));
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
function gcmPaddingLength(payloadLength) {
|
|
824
|
+
const unpadded = 1 + payloadLength;
|
|
825
|
+
let padding = GCM_BLOCK - unpadded % GCM_BLOCK;
|
|
826
|
+
while (padding < MIN_PADDING) padding += GCM_BLOCK;
|
|
827
|
+
return padding;
|
|
828
|
+
}
|
|
829
|
+
function framePlain(payload) {
|
|
830
|
+
const unpadded = 5 + payload.length;
|
|
831
|
+
let padding = PLAIN_BLOCK - unpadded % PLAIN_BLOCK;
|
|
832
|
+
if (padding < MIN_PADDING) padding += PLAIN_BLOCK;
|
|
833
|
+
const writer = new SshWriter(unpadded + padding);
|
|
834
|
+
writer.u32(1 + payload.length + padding);
|
|
835
|
+
writer.u8(padding);
|
|
836
|
+
writer.raw(payload);
|
|
837
|
+
const frame = writer.take();
|
|
838
|
+
const filler = new Uint8Array(padding);
|
|
839
|
+
crypto.getRandomValues(filler);
|
|
840
|
+
return concatBytes(frame, filler);
|
|
841
|
+
}
|
|
842
|
+
function buildKexInit(offer) {
|
|
843
|
+
const cookie = new Uint8Array(16);
|
|
844
|
+
crypto.getRandomValues(cookie);
|
|
845
|
+
const writer = new SshWriter(512);
|
|
846
|
+
writer.u8(MSG.KEXINIT).raw(cookie);
|
|
847
|
+
writer.nameList(offer.kex);
|
|
848
|
+
writer.nameList(offer.hostKey);
|
|
849
|
+
writer.nameList(CIPHER_NAMES);
|
|
850
|
+
writer.nameList(CIPHER_NAMES);
|
|
851
|
+
writer.nameList(MAC_NAMES);
|
|
852
|
+
writer.nameList(MAC_NAMES);
|
|
853
|
+
writer.nameList(COMPRESSION_NAMES);
|
|
854
|
+
writer.nameList(COMPRESSION_NAMES);
|
|
855
|
+
writer.nameList([]);
|
|
856
|
+
writer.nameList([]);
|
|
857
|
+
writer.boolean(false);
|
|
858
|
+
writer.u32(0);
|
|
859
|
+
return writer.take();
|
|
860
|
+
}
|
|
861
|
+
function parseKexInit(payload) {
|
|
862
|
+
const reader = new SshReader(payload, 17);
|
|
863
|
+
return {
|
|
864
|
+
kex: reader.nameList(),
|
|
865
|
+
hostKey: reader.nameList(),
|
|
866
|
+
cipherClientToServer: reader.nameList(),
|
|
867
|
+
cipherServerToClient: reader.nameList(),
|
|
868
|
+
macClientToServer: reader.nameList(),
|
|
869
|
+
macServerToClient: reader.nameList(),
|
|
870
|
+
compressionClientToServer: reader.nameList(),
|
|
871
|
+
compressionServerToClient: reader.nameList()
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/protocols/ssh/session.ts
|
|
876
|
+
var SERVICE = "ssh-connection";
|
|
877
|
+
var TERMINAL_TYPE = "xterm-256color";
|
|
878
|
+
var INITIAL_WINDOW = 2 * 1024 * 1024;
|
|
879
|
+
var MAX_PACKET = 32 * 1024;
|
|
880
|
+
var WINDOW_REFILL_THRESHOLD = INITIAL_WINDOW / 2;
|
|
881
|
+
var PTY_MODES = new Uint8Array([0]);
|
|
882
|
+
var COMMAND_START_MS = 2e4;
|
|
883
|
+
var COMMAND_IDLE_MS = 5 * 6e4;
|
|
884
|
+
var COMMAND_MAX_MS = 60 * 6e4;
|
|
885
|
+
var NO_BYTES = new Uint8Array(0);
|
|
886
|
+
function singleQuote(value) {
|
|
887
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
888
|
+
}
|
|
889
|
+
function shellFromPrompt(text) {
|
|
890
|
+
const tail = text.slice(-400);
|
|
891
|
+
if (/(?:^|\n)PS [A-Za-z]:\\[^\n]*>\s*$/.test(tail)) return "powershell";
|
|
892
|
+
if (/(?:^|\n)[^\n]*[A-Za-z]:\\[^\n]*>\s*$/.test(tail)) return "cmd";
|
|
893
|
+
return "";
|
|
894
|
+
}
|
|
895
|
+
var FRAMING_REJECTED = /is not recognized as an internal or external command|command not found|not recognized as the name of a cmdlet|CommandNotFoundException/i;
|
|
896
|
+
function frameCommand(family, command, id) {
|
|
897
|
+
if (family === "cmd") {
|
|
898
|
+
return `echo.&echo CCB${id}&${command}&call echo.CCE${id} %^ERRORLEVEL%\r`;
|
|
899
|
+
}
|
|
900
|
+
if (family === "powershell") {
|
|
901
|
+
const encoded = toBase64(encodeUtf8(command));
|
|
902
|
+
return `Write-Output ""; Write-Output ("CCB" + "${id}"); $__ccCode = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $global:LASTEXITCODE = $null; . ([ScriptBlock]::Create($__ccCode)); $__ccOk = $?; $__cc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($__ccOk) { 0 } else { 1 }; Write-Output ("CCE" + "${id} " + $__cc)\r`;
|
|
903
|
+
}
|
|
904
|
+
return `printf '\\n%s%s\\n' 'CCB' '${id}'; eval ${singleQuote(command)}; __cc=$?; printf '\\n%s%s %s\\n' 'CCE' '${id}' "$__cc"\r`;
|
|
905
|
+
}
|
|
906
|
+
function stripTerminalCodes(text) {
|
|
907
|
+
return text.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "").replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\u001b[@-Z\\-_]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
908
|
+
}
|
|
909
|
+
function filterDisplay(session, bytes) {
|
|
910
|
+
const filter = session.displayFilter;
|
|
911
|
+
if (!filter) return bytes;
|
|
912
|
+
filter.pending += decodeUtf8(bytes);
|
|
913
|
+
let shown = "";
|
|
914
|
+
if (filter.stage === "before") {
|
|
915
|
+
const at = filter.pending.indexOf(filter.begin);
|
|
916
|
+
if (at === -1) return NO_BYTES;
|
|
917
|
+
filter.pending = filter.pending.slice(at + filter.begin.length).replace(/^\r?\n/, "");
|
|
918
|
+
filter.stage = "inside";
|
|
919
|
+
}
|
|
920
|
+
if (filter.stage === "inside") {
|
|
921
|
+
const at = filter.pending.indexOf(filter.end);
|
|
922
|
+
if (at === -1) {
|
|
923
|
+
shown = filter.pending;
|
|
924
|
+
filter.pending = "";
|
|
925
|
+
return shown ? encodeUtf8(shown) : NO_BYTES;
|
|
926
|
+
}
|
|
927
|
+
shown = filter.pending.slice(0, at);
|
|
928
|
+
filter.pending = filter.pending.slice(at);
|
|
929
|
+
filter.stage = "closing";
|
|
930
|
+
}
|
|
931
|
+
const newline = filter.pending.indexOf("\n");
|
|
932
|
+
if (newline !== -1) {
|
|
933
|
+
shown += filter.pending.slice(newline + 1);
|
|
934
|
+
session.displayFilter = null;
|
|
935
|
+
}
|
|
936
|
+
return shown ? encodeUtf8(shown) : NO_BYTES;
|
|
937
|
+
}
|
|
938
|
+
function releaseDisplayFilter(session) {
|
|
939
|
+
const filter = session.displayFilter;
|
|
940
|
+
session.displayFilter = null;
|
|
941
|
+
if (!filter || filter.stage !== "before" || !filter.pending) return;
|
|
942
|
+
session.dispatchEvent(new CustomEvent("data", {
|
|
943
|
+
detail: { bytes: NO_BYTES, display: encodeUtf8(filter.pending), isStderr: false }
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
var SshSession = class extends EventTarget {
|
|
947
|
+
/**
|
|
948
|
+
* @param {string} url gateway WebSocket URL
|
|
949
|
+
* @param {object} options
|
|
950
|
+
* @param {string} options.username
|
|
951
|
+
* @param {string} [options.password]
|
|
952
|
+
* @param {SshIdentity[] | (() => Promise<SshIdentity[]>)} [options.identities]
|
|
953
|
+
* @param {'powershell'} [options.preferredShell]
|
|
954
|
+
* @param {string} [options.subsystem] run a subsystem (e.g. 'sftp') rather than a shell
|
|
955
|
+
* @param {(step: string, detail: object) => void} [options.log] where handshake notes go
|
|
956
|
+
* @param {(info: object) => Promise<boolean>} [options.verifyHost]
|
|
957
|
+
* @param {(prompt: object) => Promise<string>} [options.requestInput]
|
|
958
|
+
*/
|
|
959
|
+
constructor(url, options = {}) {
|
|
960
|
+
super();
|
|
961
|
+
this.options = options;
|
|
962
|
+
this.username = options.username || "";
|
|
963
|
+
this.password = options.password || "";
|
|
964
|
+
this.identities = options.identities || [];
|
|
965
|
+
this.requestInput = options.requestInput || (async () => "");
|
|
966
|
+
this.canAsk = Boolean(options.requestInput);
|
|
967
|
+
this.columns = options.columns || 80;
|
|
968
|
+
this.rows = options.rows || 24;
|
|
969
|
+
this.subsystem = options.subsystem || "";
|
|
970
|
+
this.channelId = 0;
|
|
971
|
+
this.remoteChannelId = null;
|
|
972
|
+
this.remoteWindow = 0;
|
|
973
|
+
this.remoteMaxPacket = MAX_PACKET;
|
|
974
|
+
this.localWindow = INITIAL_WINDOW;
|
|
975
|
+
this.pendingWrites = [];
|
|
976
|
+
this.shellOpen = false;
|
|
977
|
+
this.commandInFlight = false;
|
|
978
|
+
this.displayFilter = null;
|
|
979
|
+
this.readySignaled = false;
|
|
980
|
+
this.powerShellAttempted = false;
|
|
981
|
+
this.powerShellTimer = null;
|
|
982
|
+
this.shellFamily = "";
|
|
983
|
+
this.promptTail = "";
|
|
984
|
+
this.exitStatus = null;
|
|
985
|
+
this.authMethods = [];
|
|
986
|
+
this.triedKeys = [];
|
|
987
|
+
this.passwordRejected = false;
|
|
988
|
+
this.closed = false;
|
|
989
|
+
this.waiters = /* @__PURE__ */ new Map();
|
|
990
|
+
this.transport = new SshTransport(url, {
|
|
991
|
+
verifyHost: options.verifyHost || (async () => true),
|
|
992
|
+
openTransport: options.openTransport || null,
|
|
993
|
+
// stdout by default, because a terminal is what this usually feeds and
|
|
994
|
+
// the handshake is worth seeing there. A caller whose stdout is somebody
|
|
995
|
+
// else's input -- `cp --json`, say -- passes its own and sends it
|
|
996
|
+
// elsewhere; a line of key exchange in the middle of a JSON document is
|
|
997
|
+
// not a cosmetic problem.
|
|
998
|
+
log: options.log ?? ((step, detail) => console.info(`[SSH] ${step}`, detail))
|
|
999
|
+
});
|
|
1000
|
+
this.transport.addEventListener("packet", (event) => {
|
|
1001
|
+
this.#handlePacket(event.detail.type, event.detail.payload);
|
|
1002
|
+
});
|
|
1003
|
+
this.transport.addEventListener("greeting", (event) => {
|
|
1004
|
+
for (const line of event.detail.lines) this.#emitBanner(`${line}\r
|
|
1005
|
+
`);
|
|
1006
|
+
});
|
|
1007
|
+
this.transport.addEventListener("banner", (event) => this.#emitBanner(event.detail.text));
|
|
1008
|
+
this.transport.addEventListener("keys", (event) => {
|
|
1009
|
+
this.fingerprint = event.detail.fingerprint;
|
|
1010
|
+
this.#status("secured", `Host key ${event.detail.fingerprint} verified.`);
|
|
1011
|
+
this.dispatchEvent(new CustomEvent("secure", { detail: event.detail }));
|
|
1012
|
+
if (event.detail.firstTime) void this.#authenticate();
|
|
1013
|
+
});
|
|
1014
|
+
this.transport.addEventListener("hostdisconnect", (event) => {
|
|
1015
|
+
this.lastMessage = event.detail.reason;
|
|
1016
|
+
});
|
|
1017
|
+
this.transport.addEventListener("error", (event) => {
|
|
1018
|
+
this.lastMessage = event.detail.message;
|
|
1019
|
+
this.dispatchEvent(new CustomEvent("error", { detail: event.detail }));
|
|
1020
|
+
});
|
|
1021
|
+
this.transport.addEventListener("close", (event) => {
|
|
1022
|
+
if (this.closed) return;
|
|
1023
|
+
this.closed = true;
|
|
1024
|
+
clearTimeout(this.powerShellTimer);
|
|
1025
|
+
this.#abandonWaiters(event.detail.message || "The SSH connection closed.");
|
|
1026
|
+
this.dispatchEvent(new CustomEvent("close", {
|
|
1027
|
+
detail: {
|
|
1028
|
+
...event.detail,
|
|
1029
|
+
message: event.detail.message || this.lastMessage || "",
|
|
1030
|
+
exitStatus: this.exitStatus
|
|
1031
|
+
}
|
|
1032
|
+
}));
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
connect() {
|
|
1036
|
+
this.#status("connecting", "Opening the SSH transport\u2026");
|
|
1037
|
+
this.transport.connect();
|
|
1038
|
+
}
|
|
1039
|
+
disconnect() {
|
|
1040
|
+
if (this.closed) return;
|
|
1041
|
+
clearTimeout(this.powerShellTimer);
|
|
1042
|
+
if (this.shellOpen && this.remoteChannelId !== null) {
|
|
1043
|
+
this.transport.send(new SshWriter(8).u8(MSG.CHANNEL_CLOSE).u32(this.remoteChannelId).take());
|
|
1044
|
+
}
|
|
1045
|
+
this.transport.disconnect("Closed by the user");
|
|
1046
|
+
}
|
|
1047
|
+
/** Send keystrokes, or anything else the terminal produces, to the shell. */
|
|
1048
|
+
write(data) {
|
|
1049
|
+
const bytes = typeof data === "string" ? encodeUtf8(data) : data;
|
|
1050
|
+
if (!bytes.length) return;
|
|
1051
|
+
this.pendingWrites.push(bytes);
|
|
1052
|
+
this.#flushWrites();
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Run one command in this shell and return what it printed.
|
|
1056
|
+
*
|
|
1057
|
+
* Deliberately the *same* shell the user is looking at, not a second channel:
|
|
1058
|
+
* a command the assistant runs inherits the working directory, the
|
|
1059
|
+
* environment and the login the user already has, and it appears in their
|
|
1060
|
+
* terminal as it happens. An agent acting on a machine invisibly is worse
|
|
1061
|
+
* than an agent that is slower to read.
|
|
1062
|
+
*
|
|
1063
|
+
* The output is framed by markers rather than measured, because an
|
|
1064
|
+
* interactive shell echoes its input and prints a prompt around it. The
|
|
1065
|
+
* markers are assembled by the shell from two pieces, so the echoed command
|
|
1066
|
+
* line -- which contains the pieces but not the joined marker -- never looks
|
|
1067
|
+
* like the real boundary.
|
|
1068
|
+
*
|
|
1069
|
+
* A `command` event brackets the run so the terminal can label it, and the
|
|
1070
|
+
* three deadlines above decide when a command is stuck rather than slow.
|
|
1071
|
+
*/
|
|
1072
|
+
runCommand(command, {
|
|
1073
|
+
startMs = COMMAND_START_MS,
|
|
1074
|
+
idleMs = COMMAND_IDLE_MS,
|
|
1075
|
+
maxMs = COMMAND_MAX_MS
|
|
1076
|
+
} = {}) {
|
|
1077
|
+
const text = String(command || "").trim();
|
|
1078
|
+
if (!text) return Promise.reject(new Error("No command was given."));
|
|
1079
|
+
if (!this.shellOpen || this.closed) {
|
|
1080
|
+
return Promise.reject(new Error("The SSH shell is not open."));
|
|
1081
|
+
}
|
|
1082
|
+
if (this.readySignaled === false) {
|
|
1083
|
+
return Promise.reject(new Error("The SSH shell is still starting."));
|
|
1084
|
+
}
|
|
1085
|
+
if (this.commandInFlight) {
|
|
1086
|
+
return Promise.reject(new Error("Another command is still running in this shell."));
|
|
1087
|
+
}
|
|
1088
|
+
const id = Array.from(crypto.getRandomValues(new Uint8Array(6))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1089
|
+
const begin = `CCB${id}`;
|
|
1090
|
+
const end = `CCE${id}`;
|
|
1091
|
+
const family = this.shellFamily || "posix";
|
|
1092
|
+
this.commandInFlight = true;
|
|
1093
|
+
const startedAt = Date.now();
|
|
1094
|
+
this.displayFilter = { begin, end, stage: "before", pending: "" };
|
|
1095
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
1096
|
+
detail: { phase: "start", id, command: text }
|
|
1097
|
+
}));
|
|
1098
|
+
return new Promise((resolve, reject) => {
|
|
1099
|
+
let transcript = "";
|
|
1100
|
+
let started = false;
|
|
1101
|
+
let startTimer = null;
|
|
1102
|
+
let idleTimer = null;
|
|
1103
|
+
let maxTimer = null;
|
|
1104
|
+
const finish = (settle) => {
|
|
1105
|
+
clearTimeout(startTimer);
|
|
1106
|
+
clearTimeout(idleTimer);
|
|
1107
|
+
clearTimeout(maxTimer);
|
|
1108
|
+
this.removeEventListener("data", onData);
|
|
1109
|
+
this.removeEventListener("close", onClose);
|
|
1110
|
+
this.commandInFlight = false;
|
|
1111
|
+
releaseDisplayFilter(this);
|
|
1112
|
+
settle();
|
|
1113
|
+
};
|
|
1114
|
+
const done = (result) => finish(() => {
|
|
1115
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
1116
|
+
detail: {
|
|
1117
|
+
phase: "end",
|
|
1118
|
+
id,
|
|
1119
|
+
command: text,
|
|
1120
|
+
exitStatus: result.exitStatus,
|
|
1121
|
+
timedOut: result.timedOut,
|
|
1122
|
+
started: result.started !== false,
|
|
1123
|
+
reason: result.reason,
|
|
1124
|
+
durationMs: result.durationMs
|
|
1125
|
+
}
|
|
1126
|
+
}));
|
|
1127
|
+
resolve(result);
|
|
1128
|
+
});
|
|
1129
|
+
const fail = (error) => finish(() => {
|
|
1130
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
1131
|
+
detail: {
|
|
1132
|
+
phase: "end",
|
|
1133
|
+
id,
|
|
1134
|
+
command: text,
|
|
1135
|
+
exitStatus: null,
|
|
1136
|
+
timedOut: false,
|
|
1137
|
+
started,
|
|
1138
|
+
durationMs: Date.now() - startedAt,
|
|
1139
|
+
error: error.message
|
|
1140
|
+
}
|
|
1141
|
+
}));
|
|
1142
|
+
reject(error);
|
|
1143
|
+
});
|
|
1144
|
+
const partialOutput = () => {
|
|
1145
|
+
const at = transcript.indexOf(begin);
|
|
1146
|
+
return at === -1 ? "" : stripTerminalCodes(transcript.slice(at + begin.length)).replace(/^\r?\n/, "").replace(/\s+$/, "");
|
|
1147
|
+
};
|
|
1148
|
+
const giveUp = (reason) => {
|
|
1149
|
+
this.write("");
|
|
1150
|
+
done({
|
|
1151
|
+
output: partialOutput(),
|
|
1152
|
+
exitStatus: null,
|
|
1153
|
+
timedOut: true,
|
|
1154
|
+
started,
|
|
1155
|
+
reason,
|
|
1156
|
+
durationMs: Date.now() - startedAt
|
|
1157
|
+
});
|
|
1158
|
+
};
|
|
1159
|
+
const onData = (event) => {
|
|
1160
|
+
transcript += decodeUtf8(event.detail.bytes);
|
|
1161
|
+
if (!started && transcript.indexOf(begin) !== -1) {
|
|
1162
|
+
started = true;
|
|
1163
|
+
clearTimeout(startTimer);
|
|
1164
|
+
startTimer = null;
|
|
1165
|
+
}
|
|
1166
|
+
if (started) {
|
|
1167
|
+
clearTimeout(idleTimer);
|
|
1168
|
+
idleTimer = setTimeout(() => giveUp("idle"), idleMs);
|
|
1169
|
+
}
|
|
1170
|
+
const endAt = transcript.indexOf(`${end} `);
|
|
1171
|
+
if (endAt === -1) {
|
|
1172
|
+
if (!started && FRAMING_REJECTED.test(stripTerminalCodes(transcript))) {
|
|
1173
|
+
const guessed = shellFromPrompt(stripTerminalCodes(transcript)) || "cmd";
|
|
1174
|
+
const wrongGuess = family !== guessed;
|
|
1175
|
+
this.shellFamily = guessed;
|
|
1176
|
+
fail(new Error(
|
|
1177
|
+
wrongGuess ? `This shell is ${guessed === "powershell" ? "PowerShell" : "Windows cmd"}, not a POSIX shell, and it rejected the wrapper used to capture output. The shell has been noted; run the command again and it will be framed correctly.` : `The shell rejected the wrapper used to capture output: ${stripTerminalCodes(transcript).split("\n").filter(Boolean).pop() || "no detail"}`
|
|
1178
|
+
));
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
const tail = transcript.slice(endAt);
|
|
1183
|
+
const status = parseInt(tail.slice(end.length + 1).trim(), 10);
|
|
1184
|
+
const beginAt = transcript.indexOf(begin);
|
|
1185
|
+
const body = beginAt === -1 ? transcript.slice(0, endAt) : transcript.slice(beginAt + begin.length, endAt);
|
|
1186
|
+
done({
|
|
1187
|
+
output: stripTerminalCodes(body).replace(/^\r?\n/, "").replace(/\s+$/, ""),
|
|
1188
|
+
exitStatus: Number.isInteger(status) ? status : null,
|
|
1189
|
+
timedOut: false,
|
|
1190
|
+
started: true,
|
|
1191
|
+
durationMs: Date.now() - startedAt
|
|
1192
|
+
});
|
|
1193
|
+
};
|
|
1194
|
+
const onClose = () => fail(new Error("The SSH session closed while the command was running."));
|
|
1195
|
+
this.addEventListener("data", onData);
|
|
1196
|
+
this.addEventListener("close", onClose);
|
|
1197
|
+
startTimer = setTimeout(() => giveUp("start"), startMs);
|
|
1198
|
+
idleTimer = setTimeout(() => giveUp("idle"), idleMs);
|
|
1199
|
+
maxTimer = setTimeout(() => giveUp("max"), maxMs);
|
|
1200
|
+
this.write(frameCommand(family, text, id));
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
/** Tell the shell its window changed, so full-screen programs redraw. */
|
|
1204
|
+
resize(columns, rows) {
|
|
1205
|
+
const nextColumns = Math.max(1, Math.floor(columns));
|
|
1206
|
+
const nextRows = Math.max(1, Math.floor(rows));
|
|
1207
|
+
if (nextColumns === this.columns && nextRows === this.rows) return;
|
|
1208
|
+
this.columns = nextColumns;
|
|
1209
|
+
this.rows = nextRows;
|
|
1210
|
+
if (!this.shellOpen || this.remoteChannelId === null) return;
|
|
1211
|
+
this.transport.send(new SshWriter(64).u8(MSG.CHANNEL_REQUEST).u32(this.remoteChannelId).string("window-change").boolean(false).u32(nextColumns).u32(nextRows).u32(0).u32(0).take());
|
|
1212
|
+
}
|
|
1213
|
+
// --- plumbing ------------------------------------------------------------
|
|
1214
|
+
#status(phase, message) {
|
|
1215
|
+
this.dispatchEvent(new CustomEvent("status", { detail: { phase, message } }));
|
|
1216
|
+
}
|
|
1217
|
+
#emitBanner(text) {
|
|
1218
|
+
if (text) this.dispatchEvent(new CustomEvent("banner", { detail: { text } }));
|
|
1219
|
+
}
|
|
1220
|
+
/** Wait for one of a few message types: the handshakes read linearly that way. */
|
|
1221
|
+
#expect(...types) {
|
|
1222
|
+
return new Promise((resolve, reject) => {
|
|
1223
|
+
for (const type of types) this.waiters.set(type, { resolve, reject });
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
#settle(type, payload) {
|
|
1227
|
+
const waiter = this.waiters.get(type);
|
|
1228
|
+
if (!waiter) return false;
|
|
1229
|
+
this.waiters.clear();
|
|
1230
|
+
waiter.resolve({ type, payload });
|
|
1231
|
+
return true;
|
|
1232
|
+
}
|
|
1233
|
+
/** A closed transport must not leave a handshake waiting forever. */
|
|
1234
|
+
#abandonWaiters(message) {
|
|
1235
|
+
if (this.waiters.size === 0) return;
|
|
1236
|
+
const pending = [...new Set(this.waiters.values())];
|
|
1237
|
+
this.waiters.clear();
|
|
1238
|
+
for (const waiter of pending) waiter.reject(new Error(message));
|
|
1239
|
+
}
|
|
1240
|
+
#handlePacket(type, payload) {
|
|
1241
|
+
if (this.#settle(type, payload)) return;
|
|
1242
|
+
switch (type) {
|
|
1243
|
+
case MSG.USERAUTH_BANNER:
|
|
1244
|
+
this.#emitBanner(new SshReader(payload, 1).string().replaceAll("\n", "\r\n"));
|
|
1245
|
+
return;
|
|
1246
|
+
case MSG.CHANNEL_DATA: {
|
|
1247
|
+
const reader = new SshReader(payload, 1);
|
|
1248
|
+
reader.u32();
|
|
1249
|
+
this.#consume(reader.stringBytes());
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
case MSG.CHANNEL_EXTENDED_DATA: {
|
|
1253
|
+
const reader = new SshReader(payload, 1);
|
|
1254
|
+
reader.u32();
|
|
1255
|
+
const dataType = reader.u32();
|
|
1256
|
+
const bytes = reader.stringBytes();
|
|
1257
|
+
this.#consume(bytes, dataType === EXTENDED_DATA_STDERR);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
case MSG.CHANNEL_WINDOW_ADJUST: {
|
|
1261
|
+
const reader = new SshReader(payload, 1);
|
|
1262
|
+
reader.u32();
|
|
1263
|
+
this.remoteWindow += reader.u32();
|
|
1264
|
+
this.#flushWrites();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
case MSG.CHANNEL_REQUEST: {
|
|
1268
|
+
const reader = new SshReader(payload, 1);
|
|
1269
|
+
reader.u32();
|
|
1270
|
+
const request = reader.string();
|
|
1271
|
+
const wantReply = reader.boolean();
|
|
1272
|
+
if (request === "exit-status") this.exitStatus = reader.u32();
|
|
1273
|
+
if (request === "exit-signal") this.exitSignal = reader.string();
|
|
1274
|
+
if (wantReply && this.remoteChannelId !== null) {
|
|
1275
|
+
this.transport.send(new SshWriter(8).u8(MSG.CHANNEL_SUCCESS).u32(this.remoteChannelId).take());
|
|
1276
|
+
}
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
case MSG.CHANNEL_EOF:
|
|
1280
|
+
return;
|
|
1281
|
+
case MSG.CHANNEL_CLOSE:
|
|
1282
|
+
this.shellOpen = false;
|
|
1283
|
+
this.#status("closed", "The remote shell ended.");
|
|
1284
|
+
this.transport.disconnect("Shell closed");
|
|
1285
|
+
return;
|
|
1286
|
+
default:
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Watch the prompt for which shell this machine answers with, so the first
|
|
1292
|
+
* command is framed correctly rather than discovering it by failing.
|
|
1293
|
+
*/
|
|
1294
|
+
#noteShellFamily(bytes) {
|
|
1295
|
+
this.promptTail = (this.promptTail + stripTerminalCodes(decodeUtf8(bytes))).slice(-400);
|
|
1296
|
+
const family = shellFromPrompt(this.promptTail);
|
|
1297
|
+
if (!family) return;
|
|
1298
|
+
if (family === "cmd" && !this.powerShellAttempted) {
|
|
1299
|
+
this.#startPowerShell();
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (family === "cmd" && this.powerShellAttempted && !this.readySignaled) return;
|
|
1303
|
+
this.shellFamily = family;
|
|
1304
|
+
if (family === "powershell" && !this.readySignaled) {
|
|
1305
|
+
this.#markReady("Connected \xB7 PowerShell");
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/** Replace Windows OpenSSH's usual cmd.exe with an interactive PowerShell. */
|
|
1309
|
+
#startPowerShell() {
|
|
1310
|
+
if (this.powerShellAttempted || !this.shellOpen) return;
|
|
1311
|
+
this.powerShellAttempted = true;
|
|
1312
|
+
this.readySignaled = false;
|
|
1313
|
+
this.shellFamily = "";
|
|
1314
|
+
this.#status("opening", "Starting PowerShell\u2026");
|
|
1315
|
+
this.write("powershell.exe -NoLogo\r");
|
|
1316
|
+
this.powerShellTimer = setTimeout(() => {
|
|
1317
|
+
if (this.readySignaled || this.closed) return;
|
|
1318
|
+
this.shellFamily = "powershell";
|
|
1319
|
+
this.#markReady("Connected \xB7 PowerShell");
|
|
1320
|
+
}, 2e3);
|
|
1321
|
+
}
|
|
1322
|
+
#markReady(message = "Connected.") {
|
|
1323
|
+
clearTimeout(this.powerShellTimer);
|
|
1324
|
+
this.powerShellTimer = null;
|
|
1325
|
+
this.readySignaled = true;
|
|
1326
|
+
this.#status("ready", message);
|
|
1327
|
+
this.dispatchEvent(new CustomEvent("ready", {
|
|
1328
|
+
detail: { fingerprint: this.fingerprint, shellFamily: this.shellFamily || "posix" }
|
|
1329
|
+
}));
|
|
1330
|
+
}
|
|
1331
|
+
/** Shell output, with the receive window topped up before it runs dry. */
|
|
1332
|
+
#consume(bytes, isStderr = false) {
|
|
1333
|
+
if (!bytes.length) return;
|
|
1334
|
+
if (!this.subsystem) this.#noteShellFamily(bytes);
|
|
1335
|
+
this.dispatchEvent(new CustomEvent("data", {
|
|
1336
|
+
detail: { bytes, display: filterDisplay(this, bytes), isStderr }
|
|
1337
|
+
}));
|
|
1338
|
+
this.localWindow -= bytes.length;
|
|
1339
|
+
if (this.localWindow > WINDOW_REFILL_THRESHOLD || this.remoteChannelId === null) return;
|
|
1340
|
+
const increment = INITIAL_WINDOW - this.localWindow;
|
|
1341
|
+
this.localWindow = INITIAL_WINDOW;
|
|
1342
|
+
this.transport.send(new SshWriter(16).u8(MSG.CHANNEL_WINDOW_ADJUST).u32(this.remoteChannelId).u32(increment).take());
|
|
1343
|
+
}
|
|
1344
|
+
#flushWrites() {
|
|
1345
|
+
if (!this.shellOpen || this.remoteChannelId === null) return;
|
|
1346
|
+
while (this.pendingWrites.length > 0) {
|
|
1347
|
+
const next = this.pendingWrites[0];
|
|
1348
|
+
const room = Math.min(this.remoteWindow, this.remoteMaxPacket - 64);
|
|
1349
|
+
if (room <= 0) return;
|
|
1350
|
+
const chunk = next.length <= room ? next : next.subarray(0, room);
|
|
1351
|
+
if (chunk.length === next.length) this.pendingWrites.shift();
|
|
1352
|
+
else this.pendingWrites[0] = next.subarray(chunk.length);
|
|
1353
|
+
this.remoteWindow -= chunk.length;
|
|
1354
|
+
this.transport.send(new SshWriter(chunk.length + 16).u8(MSG.CHANNEL_DATA).u32(this.remoteChannelId).string(chunk).take());
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// --- authentication ------------------------------------------------------
|
|
1358
|
+
async #authenticate() {
|
|
1359
|
+
try {
|
|
1360
|
+
this.#status("authenticating", `Authenticating as ${this.username}\u2026`);
|
|
1361
|
+
this.transport.send(new SshWriter(32).u8(MSG.SERVICE_REQUEST).string("ssh-userauth").take());
|
|
1362
|
+
await this.#expect(MSG.SERVICE_ACCEPT);
|
|
1363
|
+
if (await this.#tryNone()) return void await this.#openShell();
|
|
1364
|
+
if (this.authMethods.includes("publickey")) {
|
|
1365
|
+
for (const identity of await this.#identities()) {
|
|
1366
|
+
if (await this.#tryPublicKey(identity)) return void await this.#openShell();
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (this.authMethods.includes("password") && this.password) {
|
|
1370
|
+
if (await this.#tryPassword(this.password)) return void await this.#openShell();
|
|
1371
|
+
this.passwordRejected = true;
|
|
1372
|
+
}
|
|
1373
|
+
if (this.authMethods.includes("keyboard-interactive")) {
|
|
1374
|
+
if (await this.#tryKeyboardInteractive()) return void await this.#openShell();
|
|
1375
|
+
if (this.canAsk && this.authMethods.includes("keyboard-interactive")) {
|
|
1376
|
+
if (await this.#tryKeyboardInteractive({ usePassword: false })) {
|
|
1377
|
+
return void await this.#openShell();
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (this.canAsk && this.authMethods.includes("password")) {
|
|
1382
|
+
const typed = await this.requestInput({
|
|
1383
|
+
prompt: `${this.username}@${this.options.hostLabel || "host"}'s password: `,
|
|
1384
|
+
echo: false
|
|
1385
|
+
});
|
|
1386
|
+
if (typed && await this.#tryPassword(typed)) return void await this.#openShell();
|
|
1387
|
+
}
|
|
1388
|
+
throw new Error(this.authMethods.length ? `SSH: authentication failed${this.triedKeys.length ? ` (the host refused ${this.triedKeys.join(", ")})` : ""}. The host accepts ${this.authMethods.join(", ")}.` : "SSH: the host rejected the connection before any authentication method was offered.");
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
if (!this.closed) this.transport.fail(error);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
async #tryNone() {
|
|
1394
|
+
this.transport.send(new SshWriter(64).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("none").take());
|
|
1395
|
+
return this.#authResult();
|
|
1396
|
+
}
|
|
1397
|
+
async #tryPassword(password) {
|
|
1398
|
+
this.transport.send(new SshWriter(128).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("password").boolean(false).string(password).take());
|
|
1399
|
+
return this.#authResult();
|
|
1400
|
+
}
|
|
1401
|
+
/** The keys to offer, resolved once and remembered. */
|
|
1402
|
+
async #identities() {
|
|
1403
|
+
const source = this.identities;
|
|
1404
|
+
const list = typeof source === "function" ? await source() : source;
|
|
1405
|
+
this.identities = list;
|
|
1406
|
+
return list;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Sign in with a key.
|
|
1410
|
+
*
|
|
1411
|
+
* Two round trips rather than one, and deliberately: the first offers the key
|
|
1412
|
+
* with no signature, which asks the host whether that key under that
|
|
1413
|
+
* signature algorithm would be accepted at all. A host that says no has not
|
|
1414
|
+
* counted a failed attempt, which matters because an RSA key can be signed
|
|
1415
|
+
* three different ways and a host that allows six attempts would otherwise
|
|
1416
|
+
* spend half of them on the client guessing which digest it wants.
|
|
1417
|
+
*
|
|
1418
|
+
* What is signed is the whole request with the session identifier in front,
|
|
1419
|
+
* so a signature cannot be lifted from one connection into another.
|
|
1420
|
+
*/
|
|
1421
|
+
async #tryPublicKey(identity) {
|
|
1422
|
+
const sessionId = this.transport.sessionId;
|
|
1423
|
+
if (!sessionId) throw new Error("SSH: no session identifier to sign against.");
|
|
1424
|
+
for (const algorithm of identity.algorithms) {
|
|
1425
|
+
this.#status("authenticating", `Offering ${identity.comment || identity.keyType}\u2026`);
|
|
1426
|
+
this.transport.send(new SshWriter(identity.keyBlob.length + 128).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("publickey").boolean(false).string(algorithm).string(identity.keyBlob).take());
|
|
1427
|
+
const offer = await this.#expect(
|
|
1428
|
+
MSG.USERAUTH_PK_OK,
|
|
1429
|
+
MSG.USERAUTH_FAILURE,
|
|
1430
|
+
MSG.USERAUTH_SUCCESS
|
|
1431
|
+
);
|
|
1432
|
+
if (offer.type === MSG.USERAUTH_SUCCESS) return true;
|
|
1433
|
+
if (offer.type === MSG.USERAUTH_FAILURE) {
|
|
1434
|
+
this.#readFailure(offer.payload);
|
|
1435
|
+
if (!this.authMethods.includes("publickey")) return false;
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
const request = new SshWriter(identity.keyBlob.length + 128).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("publickey").boolean(true).string(algorithm).string(identity.keyBlob).take();
|
|
1439
|
+
const signed = await identity.sign(
|
|
1440
|
+
new SshWriter(request.length + sessionId.length + 8).string(sessionId).raw(request).take(),
|
|
1441
|
+
algorithm
|
|
1442
|
+
);
|
|
1443
|
+
this.transport.send(new SshWriter(request.length + signed.length + 8).raw(request).string(signed).take());
|
|
1444
|
+
if (await this.#authResult()) return true;
|
|
1445
|
+
this.triedKeys.push(identity.comment || identity.keyType);
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
this.triedKeys.push(identity.comment || identity.keyType);
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* keyboard-interactive is how most Linux hosts actually ask for a password,
|
|
1453
|
+
* and the only way a one-time code can be entered. Prompts are answered from
|
|
1454
|
+
* the password when one is obviously being asked for, and by the user
|
|
1455
|
+
* otherwise.
|
|
1456
|
+
*/
|
|
1457
|
+
async #tryKeyboardInteractive({ usePassword = true } = {}) {
|
|
1458
|
+
if (this.passwordRejected) usePassword = false;
|
|
1459
|
+
this.transport.send(new SshWriter(96).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("keyboard-interactive").string("").string("").take());
|
|
1460
|
+
let usedPassword = !usePassword;
|
|
1461
|
+
let rounds = 0;
|
|
1462
|
+
while (true) {
|
|
1463
|
+
const { type, payload } = await this.#expect(
|
|
1464
|
+
MSG.USERAUTH_SUCCESS,
|
|
1465
|
+
MSG.USERAUTH_FAILURE,
|
|
1466
|
+
MSG.USERAUTH_INFO_REQUEST
|
|
1467
|
+
);
|
|
1468
|
+
if (type === MSG.USERAUTH_SUCCESS) return true;
|
|
1469
|
+
if (type === MSG.USERAUTH_FAILURE) {
|
|
1470
|
+
this.#readFailure(payload);
|
|
1471
|
+
return false;
|
|
1472
|
+
}
|
|
1473
|
+
const reader = new SshReader(payload, 1);
|
|
1474
|
+
const name = reader.string();
|
|
1475
|
+
const instruction = reader.string();
|
|
1476
|
+
reader.string();
|
|
1477
|
+
const promptCount = reader.u32();
|
|
1478
|
+
if (name) this.#emitBanner(`${name.replaceAll("\n", "\r\n")}\r
|
|
1479
|
+
`);
|
|
1480
|
+
if (instruction) this.#emitBanner(`${instruction.replaceAll("\n", "\r\n")}\r
|
|
1481
|
+
`);
|
|
1482
|
+
if (++rounds > 3) return false;
|
|
1483
|
+
const answers = [];
|
|
1484
|
+
let real = false;
|
|
1485
|
+
for (let index = 0; index < promptCount; index++) {
|
|
1486
|
+
const prompt = reader.string();
|
|
1487
|
+
const echo = reader.boolean();
|
|
1488
|
+
const looksLikePassword = !echo && /pass(word|phrase)/i.test(prompt);
|
|
1489
|
+
if (looksLikePassword && this.password && !usedPassword) {
|
|
1490
|
+
usedPassword = true;
|
|
1491
|
+
real = true;
|
|
1492
|
+
answers.push(this.password);
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const typed = this.canAsk ? await this.requestInput({ prompt, echo }) : "";
|
|
1496
|
+
if (typed) real = true;
|
|
1497
|
+
answers.push(typed);
|
|
1498
|
+
}
|
|
1499
|
+
if (promptCount > 0 && !real) return false;
|
|
1500
|
+
const response = new SshWriter(256).u8(MSG.USERAUTH_INFO_RESPONSE).u32(answers.length);
|
|
1501
|
+
for (const answer of answers) response.string(answer);
|
|
1502
|
+
this.transport.send(response.take());
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
async #authResult() {
|
|
1506
|
+
const { type, payload } = await this.#expect(MSG.USERAUTH_SUCCESS, MSG.USERAUTH_FAILURE);
|
|
1507
|
+
if (type === MSG.USERAUTH_SUCCESS) return true;
|
|
1508
|
+
this.#readFailure(payload);
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
#readFailure(payload) {
|
|
1512
|
+
const reader = new SshReader(payload, 1);
|
|
1513
|
+
this.authMethods = reader.nameList();
|
|
1514
|
+
const partial = reader.boolean();
|
|
1515
|
+
if (partial) this.#emitBanner("\r\nOne factor accepted; the host wants another.\r\n");
|
|
1516
|
+
}
|
|
1517
|
+
// --- the channel ---------------------------------------------------------
|
|
1518
|
+
async #openShell() {
|
|
1519
|
+
this.#status("opening", this.subsystem ? `Starting the ${this.subsystem} subsystem\u2026` : "Starting the remote shell\u2026");
|
|
1520
|
+
this.transport.send(new SshWriter(64).u8(MSG.CHANNEL_OPEN).string("session").u32(this.channelId).u32(INITIAL_WINDOW).u32(MAX_PACKET).take());
|
|
1521
|
+
const opened = await this.#expect(MSG.CHANNEL_OPEN_CONFIRMATION, MSG.CHANNEL_OPEN_FAILURE);
|
|
1522
|
+
if (opened.type === MSG.CHANNEL_OPEN_FAILURE) {
|
|
1523
|
+
const reader2 = new SshReader(opened.payload, 1);
|
|
1524
|
+
reader2.u32();
|
|
1525
|
+
reader2.u32();
|
|
1526
|
+
throw new Error(`SSH: the host refused to open a session channel (${reader2.string()}).`);
|
|
1527
|
+
}
|
|
1528
|
+
const reader = new SshReader(opened.payload, 1);
|
|
1529
|
+
reader.u32();
|
|
1530
|
+
this.remoteChannelId = reader.u32();
|
|
1531
|
+
this.remoteWindow = reader.u32();
|
|
1532
|
+
this.remoteMaxPacket = Math.max(1024, Math.min(reader.u32(), MAX_PACKET));
|
|
1533
|
+
if (this.subsystem) {
|
|
1534
|
+
this.transport.send(new SshWriter(64).u8(MSG.CHANNEL_REQUEST).u32(this.remoteChannelId).string("subsystem").boolean(true).string(this.subsystem).take());
|
|
1535
|
+
const started = await this.#expect(MSG.CHANNEL_SUCCESS, MSG.CHANNEL_FAILURE);
|
|
1536
|
+
if (started.type === MSG.CHANNEL_FAILURE) {
|
|
1537
|
+
throw new Error(`SSH: the host has no ${this.subsystem} subsystem (on OpenSSH that is the \`Subsystem ${this.subsystem}\` line in sshd_config).`);
|
|
1538
|
+
}
|
|
1539
|
+
this.shellOpen = true;
|
|
1540
|
+
this.#flushWrites();
|
|
1541
|
+
this.#markReady(`Connected \xB7 ${this.subsystem}`);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
this.transport.send(new SshWriter(128).u8(MSG.CHANNEL_REQUEST).u32(this.remoteChannelId).string("pty-req").boolean(true).string(TERMINAL_TYPE).u32(this.columns).u32(this.rows).u32(0).u32(0).string(PTY_MODES).take());
|
|
1545
|
+
const pty = await this.#expect(MSG.CHANNEL_SUCCESS, MSG.CHANNEL_FAILURE);
|
|
1546
|
+
if (pty.type === MSG.CHANNEL_FAILURE) {
|
|
1547
|
+
throw new Error("SSH: the host refused a pseudo-terminal, so there is no interactive shell to show.");
|
|
1548
|
+
}
|
|
1549
|
+
this.transport.send(new SshWriter(32).u8(MSG.CHANNEL_REQUEST).u32(this.remoteChannelId).string("shell").boolean(true).take());
|
|
1550
|
+
const shell = await this.#expect(MSG.CHANNEL_SUCCESS, MSG.CHANNEL_FAILURE);
|
|
1551
|
+
if (shell.type === MSG.CHANNEL_FAILURE) {
|
|
1552
|
+
throw new Error("SSH: the host refused to start a shell for this account.");
|
|
1553
|
+
}
|
|
1554
|
+
this.shellOpen = true;
|
|
1555
|
+
this.#flushWrites();
|
|
1556
|
+
if (this.options.preferredShell === "powershell") this.#startPowerShell();
|
|
1557
|
+
else this.#markReady();
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
export {
|
|
1562
|
+
GATEWAY_READY_SIGNAL,
|
|
1563
|
+
GATEWAY_KEEPALIVE_SIGNAL,
|
|
1564
|
+
KEEPALIVE_INTERVAL_MS,
|
|
1565
|
+
TcpTransport,
|
|
1566
|
+
SshSession
|
|
1567
|
+
};
|