ai-remote 0.1.0
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 +50 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +7 -0
- package/dist/protocols/index.js +35 -0
- package/dist/protocols/index.js.map +7 -0
- package/dist/protocols/rdp/buffer.js +170 -0
- package/dist/protocols/rdp/buffer.js.map +7 -0
- package/dist/protocols/rdp/caps.js +260 -0
- package/dist/protocols/rdp/caps.js.map +7 -0
- package/dist/protocols/rdp/cert.js +135 -0
- package/dist/protocols/rdp/cert.js.map +7 -0
- package/dist/protocols/rdp/client.js +1068 -0
- package/dist/protocols/rdp/client.js.map +7 -0
- package/dist/protocols/rdp/cliprdr.js +240 -0
- package/dist/protocols/rdp/cliprdr.js.map +7 -0
- package/dist/protocols/rdp/credssp.js +254 -0
- package/dist/protocols/rdp/credssp.js.map +7 -0
- package/dist/protocols/rdp/crypto.js +369 -0
- package/dist/protocols/rdp/crypto.js.map +7 -0
- package/dist/protocols/rdp/display.js +589 -0
- package/dist/protocols/rdp/display.js.map +7 -0
- package/dist/protocols/rdp/gcc.js +132 -0
- package/dist/protocols/rdp/gcc.js.map +7 -0
- package/dist/protocols/rdp/keymap.js +132 -0
- package/dist/protocols/rdp/keymap.js.map +7 -0
- package/dist/protocols/rdp/mcs.js +223 -0
- package/dist/protocols/rdp/mcs.js.map +7 -0
- package/dist/protocols/rdp/ntlm.js +305 -0
- package/dist/protocols/rdp/ntlm.js.map +7 -0
- package/dist/protocols/rdp/pdu.js +444 -0
- package/dist/protocols/rdp/pdu.js.map +7 -0
- package/dist/protocols/rdp/rail.js +383 -0
- package/dist/protocols/rdp/rail.js.map +7 -0
- package/dist/protocols/rdp/rle.js +276 -0
- package/dist/protocols/rdp/rle.js.map +7 -0
- package/dist/protocols/rdp/sec.js +156 -0
- package/dist/protocols/rdp/sec.js.map +7 -0
- package/dist/protocols/rdp/session.js +307 -0
- package/dist/protocols/rdp/session.js.map +7 -0
- package/dist/protocols/rdp/tls.js +354 -0
- package/dist/protocols/rdp/tls.js.map +7 -0
- package/dist/protocols/rdp/vchannel.js +59 -0
- package/dist/protocols/rdp/vchannel.js.map +7 -0
- package/dist/protocols/rdp/x224.js +110 -0
- package/dist/protocols/rdp/x224.js.map +7 -0
- package/dist/protocols/ssh/kex.js +177 -0
- package/dist/protocols/ssh/kex.js.map +7 -0
- package/dist/protocols/ssh/messages.js +55 -0
- package/dist/protocols/ssh/messages.js.map +7 -0
- package/dist/protocols/ssh/session.js +608 -0
- package/dist/protocols/ssh/session.js.map +7 -0
- package/dist/protocols/ssh/terminal.js +191 -0
- package/dist/protocols/ssh/terminal.js.map +7 -0
- package/dist/protocols/ssh/transport.js +611 -0
- package/dist/protocols/ssh/transport.js.map +7 -0
- package/dist/protocols/ssh/wire.js +189 -0
- package/dist/protocols/ssh/wire.js.map +7 -0
- package/dist/protocols/types.js +1 -0
- package/dist/protocols/types.js.map +7 -0
- package/dist/protocols/vnc/input.js +16 -0
- package/dist/protocols/vnc/input.js.map +7 -0
- package/dist/protocols/vnc/session.js +259 -0
- package/dist/protocols/vnc/session.js.map +7 -0
- package/dist/shared/connection.js +114 -0
- package/dist/shared/connection.js.map +7 -0
- package/dist/shared/device.js +1 -0
- package/dist/shared/device.js.map +7 -0
- package/dist/shared/hosts.js +133 -0
- package/dist/shared/hosts.js.map +7 -0
- package/dist/shared/icons.js +42 -0
- package/dist/shared/icons.js.map +7 -0
- package/dist/shared/protocol.js +40 -0
- package/dist/shared/protocol.js.map +7 -0
- package/dist/shared/signals.js +42 -0
- package/dist/shared/signals.js.map +7 -0
- package/package.json +82 -0
- package/src/index.ts +14 -0
- package/src/protocols/index.ts +72 -0
- package/src/protocols/rdp/buffer.ts +204 -0
- package/src/protocols/rdp/caps.ts +304 -0
- package/src/protocols/rdp/cert.ts +190 -0
- package/src/protocols/rdp/client.ts +1363 -0
- package/src/protocols/rdp/cliprdr.ts +344 -0
- package/src/protocols/rdp/credssp.ts +319 -0
- package/src/protocols/rdp/crypto.ts +416 -0
- package/src/protocols/rdp/display.ts +750 -0
- package/src/protocols/rdp/gcc.ts +162 -0
- package/src/protocols/rdp/keymap.ts +68 -0
- package/src/protocols/rdp/mcs.ts +283 -0
- package/src/protocols/rdp/ntlm.ts +380 -0
- package/src/protocols/rdp/pdu.ts +497 -0
- package/src/protocols/rdp/rail.ts +480 -0
- package/src/protocols/rdp/rle.ts +343 -0
- package/src/protocols/rdp/sec.ts +184 -0
- package/src/protocols/rdp/session.ts +393 -0
- package/src/protocols/rdp/tls.ts +452 -0
- package/src/protocols/rdp/vchannel.ts +89 -0
- package/src/protocols/rdp/x224.ts +139 -0
- package/src/protocols/ssh/kex.ts +227 -0
- package/src/protocols/ssh/messages.ts +54 -0
- package/src/protocols/ssh/session.ts +878 -0
- package/src/protocols/ssh/terminal.ts +242 -0
- package/src/protocols/ssh/transport.ts +732 -0
- package/src/protocols/ssh/wire.ts +220 -0
- package/src/protocols/types.ts +146 -0
- package/src/protocols/vnc/input.ts +13 -0
- package/src/protocols/vnc/novnc.d.ts +75 -0
- package/src/protocols/vnc/session.ts +367 -0
- package/src/shared/connection.ts +206 -0
- package/src/shared/device.ts +28 -0
- package/src/shared/hosts.ts +240 -0
- package/src/shared/icons.ts +49 -0
- package/src/shared/protocol.ts +55 -0
- package/src/shared/signals.ts +67 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { MSG, EXTENDED_DATA_STDERR } from "./messages";
|
|
2
|
+
import { SshReader, SshWriter, decodeUtf8, encodeUtf8, toBase64 } from "./wire";
|
|
3
|
+
import { SshTransport } from "./transport";
|
|
4
|
+
const DEFAULT_PORT = 22;
|
|
5
|
+
const SERVICE = "ssh-connection";
|
|
6
|
+
const TERMINAL_TYPE = "xterm-256color";
|
|
7
|
+
const INITIAL_WINDOW = 2 * 1024 * 1024;
|
|
8
|
+
const MAX_PACKET = 32 * 1024;
|
|
9
|
+
const WINDOW_REFILL_THRESHOLD = INITIAL_WINDOW / 2;
|
|
10
|
+
const PTY_MODES = new Uint8Array([0]);
|
|
11
|
+
const COMMAND_START_MS = 2e4;
|
|
12
|
+
const COMMAND_IDLE_MS = 5 * 6e4;
|
|
13
|
+
const COMMAND_MAX_MS = 60 * 6e4;
|
|
14
|
+
const NO_BYTES = new Uint8Array(0);
|
|
15
|
+
function singleQuote(value) {
|
|
16
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
17
|
+
}
|
|
18
|
+
function shellFromPrompt(text) {
|
|
19
|
+
const tail = text.slice(-400);
|
|
20
|
+
if (/(?:^|\n)PS [A-Za-z]:\\[^\n]*>\s*$/.test(tail)) return "powershell";
|
|
21
|
+
if (/(?:^|\n)[^\n]*[A-Za-z]:\\[^\n]*>\s*$/.test(tail)) return "cmd";
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
const FRAMING_REJECTED = /is not recognized as an internal or external command|command not found|not recognized as the name of a cmdlet|CommandNotFoundException/i;
|
|
25
|
+
function frameCommand(family, command, id) {
|
|
26
|
+
if (family === "cmd") {
|
|
27
|
+
return `echo.&echo CCB${id}&${command}&call echo.CCE${id} %^ERRORLEVEL%\r`;
|
|
28
|
+
}
|
|
29
|
+
if (family === "powershell") {
|
|
30
|
+
const encoded = toBase64(encodeUtf8(command));
|
|
31
|
+
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`;
|
|
32
|
+
}
|
|
33
|
+
return `printf '\\n%s%s\\n' 'CCB' '${id}'; eval ${singleQuote(command)}; __cc=$?; printf '\\n%s%s %s\\n' 'CCE' '${id}' "$__cc"\r`;
|
|
34
|
+
}
|
|
35
|
+
function stripTerminalCodes(text) {
|
|
36
|
+
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, "");
|
|
37
|
+
}
|
|
38
|
+
function filterDisplay(session, bytes) {
|
|
39
|
+
const filter = session.displayFilter;
|
|
40
|
+
if (!filter) return bytes;
|
|
41
|
+
filter.pending += decodeUtf8(bytes);
|
|
42
|
+
let shown = "";
|
|
43
|
+
if (filter.stage === "before") {
|
|
44
|
+
const at = filter.pending.indexOf(filter.begin);
|
|
45
|
+
if (at === -1) return NO_BYTES;
|
|
46
|
+
filter.pending = filter.pending.slice(at + filter.begin.length).replace(/^\r?\n/, "");
|
|
47
|
+
filter.stage = "inside";
|
|
48
|
+
}
|
|
49
|
+
if (filter.stage === "inside") {
|
|
50
|
+
const at = filter.pending.indexOf(filter.end);
|
|
51
|
+
if (at === -1) {
|
|
52
|
+
shown = filter.pending;
|
|
53
|
+
filter.pending = "";
|
|
54
|
+
return shown ? encodeUtf8(shown) : NO_BYTES;
|
|
55
|
+
}
|
|
56
|
+
shown = filter.pending.slice(0, at);
|
|
57
|
+
filter.pending = filter.pending.slice(at);
|
|
58
|
+
filter.stage = "closing";
|
|
59
|
+
}
|
|
60
|
+
const newline = filter.pending.indexOf("\n");
|
|
61
|
+
if (newline !== -1) {
|
|
62
|
+
shown += filter.pending.slice(newline + 1);
|
|
63
|
+
session.displayFilter = null;
|
|
64
|
+
}
|
|
65
|
+
return shown ? encodeUtf8(shown) : NO_BYTES;
|
|
66
|
+
}
|
|
67
|
+
function releaseDisplayFilter(session) {
|
|
68
|
+
const filter = session.displayFilter;
|
|
69
|
+
session.displayFilter = null;
|
|
70
|
+
if (!filter || filter.stage !== "before" || !filter.pending) return;
|
|
71
|
+
session.dispatchEvent(new CustomEvent("data", {
|
|
72
|
+
detail: { bytes: NO_BYTES, display: encodeUtf8(filter.pending), isStderr: false }
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
class SshSession extends EventTarget {
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} url gateway WebSocket URL
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {string} options.username
|
|
80
|
+
* @param {string} [options.password]
|
|
81
|
+
* @param {'powershell'} [options.preferredShell]
|
|
82
|
+
* @param {(info: object) => Promise<boolean>} [options.verifyHost]
|
|
83
|
+
* @param {(prompt: object) => Promise<string>} [options.requestInput]
|
|
84
|
+
*/
|
|
85
|
+
constructor(url, options = {}) {
|
|
86
|
+
super();
|
|
87
|
+
this.options = options;
|
|
88
|
+
this.username = options.username || "";
|
|
89
|
+
this.password = options.password || "";
|
|
90
|
+
this.requestInput = options.requestInput || (async () => "");
|
|
91
|
+
this.columns = options.columns || 80;
|
|
92
|
+
this.rows = options.rows || 24;
|
|
93
|
+
this.channelId = 0;
|
|
94
|
+
this.remoteChannelId = null;
|
|
95
|
+
this.remoteWindow = 0;
|
|
96
|
+
this.remoteMaxPacket = MAX_PACKET;
|
|
97
|
+
this.localWindow = INITIAL_WINDOW;
|
|
98
|
+
this.pendingWrites = [];
|
|
99
|
+
this.shellOpen = false;
|
|
100
|
+
this.commandInFlight = false;
|
|
101
|
+
this.displayFilter = null;
|
|
102
|
+
this.readySignaled = false;
|
|
103
|
+
this.powerShellAttempted = false;
|
|
104
|
+
this.powerShellTimer = null;
|
|
105
|
+
this.shellFamily = "";
|
|
106
|
+
this.promptTail = "";
|
|
107
|
+
this.exitStatus = null;
|
|
108
|
+
this.authMethods = [];
|
|
109
|
+
this.closed = false;
|
|
110
|
+
this.waiters = /* @__PURE__ */ new Map();
|
|
111
|
+
this.transport = new SshTransport(url, {
|
|
112
|
+
verifyHost: options.verifyHost || (async () => true),
|
|
113
|
+
log: (step, detail) => console.info(`[SSH] ${step}`, detail)
|
|
114
|
+
});
|
|
115
|
+
this.transport.addEventListener("packet", (event) => {
|
|
116
|
+
this.#handlePacket(event.detail.type, event.detail.payload);
|
|
117
|
+
});
|
|
118
|
+
this.transport.addEventListener("greeting", (event) => {
|
|
119
|
+
for (const line of event.detail.lines) this.#emitBanner(`${line}\r
|
|
120
|
+
`);
|
|
121
|
+
});
|
|
122
|
+
this.transport.addEventListener("banner", (event) => this.#emitBanner(event.detail.text));
|
|
123
|
+
this.transport.addEventListener("keys", (event) => {
|
|
124
|
+
this.fingerprint = event.detail.fingerprint;
|
|
125
|
+
this.#status("secured", `Host key ${event.detail.fingerprint} verified.`);
|
|
126
|
+
this.dispatchEvent(new CustomEvent("secure", { detail: event.detail }));
|
|
127
|
+
if (event.detail.firstTime) void this.#authenticate();
|
|
128
|
+
});
|
|
129
|
+
this.transport.addEventListener("hostdisconnect", (event) => {
|
|
130
|
+
this.lastMessage = event.detail.reason;
|
|
131
|
+
});
|
|
132
|
+
this.transport.addEventListener("error", (event) => {
|
|
133
|
+
this.lastMessage = event.detail.message;
|
|
134
|
+
this.dispatchEvent(new CustomEvent("error", { detail: event.detail }));
|
|
135
|
+
});
|
|
136
|
+
this.transport.addEventListener("close", (event) => {
|
|
137
|
+
if (this.closed) return;
|
|
138
|
+
this.closed = true;
|
|
139
|
+
clearTimeout(this.powerShellTimer);
|
|
140
|
+
this.#abandonWaiters(event.detail.message || "The SSH connection closed.");
|
|
141
|
+
this.dispatchEvent(new CustomEvent("close", {
|
|
142
|
+
detail: {
|
|
143
|
+
...event.detail,
|
|
144
|
+
message: event.detail.message || this.lastMessage || "",
|
|
145
|
+
exitStatus: this.exitStatus
|
|
146
|
+
}
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
connect() {
|
|
151
|
+
this.#status("connecting", "Opening the SSH transport\u2026");
|
|
152
|
+
this.transport.connect();
|
|
153
|
+
}
|
|
154
|
+
disconnect() {
|
|
155
|
+
if (this.closed) return;
|
|
156
|
+
clearTimeout(this.powerShellTimer);
|
|
157
|
+
if (this.shellOpen && this.remoteChannelId !== null) {
|
|
158
|
+
this.transport.send(new SshWriter(8).u8(MSG.CHANNEL_CLOSE).u32(this.remoteChannelId).take());
|
|
159
|
+
}
|
|
160
|
+
this.transport.disconnect("Closed by the user");
|
|
161
|
+
}
|
|
162
|
+
/** Send keystrokes, or anything else the terminal produces, to the shell. */
|
|
163
|
+
write(data) {
|
|
164
|
+
const bytes = typeof data === "string" ? encodeUtf8(data) : data;
|
|
165
|
+
if (!bytes.length) return;
|
|
166
|
+
this.pendingWrites.push(bytes);
|
|
167
|
+
this.#flushWrites();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Run one command in this shell and return what it printed.
|
|
171
|
+
*
|
|
172
|
+
* Deliberately the *same* shell the user is looking at, not a second channel:
|
|
173
|
+
* a command the assistant runs inherits the working directory, the
|
|
174
|
+
* environment and the login the user already has, and it appears in their
|
|
175
|
+
* terminal as it happens. An agent acting on a machine invisibly is worse
|
|
176
|
+
* than an agent that is slower to read.
|
|
177
|
+
*
|
|
178
|
+
* The output is framed by markers rather than measured, because an
|
|
179
|
+
* interactive shell echoes its input and prints a prompt around it. The
|
|
180
|
+
* markers are assembled by the shell from two pieces, so the echoed command
|
|
181
|
+
* line -- which contains the pieces but not the joined marker -- never looks
|
|
182
|
+
* like the real boundary.
|
|
183
|
+
*
|
|
184
|
+
* A `command` event brackets the run so the terminal can label it, and the
|
|
185
|
+
* three deadlines above decide when a command is stuck rather than slow.
|
|
186
|
+
*/
|
|
187
|
+
runCommand(command, {
|
|
188
|
+
startMs = COMMAND_START_MS,
|
|
189
|
+
idleMs = COMMAND_IDLE_MS,
|
|
190
|
+
maxMs = COMMAND_MAX_MS
|
|
191
|
+
} = {}) {
|
|
192
|
+
const text = String(command || "").trim();
|
|
193
|
+
if (!text) return Promise.reject(new Error("No command was given."));
|
|
194
|
+
if (!this.shellOpen || this.closed) {
|
|
195
|
+
return Promise.reject(new Error("The SSH shell is not open."));
|
|
196
|
+
}
|
|
197
|
+
if (this.readySignaled === false) {
|
|
198
|
+
return Promise.reject(new Error("The SSH shell is still starting."));
|
|
199
|
+
}
|
|
200
|
+
if (this.commandInFlight) {
|
|
201
|
+
return Promise.reject(new Error("Another command is still running in this shell."));
|
|
202
|
+
}
|
|
203
|
+
const id = Array.from(crypto.getRandomValues(new Uint8Array(6))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
204
|
+
const begin = `CCB${id}`;
|
|
205
|
+
const end = `CCE${id}`;
|
|
206
|
+
const family = this.shellFamily || "posix";
|
|
207
|
+
this.commandInFlight = true;
|
|
208
|
+
const startedAt = Date.now();
|
|
209
|
+
this.displayFilter = { begin, end, stage: "before", pending: "" };
|
|
210
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
211
|
+
detail: { phase: "start", id, command: text }
|
|
212
|
+
}));
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
let transcript = "";
|
|
215
|
+
let started = false;
|
|
216
|
+
let startTimer = null;
|
|
217
|
+
let idleTimer = null;
|
|
218
|
+
let maxTimer = null;
|
|
219
|
+
const finish = (settle) => {
|
|
220
|
+
clearTimeout(startTimer);
|
|
221
|
+
clearTimeout(idleTimer);
|
|
222
|
+
clearTimeout(maxTimer);
|
|
223
|
+
this.removeEventListener("data", onData);
|
|
224
|
+
this.removeEventListener("close", onClose);
|
|
225
|
+
this.commandInFlight = false;
|
|
226
|
+
releaseDisplayFilter(this);
|
|
227
|
+
settle();
|
|
228
|
+
};
|
|
229
|
+
const done = (result) => finish(() => {
|
|
230
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
231
|
+
detail: {
|
|
232
|
+
phase: "end",
|
|
233
|
+
id,
|
|
234
|
+
command: text,
|
|
235
|
+
exitStatus: result.exitStatus,
|
|
236
|
+
timedOut: result.timedOut,
|
|
237
|
+
started: result.started !== false,
|
|
238
|
+
reason: result.reason,
|
|
239
|
+
durationMs: result.durationMs
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
resolve(result);
|
|
243
|
+
});
|
|
244
|
+
const fail = (error) => finish(() => {
|
|
245
|
+
this.dispatchEvent(new CustomEvent("command", {
|
|
246
|
+
detail: {
|
|
247
|
+
phase: "end",
|
|
248
|
+
id,
|
|
249
|
+
command: text,
|
|
250
|
+
exitStatus: null,
|
|
251
|
+
timedOut: false,
|
|
252
|
+
started,
|
|
253
|
+
durationMs: Date.now() - startedAt,
|
|
254
|
+
error: error.message
|
|
255
|
+
}
|
|
256
|
+
}));
|
|
257
|
+
reject(error);
|
|
258
|
+
});
|
|
259
|
+
const partialOutput = () => {
|
|
260
|
+
const at = transcript.indexOf(begin);
|
|
261
|
+
return at === -1 ? "" : stripTerminalCodes(transcript.slice(at + begin.length)).replace(/^\r?\n/, "").replace(/\s+$/, "");
|
|
262
|
+
};
|
|
263
|
+
const giveUp = (reason) => {
|
|
264
|
+
this.write("");
|
|
265
|
+
done({
|
|
266
|
+
output: partialOutput(),
|
|
267
|
+
exitStatus: null,
|
|
268
|
+
timedOut: true,
|
|
269
|
+
started,
|
|
270
|
+
reason,
|
|
271
|
+
durationMs: Date.now() - startedAt
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
const onData = (event) => {
|
|
275
|
+
transcript += decodeUtf8(event.detail.bytes);
|
|
276
|
+
if (!started && transcript.indexOf(begin) !== -1) {
|
|
277
|
+
started = true;
|
|
278
|
+
clearTimeout(startTimer);
|
|
279
|
+
startTimer = null;
|
|
280
|
+
}
|
|
281
|
+
if (started) {
|
|
282
|
+
clearTimeout(idleTimer);
|
|
283
|
+
idleTimer = setTimeout(() => giveUp("idle"), idleMs);
|
|
284
|
+
}
|
|
285
|
+
const endAt = transcript.indexOf(`${end} `);
|
|
286
|
+
if (endAt === -1) {
|
|
287
|
+
if (!started && FRAMING_REJECTED.test(stripTerminalCodes(transcript))) {
|
|
288
|
+
const guessed = shellFromPrompt(stripTerminalCodes(transcript)) || "cmd";
|
|
289
|
+
const wrongGuess = family !== guessed;
|
|
290
|
+
this.shellFamily = guessed;
|
|
291
|
+
fail(new Error(
|
|
292
|
+
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"}`
|
|
293
|
+
));
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const tail = transcript.slice(endAt);
|
|
298
|
+
const status = parseInt(tail.slice(end.length + 1).trim(), 10);
|
|
299
|
+
const beginAt = transcript.indexOf(begin);
|
|
300
|
+
const body = beginAt === -1 ? transcript.slice(0, endAt) : transcript.slice(beginAt + begin.length, endAt);
|
|
301
|
+
done({
|
|
302
|
+
output: stripTerminalCodes(body).replace(/^\r?\n/, "").replace(/\s+$/, ""),
|
|
303
|
+
exitStatus: Number.isInteger(status) ? status : null,
|
|
304
|
+
timedOut: false,
|
|
305
|
+
started: true,
|
|
306
|
+
durationMs: Date.now() - startedAt
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const onClose = () => fail(new Error("The SSH session closed while the command was running."));
|
|
310
|
+
this.addEventListener("data", onData);
|
|
311
|
+
this.addEventListener("close", onClose);
|
|
312
|
+
startTimer = setTimeout(() => giveUp("start"), startMs);
|
|
313
|
+
idleTimer = setTimeout(() => giveUp("idle"), idleMs);
|
|
314
|
+
maxTimer = setTimeout(() => giveUp("max"), maxMs);
|
|
315
|
+
this.write(frameCommand(family, text, id));
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Tell the shell its window changed, so full-screen programs redraw. */
|
|
319
|
+
resize(columns, rows) {
|
|
320
|
+
const nextColumns = Math.max(1, Math.floor(columns));
|
|
321
|
+
const nextRows = Math.max(1, Math.floor(rows));
|
|
322
|
+
if (nextColumns === this.columns && nextRows === this.rows) return;
|
|
323
|
+
this.columns = nextColumns;
|
|
324
|
+
this.rows = nextRows;
|
|
325
|
+
if (!this.shellOpen || this.remoteChannelId === null) return;
|
|
326
|
+
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());
|
|
327
|
+
}
|
|
328
|
+
// --- plumbing ------------------------------------------------------------
|
|
329
|
+
#status(phase, message) {
|
|
330
|
+
this.dispatchEvent(new CustomEvent("status", { detail: { phase, message } }));
|
|
331
|
+
}
|
|
332
|
+
#emitBanner(text) {
|
|
333
|
+
if (text) this.dispatchEvent(new CustomEvent("banner", { detail: { text } }));
|
|
334
|
+
}
|
|
335
|
+
/** Wait for one of a few message types: the handshakes read linearly that way. */
|
|
336
|
+
#expect(...types) {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
for (const type of types) this.waiters.set(type, { resolve, reject });
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
#settle(type, payload) {
|
|
342
|
+
const waiter = this.waiters.get(type);
|
|
343
|
+
if (!waiter) return false;
|
|
344
|
+
this.waiters.clear();
|
|
345
|
+
waiter.resolve({ type, payload });
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
/** A closed transport must not leave a handshake waiting forever. */
|
|
349
|
+
#abandonWaiters(message) {
|
|
350
|
+
if (this.waiters.size === 0) return;
|
|
351
|
+
const pending = [...new Set(this.waiters.values())];
|
|
352
|
+
this.waiters.clear();
|
|
353
|
+
for (const waiter of pending) waiter.reject(new Error(message));
|
|
354
|
+
}
|
|
355
|
+
#handlePacket(type, payload) {
|
|
356
|
+
if (this.#settle(type, payload)) return;
|
|
357
|
+
switch (type) {
|
|
358
|
+
case MSG.USERAUTH_BANNER:
|
|
359
|
+
this.#emitBanner(new SshReader(payload, 1).string().replaceAll("\n", "\r\n"));
|
|
360
|
+
return;
|
|
361
|
+
case MSG.CHANNEL_DATA: {
|
|
362
|
+
const reader = new SshReader(payload, 1);
|
|
363
|
+
reader.u32();
|
|
364
|
+
this.#consume(reader.stringBytes());
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
case MSG.CHANNEL_EXTENDED_DATA: {
|
|
368
|
+
const reader = new SshReader(payload, 1);
|
|
369
|
+
reader.u32();
|
|
370
|
+
const dataType = reader.u32();
|
|
371
|
+
const bytes = reader.stringBytes();
|
|
372
|
+
this.#consume(bytes, dataType === EXTENDED_DATA_STDERR);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
case MSG.CHANNEL_WINDOW_ADJUST: {
|
|
376
|
+
const reader = new SshReader(payload, 1);
|
|
377
|
+
reader.u32();
|
|
378
|
+
this.remoteWindow += reader.u32();
|
|
379
|
+
this.#flushWrites();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
case MSG.CHANNEL_REQUEST: {
|
|
383
|
+
const reader = new SshReader(payload, 1);
|
|
384
|
+
reader.u32();
|
|
385
|
+
const request = reader.string();
|
|
386
|
+
const wantReply = reader.boolean();
|
|
387
|
+
if (request === "exit-status") this.exitStatus = reader.u32();
|
|
388
|
+
if (request === "exit-signal") this.exitSignal = reader.string();
|
|
389
|
+
if (wantReply && this.remoteChannelId !== null) {
|
|
390
|
+
this.transport.send(new SshWriter(8).u8(MSG.CHANNEL_SUCCESS).u32(this.remoteChannelId).take());
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
case MSG.CHANNEL_EOF:
|
|
395
|
+
return;
|
|
396
|
+
case MSG.CHANNEL_CLOSE:
|
|
397
|
+
this.shellOpen = false;
|
|
398
|
+
this.#status("closed", "The remote shell ended.");
|
|
399
|
+
this.transport.disconnect("Shell closed");
|
|
400
|
+
return;
|
|
401
|
+
default:
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Watch the prompt for which shell this machine answers with, so the first
|
|
407
|
+
* command is framed correctly rather than discovering it by failing.
|
|
408
|
+
*/
|
|
409
|
+
#noteShellFamily(bytes) {
|
|
410
|
+
this.promptTail = (this.promptTail + stripTerminalCodes(decodeUtf8(bytes))).slice(-400);
|
|
411
|
+
const family = shellFromPrompt(this.promptTail);
|
|
412
|
+
if (!family) return;
|
|
413
|
+
if (family === "cmd" && !this.powerShellAttempted) {
|
|
414
|
+
this.#startPowerShell();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (family === "cmd" && this.powerShellAttempted && !this.readySignaled) return;
|
|
418
|
+
this.shellFamily = family;
|
|
419
|
+
if (family === "powershell" && !this.readySignaled) {
|
|
420
|
+
this.#markReady("Connected \xB7 PowerShell");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Replace Windows OpenSSH's usual cmd.exe with an interactive PowerShell. */
|
|
424
|
+
#startPowerShell() {
|
|
425
|
+
if (this.powerShellAttempted || !this.shellOpen) return;
|
|
426
|
+
this.powerShellAttempted = true;
|
|
427
|
+
this.readySignaled = false;
|
|
428
|
+
this.shellFamily = "";
|
|
429
|
+
this.#status("opening", "Starting PowerShell\u2026");
|
|
430
|
+
this.write("powershell.exe -NoLogo\r");
|
|
431
|
+
this.powerShellTimer = setTimeout(() => {
|
|
432
|
+
if (this.readySignaled || this.closed) return;
|
|
433
|
+
this.shellFamily = "powershell";
|
|
434
|
+
this.#markReady("Connected \xB7 PowerShell");
|
|
435
|
+
}, 2e3);
|
|
436
|
+
}
|
|
437
|
+
#markReady(message = "Connected.") {
|
|
438
|
+
clearTimeout(this.powerShellTimer);
|
|
439
|
+
this.powerShellTimer = null;
|
|
440
|
+
this.readySignaled = true;
|
|
441
|
+
this.#status("ready", message);
|
|
442
|
+
this.dispatchEvent(new CustomEvent("ready", {
|
|
443
|
+
detail: { fingerprint: this.fingerprint, shellFamily: this.shellFamily || "posix" }
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
/** Shell output, with the receive window topped up before it runs dry. */
|
|
447
|
+
#consume(bytes, isStderr = false) {
|
|
448
|
+
if (!bytes.length) return;
|
|
449
|
+
this.#noteShellFamily(bytes);
|
|
450
|
+
this.dispatchEvent(new CustomEvent("data", {
|
|
451
|
+
detail: { bytes, display: filterDisplay(this, bytes), isStderr }
|
|
452
|
+
}));
|
|
453
|
+
this.localWindow -= bytes.length;
|
|
454
|
+
if (this.localWindow > WINDOW_REFILL_THRESHOLD || this.remoteChannelId === null) return;
|
|
455
|
+
const increment = INITIAL_WINDOW - this.localWindow;
|
|
456
|
+
this.localWindow = INITIAL_WINDOW;
|
|
457
|
+
this.transport.send(new SshWriter(16).u8(MSG.CHANNEL_WINDOW_ADJUST).u32(this.remoteChannelId).u32(increment).take());
|
|
458
|
+
}
|
|
459
|
+
#flushWrites() {
|
|
460
|
+
if (!this.shellOpen || this.remoteChannelId === null) return;
|
|
461
|
+
while (this.pendingWrites.length > 0) {
|
|
462
|
+
const next = this.pendingWrites[0];
|
|
463
|
+
const room = Math.min(this.remoteWindow, this.remoteMaxPacket - 64);
|
|
464
|
+
if (room <= 0) return;
|
|
465
|
+
const chunk = next.length <= room ? next : next.subarray(0, room);
|
|
466
|
+
if (chunk.length === next.length) this.pendingWrites.shift();
|
|
467
|
+
else this.pendingWrites[0] = next.subarray(chunk.length);
|
|
468
|
+
this.remoteWindow -= chunk.length;
|
|
469
|
+
this.transport.send(new SshWriter(chunk.length + 16).u8(MSG.CHANNEL_DATA).u32(this.remoteChannelId).string(chunk).take());
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// --- authentication ------------------------------------------------------
|
|
473
|
+
async #authenticate() {
|
|
474
|
+
try {
|
|
475
|
+
this.#status("authenticating", `Authenticating as ${this.username}\u2026`);
|
|
476
|
+
this.transport.send(new SshWriter(32).u8(MSG.SERVICE_REQUEST).string("ssh-userauth").take());
|
|
477
|
+
await this.#expect(MSG.SERVICE_ACCEPT);
|
|
478
|
+
if (await this.#tryNone()) return void await this.#openShell();
|
|
479
|
+
if (this.authMethods.includes("password") && this.password) {
|
|
480
|
+
if (await this.#tryPassword(this.password)) return void await this.#openShell();
|
|
481
|
+
}
|
|
482
|
+
if (this.authMethods.includes("keyboard-interactive")) {
|
|
483
|
+
if (await this.#tryKeyboardInteractive()) return void await this.#openShell();
|
|
484
|
+
if (this.authMethods.includes("keyboard-interactive")) {
|
|
485
|
+
if (await this.#tryKeyboardInteractive({ usePassword: false })) {
|
|
486
|
+
return void await this.#openShell();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (this.authMethods.includes("password")) {
|
|
491
|
+
const typed = await this.requestInput({
|
|
492
|
+
prompt: `${this.username}@${this.options.hostLabel || "host"}'s password: `,
|
|
493
|
+
echo: false
|
|
494
|
+
});
|
|
495
|
+
if (typed && await this.#tryPassword(typed)) return void await this.#openShell();
|
|
496
|
+
}
|
|
497
|
+
throw new Error(this.authMethods.length ? `SSH: authentication failed. The host accepts ${this.authMethods.join(", ")}.` : "SSH: the host rejected the connection before any authentication method was offered.");
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (!this.closed) this.transport.fail(error);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async #tryNone() {
|
|
503
|
+
this.transport.send(new SshWriter(64).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("none").take());
|
|
504
|
+
return this.#authResult();
|
|
505
|
+
}
|
|
506
|
+
async #tryPassword(password) {
|
|
507
|
+
this.transport.send(new SshWriter(128).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("password").boolean(false).string(password).take());
|
|
508
|
+
return this.#authResult();
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* keyboard-interactive is how most Linux hosts actually ask for a password,
|
|
512
|
+
* and the only way a one-time code can be entered. Prompts are answered from
|
|
513
|
+
* the password when one is obviously being asked for, and by the user
|
|
514
|
+
* otherwise.
|
|
515
|
+
*/
|
|
516
|
+
async #tryKeyboardInteractive({ usePassword = true } = {}) {
|
|
517
|
+
this.transport.send(new SshWriter(96).u8(MSG.USERAUTH_REQUEST).string(this.username).string(SERVICE).string("keyboard-interactive").string("").string("").take());
|
|
518
|
+
let usedPassword = !usePassword;
|
|
519
|
+
while (true) {
|
|
520
|
+
const { type, payload } = await this.#expect(
|
|
521
|
+
MSG.USERAUTH_SUCCESS,
|
|
522
|
+
MSG.USERAUTH_FAILURE,
|
|
523
|
+
MSG.USERAUTH_INFO_REQUEST
|
|
524
|
+
);
|
|
525
|
+
if (type === MSG.USERAUTH_SUCCESS) return true;
|
|
526
|
+
if (type === MSG.USERAUTH_FAILURE) {
|
|
527
|
+
this.#readFailure(payload);
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
const reader = new SshReader(payload, 1);
|
|
531
|
+
const name = reader.string();
|
|
532
|
+
const instruction = reader.string();
|
|
533
|
+
reader.string();
|
|
534
|
+
const promptCount = reader.u32();
|
|
535
|
+
if (name) this.#emitBanner(`${name.replaceAll("\n", "\r\n")}\r
|
|
536
|
+
`);
|
|
537
|
+
if (instruction) this.#emitBanner(`${instruction.replaceAll("\n", "\r\n")}\r
|
|
538
|
+
`);
|
|
539
|
+
const answers = [];
|
|
540
|
+
for (let index = 0; index < promptCount; index++) {
|
|
541
|
+
const prompt = reader.string();
|
|
542
|
+
const echo = reader.boolean();
|
|
543
|
+
const looksLikePassword = !echo && /pass(word|phrase)/i.test(prompt);
|
|
544
|
+
if (looksLikePassword && this.password && !usedPassword) {
|
|
545
|
+
usedPassword = true;
|
|
546
|
+
answers.push(this.password);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
answers.push(await this.requestInput({ prompt, echo }));
|
|
550
|
+
}
|
|
551
|
+
const response = new SshWriter(256).u8(MSG.USERAUTH_INFO_RESPONSE).u32(answers.length);
|
|
552
|
+
for (const answer of answers) response.string(answer);
|
|
553
|
+
this.transport.send(response.take());
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async #authResult() {
|
|
557
|
+
const { type, payload } = await this.#expect(MSG.USERAUTH_SUCCESS, MSG.USERAUTH_FAILURE);
|
|
558
|
+
if (type === MSG.USERAUTH_SUCCESS) return true;
|
|
559
|
+
this.#readFailure(payload);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
#readFailure(payload) {
|
|
563
|
+
const reader = new SshReader(payload, 1);
|
|
564
|
+
this.authMethods = reader.nameList();
|
|
565
|
+
const partial = reader.boolean();
|
|
566
|
+
if (partial) this.#emitBanner("\r\nOne factor accepted; the host wants another.\r\n");
|
|
567
|
+
}
|
|
568
|
+
// --- the shell -----------------------------------------------------------
|
|
569
|
+
async #openShell() {
|
|
570
|
+
this.#status("opening", "Starting the remote shell\u2026");
|
|
571
|
+
this.transport.send(new SshWriter(64).u8(MSG.CHANNEL_OPEN).string("session").u32(this.channelId).u32(INITIAL_WINDOW).u32(MAX_PACKET).take());
|
|
572
|
+
const opened = await this.#expect(MSG.CHANNEL_OPEN_CONFIRMATION, MSG.CHANNEL_OPEN_FAILURE);
|
|
573
|
+
if (opened.type === MSG.CHANNEL_OPEN_FAILURE) {
|
|
574
|
+
const reader2 = new SshReader(opened.payload, 1);
|
|
575
|
+
reader2.u32();
|
|
576
|
+
reader2.u32();
|
|
577
|
+
throw new Error(`SSH: the host refused to open a session channel (${reader2.string()}).`);
|
|
578
|
+
}
|
|
579
|
+
const reader = new SshReader(opened.payload, 1);
|
|
580
|
+
reader.u32();
|
|
581
|
+
this.remoteChannelId = reader.u32();
|
|
582
|
+
this.remoteWindow = reader.u32();
|
|
583
|
+
this.remoteMaxPacket = Math.max(1024, Math.min(reader.u32(), MAX_PACKET));
|
|
584
|
+
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());
|
|
585
|
+
const pty = await this.#expect(MSG.CHANNEL_SUCCESS, MSG.CHANNEL_FAILURE);
|
|
586
|
+
if (pty.type === MSG.CHANNEL_FAILURE) {
|
|
587
|
+
throw new Error("SSH: the host refused a pseudo-terminal, so there is no interactive shell to show.");
|
|
588
|
+
}
|
|
589
|
+
this.transport.send(new SshWriter(32).u8(MSG.CHANNEL_REQUEST).u32(this.remoteChannelId).string("shell").boolean(true).take());
|
|
590
|
+
const shell = await this.#expect(MSG.CHANNEL_SUCCESS, MSG.CHANNEL_FAILURE);
|
|
591
|
+
if (shell.type === MSG.CHANNEL_FAILURE) {
|
|
592
|
+
throw new Error("SSH: the host refused to start a shell for this account.");
|
|
593
|
+
}
|
|
594
|
+
this.shellOpen = true;
|
|
595
|
+
this.#flushWrites();
|
|
596
|
+
if (this.options.preferredShell === "powershell") this.#startPowerShell();
|
|
597
|
+
else this.#markReady();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
export {
|
|
601
|
+
COMMAND_IDLE_MS,
|
|
602
|
+
COMMAND_MAX_MS,
|
|
603
|
+
COMMAND_START_MS,
|
|
604
|
+
DEFAULT_PORT,
|
|
605
|
+
SshSession,
|
|
606
|
+
frameCommand
|
|
607
|
+
};
|
|
608
|
+
//# sourceMappingURL=session.js.map
|