dsh-plugin-ssh-console 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +164 -2
- package/THIRD-PARTY-NOTICES.md +59 -0
- package/client.js +1939 -0
- package/client.xterm.js +38 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +18 -0
- package/index.js +1495 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
package/index.js
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Console bundle, Host half.
|
|
3
|
+
*
|
|
4
|
+
* A read-mostly remote-console surface for the Harness Web UI: one saved list of
|
|
5
|
+
* SSH targets, one live connection, and four operations over it — a read-only
|
|
6
|
+
* directory browser, a file preview, an nvitop-style resource sampler, and a
|
|
7
|
+
* command runner.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is a plain Node module. Like every workspace-linked bundle it
|
|
10
|
+
* resolves bare specifiers from its own directory, so `ssh2` is a real dependency
|
|
11
|
+
* of this package rather than a Harness package; the Remote binding and the method
|
|
12
|
+
* marker are assembled structurally, matching what `@deepseek-ai/dsh-typert-protocol`
|
|
13
|
+
* produces for generated code.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-ssh-console
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { Client } from 'ssh2'
|
|
22
|
+
|
|
23
|
+
/** Prototype key `remoteMethods()` reads for one Service's Remote markers. */
|
|
24
|
+
const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-methods'
|
|
25
|
+
/** Cordis service key; also the wire namespace, like `workspaceController` → `workspace`. */
|
|
26
|
+
const SERVICE_KEY = 'sshConsole'
|
|
27
|
+
/** Every Remote endpoint this Service publishes. */
|
|
28
|
+
const REMOTE_METHODS = Object.freeze([
|
|
29
|
+
'servers',
|
|
30
|
+
'connect',
|
|
31
|
+
'disconnect',
|
|
32
|
+
'status',
|
|
33
|
+
'listDir',
|
|
34
|
+
'readText',
|
|
35
|
+
'metrics',
|
|
36
|
+
'exec',
|
|
37
|
+
'termOpen',
|
|
38
|
+
'termList',
|
|
39
|
+
'termRead',
|
|
40
|
+
'termWrite',
|
|
41
|
+
'termResize',
|
|
42
|
+
'termClose',
|
|
43
|
+
])
|
|
44
|
+
/** Default cadence the Client is told to poll at, in milliseconds. */
|
|
45
|
+
const DEFAULT_REFRESH_MS = 2000
|
|
46
|
+
/** Default ceiling for one command run from the console. */
|
|
47
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60000
|
|
48
|
+
/** Hard ceiling for `head -c` on a file preview. */
|
|
49
|
+
const MAX_PREVIEW_BYTES = 256 * 1024
|
|
50
|
+
/** Ceiling for one command's captured output, keeping the tail. */
|
|
51
|
+
const MAX_CAPTURED_BYTES = 512 * 1024
|
|
52
|
+
/** Entries returned for one directory listing. */
|
|
53
|
+
const MAX_DIR_ENTRIES = 400
|
|
54
|
+
/**
|
|
55
|
+
* Ceiling for the GPU process list.
|
|
56
|
+
*
|
|
57
|
+
* A busy eight-GPU host with containerised workloads can run a few hundred
|
|
58
|
+
* compute processes, so this is set well above any realistic count: it exists to
|
|
59
|
+
* bound the payload, not to trim the view. Every row survives on this host.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_GPU_PROCESSES = 512
|
|
62
|
+
/** Retained terminal scrollback per session, in bytes. */
|
|
63
|
+
const MAX_TERM_SCROLLBACK_BYTES = 512 * 1024
|
|
64
|
+
/**
|
|
65
|
+
* Terminals one server may hold at once.
|
|
66
|
+
*
|
|
67
|
+
* Each is a full login with its own scrollback, so this bounds both the remote
|
|
68
|
+
* process count and Host memory. Six is enough for a monitoring shell plus a few
|
|
69
|
+
* working ones, and keeps a runaway loop of "new terminal" from exhausting the
|
|
70
|
+
* connection.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_TERMINALS_PER_SERVER = 6
|
|
73
|
+
/** Fallback identity files, in the order OpenSSH tries them. */
|
|
74
|
+
const DEFAULT_IDENTITIES = ['id_ed25519', 'id_rsa', 'id_ecdsa']
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Separator between a server id and a terminal id in the terminal registry.
|
|
78
|
+
*
|
|
79
|
+
* A control character cannot occur in a host alias or a derived server id, so a
|
|
80
|
+
* plain `startsWith` splits the key without ambiguity.
|
|
81
|
+
*/
|
|
82
|
+
const TERM_KEY_SEPARATOR = '\u0000'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compose the registry key for one terminal.
|
|
86
|
+
* @param serverId - the server the terminal belongs to.
|
|
87
|
+
* @param termId - the terminal's own id.
|
|
88
|
+
* @returns the map key.
|
|
89
|
+
*/
|
|
90
|
+
function terminalKey(serverId, termId) {
|
|
91
|
+
return `${serverId}${TERM_KEY_SEPARATOR}${termId}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pick the lowest free `t<n>` id.
|
|
96
|
+
* @param taken - the ids already in use on this server.
|
|
97
|
+
* @returns a fresh id.
|
|
98
|
+
*/
|
|
99
|
+
function nextTerminalId(taken) {
|
|
100
|
+
const used = new Set(taken)
|
|
101
|
+
for (let index = 1; index <= MAX_TERMINALS_PER_SERVER + 1; index += 1) {
|
|
102
|
+
const candidate = `t${index}`
|
|
103
|
+
if (!used.has(candidate)) return candidate
|
|
104
|
+
}
|
|
105
|
+
return `t${used.size + 1}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a failure the Gateway forwards verbatim. The Gateway identifies Remote
|
|
110
|
+
* failures structurally (`isDSHRemoteError` + `code`), never by class identity.
|
|
111
|
+
* @param code - stable Remote failure code.
|
|
112
|
+
* @param message - user-facing diagnostic.
|
|
113
|
+
* @param details - structured payload.
|
|
114
|
+
* @returns the marked error.
|
|
115
|
+
*/
|
|
116
|
+
function remoteFailure(code, message, details = {}) {
|
|
117
|
+
return Object.assign(new Error(message), {
|
|
118
|
+
name: 'SshConsoleError',
|
|
119
|
+
code,
|
|
120
|
+
details,
|
|
121
|
+
isDSHRemoteError: true,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Quote one value for a POSIX shell.
|
|
127
|
+
* @param value - raw text.
|
|
128
|
+
* @returns the single-quoted form.
|
|
129
|
+
*/
|
|
130
|
+
function shq(value) {
|
|
131
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a shell expression for a remote path, keeping `~` expandable.
|
|
136
|
+
*
|
|
137
|
+
* Quoting `~` would ask the shell for a directory literally named `~`, so the
|
|
138
|
+
* home directory is passed to the shell as `$HOME` instead.
|
|
139
|
+
* @param path - the path as typed by a person.
|
|
140
|
+
* @returns a shell expression evaluating to that path.
|
|
141
|
+
*/
|
|
142
|
+
function remotePathExpr(path) {
|
|
143
|
+
const text = String(path)
|
|
144
|
+
if (text === '~') return '"$HOME"'
|
|
145
|
+
if (text.startsWith('~/')) return `"$HOME"${shq(text.slice(1))}`
|
|
146
|
+
return shq(text)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Clamp a number into an interval, falling back when it is not a number.
|
|
151
|
+
* @param value - candidate.
|
|
152
|
+
* @param fallback - value to use when `value` is not finite.
|
|
153
|
+
* @param min - lower bound.
|
|
154
|
+
* @param max - upper bound.
|
|
155
|
+
* @returns the clamped number.
|
|
156
|
+
*/
|
|
157
|
+
function clampNumber(value, fallback, min, max) {
|
|
158
|
+
const number = typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
159
|
+
return Math.min(max, Math.max(min, number))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Expand a leading `~` and resolve an identity path.
|
|
164
|
+
* @param path - configured path, possibly relative to the home directory.
|
|
165
|
+
* @returns an absolute path.
|
|
166
|
+
*/
|
|
167
|
+
function expandHome(path) {
|
|
168
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
169
|
+
const trimmed = path.trim()
|
|
170
|
+
if (trimmed === '~') return homedir()
|
|
171
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2))
|
|
172
|
+
return trimmed
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ------------------------------------------------------------- ssh config */
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read `~/.ssh/config` into connect targets.
|
|
179
|
+
*
|
|
180
|
+
* Only the fields this plugin needs are understood; `Include`, `Match` and every
|
|
181
|
+
* other directive are ignored, and wildcard patterns are skipped because they
|
|
182
|
+
* describe defaults rather than a server a person would pick from a list.
|
|
183
|
+
* @returns the declared hosts, in file order.
|
|
184
|
+
*/
|
|
185
|
+
function readSshConfigHosts() {
|
|
186
|
+
const path = join(homedir(), '.ssh', 'config')
|
|
187
|
+
let text
|
|
188
|
+
try {
|
|
189
|
+
if (!existsSync(path)) return []
|
|
190
|
+
text = readFileSync(path, 'utf8')
|
|
191
|
+
} catch {
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
const hosts = []
|
|
195
|
+
let current
|
|
196
|
+
const flush = () => {
|
|
197
|
+
if (current === undefined) return
|
|
198
|
+
const host = current.host ?? current.label
|
|
199
|
+
if (host.length > 0) {
|
|
200
|
+
const port = clampNumber(current.port, 22, 1, 65535)
|
|
201
|
+
hosts.push({
|
|
202
|
+
id: `${current.user ?? ''}@${host}:${port}`,
|
|
203
|
+
label: current.label,
|
|
204
|
+
host,
|
|
205
|
+
port,
|
|
206
|
+
user: current.user,
|
|
207
|
+
identityFile: current.identityFile,
|
|
208
|
+
source: 'ssh-config',
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
current = undefined
|
|
212
|
+
}
|
|
213
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
214
|
+
const line = rawLine.trim()
|
|
215
|
+
if (line.length === 0 || line.startsWith('#')) continue
|
|
216
|
+
const match = /^(\w+)\s+(.*)$/.exec(line)
|
|
217
|
+
if (match === null) continue
|
|
218
|
+
const keyword = match[1].toLowerCase()
|
|
219
|
+
const value = match[2].trim()
|
|
220
|
+
if (keyword === 'host') {
|
|
221
|
+
flush()
|
|
222
|
+
const patterns = value.split(/\s+/)
|
|
223
|
+
const literal = patterns.filter((pattern) => !/[*?!]/.test(pattern))
|
|
224
|
+
// A block made only of wildcards (`Host *`) carries defaults, not a target.
|
|
225
|
+
if (literal.length === 0) continue
|
|
226
|
+
current = { label: literal[0], host: undefined, port: 22, user: undefined, identityFile: undefined }
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (current === undefined) continue
|
|
230
|
+
if (keyword === 'hostname') current.host = value
|
|
231
|
+
else if (keyword === 'port') current.port = Number(value)
|
|
232
|
+
else if (keyword === 'user') current.user = value
|
|
233
|
+
else if (keyword === 'identityfile') current.identityFile = value
|
|
234
|
+
}
|
|
235
|
+
flush()
|
|
236
|
+
return hosts
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* --------------------------------------------------------------- sampling */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Split a `###SECTION` framed response into its sections.
|
|
243
|
+
* @param text - the remote script's combined output.
|
|
244
|
+
* @returns one array of lines per section name.
|
|
245
|
+
*/
|
|
246
|
+
function splitSections(text) {
|
|
247
|
+
const sections = new Map()
|
|
248
|
+
let name
|
|
249
|
+
let buffer = []
|
|
250
|
+
const commit = () => {
|
|
251
|
+
if (name === undefined) return
|
|
252
|
+
sections.set(name, buffer)
|
|
253
|
+
buffer = []
|
|
254
|
+
}
|
|
255
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
256
|
+
const marker = /^###([A-Z]+)\s*$/.exec(line)
|
|
257
|
+
if (marker !== null) {
|
|
258
|
+
commit()
|
|
259
|
+
name = marker[1]
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
if (name !== undefined) buffer.push(line)
|
|
263
|
+
}
|
|
264
|
+
commit()
|
|
265
|
+
return sections
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read one section as trimmed lines with trailing blanks dropped.
|
|
270
|
+
* @param sections - framed sections.
|
|
271
|
+
* @param name - section name.
|
|
272
|
+
* @returns its lines.
|
|
273
|
+
*/
|
|
274
|
+
function sectionLines(sections, name) {
|
|
275
|
+
const lines = sections.get(name) ?? []
|
|
276
|
+
const out = []
|
|
277
|
+
for (const line of lines) out.push(line)
|
|
278
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Read the first line of a section.
|
|
284
|
+
* @param sections - framed sections.
|
|
285
|
+
* @param name - section name.
|
|
286
|
+
* @returns the trimmed line, or an empty string.
|
|
287
|
+
*/
|
|
288
|
+
function sectionLine(sections, name) {
|
|
289
|
+
const lines = sectionLines(sections, name)
|
|
290
|
+
return lines.length > 0 ? lines[0].trim() : ''
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse `/proc/stat`'s aggregate CPU line into busy/idle totals.
|
|
295
|
+
* @param line - the `cpu …` line.
|
|
296
|
+
* @returns totals, or undefined when the line does not match.
|
|
297
|
+
*/
|
|
298
|
+
function parseCpuStat(line) {
|
|
299
|
+
const fields = String(line).trim().split(/\s+/)
|
|
300
|
+
if (fields[0] !== 'cpu' || fields.length < 8) return undefined
|
|
301
|
+
const numbers = fields.slice(1, 9).map((value) => Number(value))
|
|
302
|
+
if (numbers.some((value) => !Number.isFinite(value))) return undefined
|
|
303
|
+
const [user, nice, system, idle, iowait, irq, softirq, steal] = numbers
|
|
304
|
+
const total = user + nice + system + idle + iowait + irq + softirq + steal
|
|
305
|
+
return { total, idle: idle + iowait }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Turn two `/proc/stat` samples into a utilization percentage.
|
|
310
|
+
* @param previous - the earlier sample.
|
|
311
|
+
* @param current - the later sample.
|
|
312
|
+
* @returns busy percentage, or undefined without a usable delta.
|
|
313
|
+
*/
|
|
314
|
+
function cpuPercent(previous, current) {
|
|
315
|
+
if (previous === undefined || current === undefined) return undefined
|
|
316
|
+
const total = current.total - previous.total
|
|
317
|
+
const idle = current.idle - previous.idle
|
|
318
|
+
if (!Number.isFinite(total) || total <= 0) return undefined
|
|
319
|
+
return Math.max(0, Math.min(100, (1 - idle / total) * 100))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse a `nvidia-smi --query-gpu` CSV row.
|
|
324
|
+
*
|
|
325
|
+
* The device name is free text and may itself contain commas, so the fixed-width
|
|
326
|
+
* numeric tail is taken from the end and everything between is the name.
|
|
327
|
+
* @param line - one CSV row of `index,name,util,memused,memtotal,temp,power`.
|
|
328
|
+
* @returns the parsed device, or undefined for a line that does not match.
|
|
329
|
+
*/
|
|
330
|
+
function parseGpuRow(line) {
|
|
331
|
+
const parts = String(line).split(',').map((part) => part.trim())
|
|
332
|
+
if (parts.length < 7) return undefined
|
|
333
|
+
const index = Number(parts[0])
|
|
334
|
+
if (!Number.isFinite(index)) return undefined
|
|
335
|
+
const tail = parts.slice(-5)
|
|
336
|
+
const numeric = (value) => {
|
|
337
|
+
const number = Number(value)
|
|
338
|
+
return Number.isFinite(number) ? number : undefined
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
index,
|
|
342
|
+
name: parts.slice(1, parts.length - 5).join(', '),
|
|
343
|
+
utilization: numeric(tail[0]),
|
|
344
|
+
memoryUsedMiB: numeric(tail[1]),
|
|
345
|
+
memoryTotalMiB: numeric(tail[2]),
|
|
346
|
+
temperatureC: numeric(tail[3]),
|
|
347
|
+
powerW: numeric(tail[4]),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse one `index,uuid` row from `--query-gpu`.
|
|
353
|
+
* @param line - one CSV row.
|
|
354
|
+
* @returns the mapping, or undefined when the row does not match.
|
|
355
|
+
*/
|
|
356
|
+
function parseGpuUuidRow(line) {
|
|
357
|
+
const parts = String(line).split(',').map((part) => part.trim())
|
|
358
|
+
if (parts.length < 2) return undefined
|
|
359
|
+
const index = Number(parts[0])
|
|
360
|
+
if (!Number.isFinite(index) || parts[1].length === 0) return undefined
|
|
361
|
+
return { index, uuid: parts[1] }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Parse one `pid,gpu_uuid,used_memory` row from `--query-compute-apps`.
|
|
366
|
+
* @param line - one CSV row.
|
|
367
|
+
* @returns the row, or undefined when it does not match.
|
|
368
|
+
*/
|
|
369
|
+
function parseGpuAppRow(line) {
|
|
370
|
+
const parts = String(line).split(',').map((part) => part.trim())
|
|
371
|
+
if (parts.length < 3) return undefined
|
|
372
|
+
const pid = Number(parts[0])
|
|
373
|
+
if (!Number.isFinite(pid)) return undefined
|
|
374
|
+
const usedMiB = Number(parts[parts.length - 1])
|
|
375
|
+
return {
|
|
376
|
+
pid,
|
|
377
|
+
uuid: parts.slice(1, parts.length - 1).join(','),
|
|
378
|
+
usedMiB: Number.isFinite(usedMiB) ? usedMiB : undefined,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Parse one `pid,user,args` row used to identify GPU processes.
|
|
384
|
+
* @param line - one row, header excluded.
|
|
385
|
+
* @returns the identity, or undefined for a blank line.
|
|
386
|
+
*/
|
|
387
|
+
function parseGpuAppOwnerRow(line) {
|
|
388
|
+
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line)
|
|
389
|
+
if (match === null) return undefined
|
|
390
|
+
return { pid: Number(match[1]), user: match[2], command: match[3].trim() }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Parse one full `ps -eo` row: pid, ppid, user, %cpu, %mem, elapsed, stat,
|
|
395
|
+
* threads, rss(KiB) and the full command line.
|
|
396
|
+
* @param line - one row, header excluded.
|
|
397
|
+
* @returns the process, or undefined for a blank or header line.
|
|
398
|
+
*/
|
|
399
|
+
function parseProcessRow(line) {
|
|
400
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
401
|
+
if (match === null) return undefined
|
|
402
|
+
const rssKib = Number(match[9])
|
|
403
|
+
return {
|
|
404
|
+
pid: Number(match[1]),
|
|
405
|
+
ppid: Number(match[2]),
|
|
406
|
+
user: match[3],
|
|
407
|
+
cpuPercent: Number(match[4]),
|
|
408
|
+
memoryPercent: Number(match[5]),
|
|
409
|
+
elapsed: match[6],
|
|
410
|
+
state: match[7],
|
|
411
|
+
threads: Number(match[8]),
|
|
412
|
+
rssBytes: Number.isFinite(rssKib) ? rssKib * 1024 : 0,
|
|
413
|
+
command: match[10].trim(),
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Parse `/proc/meminfo` lines into a byte-accurate memory summary.
|
|
419
|
+
* @param lines - the `MemTotal:`-style lines.
|
|
420
|
+
* @returns memory in bytes.
|
|
421
|
+
*/
|
|
422
|
+
function parseMemory(lines) {
|
|
423
|
+
const values = new Map()
|
|
424
|
+
for (const line of lines) {
|
|
425
|
+
const match = /^(\w+):\s+(\d+)\s*kB$/.exec(line.trim())
|
|
426
|
+
if (match === null) continue
|
|
427
|
+
values.set(match[1], Number(match[2]) * 1024)
|
|
428
|
+
}
|
|
429
|
+
const total = values.get('MemTotal') ?? 0
|
|
430
|
+
const available = values.get('MemAvailable') ?? values.get('MemFree') ?? 0
|
|
431
|
+
return {
|
|
432
|
+
totalBytes: total,
|
|
433
|
+
availableBytes: available,
|
|
434
|
+
usedBytes: Math.max(0, total - available),
|
|
435
|
+
swapTotalBytes: values.get('SwapTotal') ?? 0,
|
|
436
|
+
swapFreeBytes: values.get('SwapFree') ?? 0,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Parse one `mode|size|mtime|path` row produced by `stat -c`.
|
|
442
|
+
* @param line - one row.
|
|
443
|
+
* @returns the entry, or undefined for a line that does not match.
|
|
444
|
+
*/
|
|
445
|
+
function parseStatRow(line) {
|
|
446
|
+
const first = line.indexOf('|')
|
|
447
|
+
if (first < 0) return undefined
|
|
448
|
+
const second = line.indexOf('|', first + 1)
|
|
449
|
+
if (second < 0) return undefined
|
|
450
|
+
const third = line.indexOf('|', second + 1)
|
|
451
|
+
if (third < 0) return undefined
|
|
452
|
+
const modeText = line.slice(0, first).trim()
|
|
453
|
+
const size = Number(line.slice(first + 1, second))
|
|
454
|
+
const mtime = Number(line.slice(second + 1, third))
|
|
455
|
+
let path = line.slice(third + 1)
|
|
456
|
+
if (path.startsWith('./')) path = path.slice(2)
|
|
457
|
+
const hex = Number.parseInt(modeText, 16)
|
|
458
|
+
const typeBits = Number.isFinite(hex) ? hex & 0o170000 : undefined
|
|
459
|
+
const kind = typeBits === 0o040000
|
|
460
|
+
? 'directory'
|
|
461
|
+
: typeBits === 0o120000
|
|
462
|
+
? 'symlink'
|
|
463
|
+
: typeBits === 0o100000
|
|
464
|
+
? 'file'
|
|
465
|
+
: 'other'
|
|
466
|
+
return {
|
|
467
|
+
name: path,
|
|
468
|
+
kind,
|
|
469
|
+
sizeBytes: Number.isFinite(size) ? size : 0,
|
|
470
|
+
modifiedMs: Number.isFinite(mtime) ? mtime * 1000 : 0,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Sampling script. Sections are framed by `###NAME` markers on their own line so
|
|
476
|
+
* one round trip carries everything the page shows.
|
|
477
|
+
*
|
|
478
|
+
* The GPU process section resolves each pid's owner and command line as well as
|
|
479
|
+
* its device, because `nvidia-smi` alone reports a bare pid and memory figure.
|
|
480
|
+
*/
|
|
481
|
+
const METRICS_SCRIPT = [
|
|
482
|
+
'echo "###HOST"',
|
|
483
|
+
'hostname 2>/dev/null',
|
|
484
|
+
'echo "###UNAME"',
|
|
485
|
+
'uname -sr 2>/dev/null',
|
|
486
|
+
'echo "###UPTIME"',
|
|
487
|
+
'cut -d. -f1 /proc/uptime 2>/dev/null',
|
|
488
|
+
'echo "###LOAD"',
|
|
489
|
+
'cat /proc/loadavg 2>/dev/null',
|
|
490
|
+
'echo "###NPROC"',
|
|
491
|
+
'nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null',
|
|
492
|
+
'echo "###STAT"',
|
|
493
|
+
'head -1 /proc/stat 2>/dev/null',
|
|
494
|
+
'echo "###MEM"',
|
|
495
|
+
"grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):' /proc/meminfo 2>/dev/null",
|
|
496
|
+
'echo "###GPU"',
|
|
497
|
+
'if command -v nvidia-smi >/dev/null 2>&1; then '
|
|
498
|
+
+ 'nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw '
|
|
499
|
+
+ '--format=csv,noheader,nounits 2>/dev/null; else echo NOGPU; fi',
|
|
500
|
+
'echo "###GPUUUID"',
|
|
501
|
+
'if command -v nvidia-smi >/dev/null 2>&1; then '
|
|
502
|
+
+ 'nvidia-smi --query-gpu=index,uuid --format=csv,noheader 2>/dev/null; fi',
|
|
503
|
+
'echo "###GPUAPPS"',
|
|
504
|
+
'if command -v nvidia-smi >/dev/null 2>&1; then '
|
|
505
|
+
+ 'nvidia-smi --query-compute-apps=pid,gpu_uuid,used_memory '
|
|
506
|
+
+ '--format=csv,noheader,nounits 2>/dev/null; fi',
|
|
507
|
+
'echo "###GPUAPPOWNERS"',
|
|
508
|
+
'apppids=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null '
|
|
509
|
+
+ "| tr -d ' ' | sed '/^$/d' | paste -sd, -); "
|
|
510
|
+
+ '[ -n "$apppids" ] && ps -o pid=,user=,args= -p "$apppids" 2>/dev/null',
|
|
511
|
+
'echo "###DISK"',
|
|
512
|
+
'df -P -h / 2>/dev/null | tail -n 1',
|
|
513
|
+
'echo "###TOP"',
|
|
514
|
+
'ps -eo pid,ppid,user,pcpu,pmem,etime,stat,nlwp,rss,args --sort=-pcpu 2>/dev/null | head -n 21',
|
|
515
|
+
'echo "###END"',
|
|
516
|
+
].join('\n')
|
|
517
|
+
|
|
518
|
+
/* ------------------------------------------------------------------ service */
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* One SSH session plus the surfaces the console page reads.
|
|
522
|
+
*
|
|
523
|
+
* The connection is deliberately owned here rather than per call: a console page
|
|
524
|
+
* polls once every couple of seconds, and re-authenticating each time would cost a
|
|
525
|
+
* handshake per sample. Loss of the connection marks the state closed so the page
|
|
526
|
+
* reports it instead of hanging.
|
|
527
|
+
*/
|
|
528
|
+
class SshConsoleService {
|
|
529
|
+
/**
|
|
530
|
+
* @param ctx - Host context owning this Service.
|
|
531
|
+
* @param config - the Loader row's `config` object, already merged with defaults.
|
|
532
|
+
*/
|
|
533
|
+
constructor(ctx, config) {
|
|
534
|
+
this.ctx = ctx
|
|
535
|
+
this.settings = {
|
|
536
|
+
refreshMs: clampNumber(config?.refreshMs, DEFAULT_REFRESH_MS, 1000, 60000),
|
|
537
|
+
execTimeoutMs: clampNumber(config?.execTimeoutMs, DEFAULT_EXEC_TIMEOUT_MS, 2000, 600000),
|
|
538
|
+
includeSshConfig: config?.includeSshConfig !== false,
|
|
539
|
+
servers: Array.isArray(config?.servers) ? config.servers : [],
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Live connections keyed by server id. One page may watch several machines,
|
|
543
|
+
* and each keeps its own CPU baseline for the utilization delta.
|
|
544
|
+
*/
|
|
545
|
+
this.sessions = new Map()
|
|
546
|
+
/** Interactive PTYs, keyed by server id. Independent of the SSH connection. */
|
|
547
|
+
this.terminals = new Map()
|
|
548
|
+
/** Binding `readBinding()` validates: service identity, Cordis key, wire namespace. */
|
|
549
|
+
this.typertRemote = Object.freeze({
|
|
550
|
+
service: this,
|
|
551
|
+
serviceKey: SERVICE_KEY,
|
|
552
|
+
namespace: SERVICE_KEY,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Every configured target: the row's own entries first, then `~/.ssh/config`.
|
|
558
|
+
* @param input - unused; the wire requires one field per declared parameter.
|
|
559
|
+
* @returns the de-duplicated target list plus the polling cadence.
|
|
560
|
+
*/
|
|
561
|
+
async servers(input) {
|
|
562
|
+
void input
|
|
563
|
+
const entries = []
|
|
564
|
+
const seen = new Set()
|
|
565
|
+
const push = (entry) => {
|
|
566
|
+
if (seen.has(entry.id)) return
|
|
567
|
+
seen.add(entry.id)
|
|
568
|
+
entries.push(entry)
|
|
569
|
+
}
|
|
570
|
+
for (const raw of this.settings.servers) {
|
|
571
|
+
if (raw === null || typeof raw !== 'object') continue
|
|
572
|
+
const host = typeof raw.host === 'string' ? raw.host.trim() : ''
|
|
573
|
+
if (host.length === 0) continue
|
|
574
|
+
const port = clampNumber(raw.port, 22, 1, 65535)
|
|
575
|
+
const user = typeof raw.user === 'string' ? raw.user.trim() : ''
|
|
576
|
+
push({
|
|
577
|
+
id: `${user}@${host}:${port}`,
|
|
578
|
+
label: typeof raw.label === 'string' && raw.label.trim().length > 0
|
|
579
|
+
? raw.label.trim()
|
|
580
|
+
: `${user === '' ? '' : `${user}@`}${host}`,
|
|
581
|
+
host,
|
|
582
|
+
port,
|
|
583
|
+
user,
|
|
584
|
+
identityFile: expandHome(raw.identityFile),
|
|
585
|
+
source: 'config',
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
if (this.settings.includeSshConfig) {
|
|
589
|
+
for (const entry of readSshConfigHosts()) {
|
|
590
|
+
push({ ...entry, identityFile: expandHome(entry.identityFile) })
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
servers: entries,
|
|
595
|
+
refreshMs: this.settings.refreshMs,
|
|
596
|
+
connectedIds: [...this.sessions.keys()],
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Open a connection to one target, replacing any previous one to the same host.
|
|
602
|
+
* @param input - `{ serverId, password?, passphrase?, identityFile? }`.
|
|
603
|
+
* @returns the connected identity.
|
|
604
|
+
*/
|
|
605
|
+
async connect(input) {
|
|
606
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
607
|
+
const serverId = typeof request.serverId === 'string' ? request.serverId : ''
|
|
608
|
+
if (serverId.length === 0) throw remoteFailure('ssh/bad-request', 'A server id is required.')
|
|
609
|
+
const { servers } = await this.servers({})
|
|
610
|
+
const server = servers.find((entry) => entry.id === serverId)
|
|
611
|
+
if (server === undefined) {
|
|
612
|
+
throw remoteFailure('ssh/unknown-server', `Unknown server "${serverId}".`, { serverId })
|
|
613
|
+
}
|
|
614
|
+
// Only this host's previous session is replaced; other hosts stay connected.
|
|
615
|
+
await this.disconnect({ serverId })
|
|
616
|
+
|
|
617
|
+
const options = {
|
|
618
|
+
host: server.host,
|
|
619
|
+
port: server.port,
|
|
620
|
+
readyTimeout: 20000,
|
|
621
|
+
keepaliveInterval: 15000,
|
|
622
|
+
keepaliveCountMax: 4,
|
|
623
|
+
}
|
|
624
|
+
if (server.user !== undefined && server.user.length > 0) options.username = server.user
|
|
625
|
+
const identityFile = expandHome(request.identityFile) ?? server.identityFile
|
|
626
|
+
const passphrase = typeof request.passphrase === 'string' && request.passphrase.length > 0
|
|
627
|
+
? request.passphrase
|
|
628
|
+
: undefined
|
|
629
|
+
const password = typeof request.password === 'string' && request.password.length > 0
|
|
630
|
+
? request.password
|
|
631
|
+
: undefined
|
|
632
|
+
|
|
633
|
+
if (password !== undefined) {
|
|
634
|
+
options.password = password
|
|
635
|
+
} else {
|
|
636
|
+
const loaded = this.loadIdentity(identityFile, passphrase)
|
|
637
|
+
if (loaded !== undefined) options.privateKey = loaded
|
|
638
|
+
}
|
|
639
|
+
if (options.privateKey === undefined && options.password === undefined) {
|
|
640
|
+
options.agent = process.platform === 'win32'
|
|
641
|
+
? '\\\\.\\pipe\\openssh-ssh-agent'
|
|
642
|
+
: (process.env.SSH_AUTH_SOCK ?? undefined)
|
|
643
|
+
if (options.agent === undefined) {
|
|
644
|
+
throw remoteFailure(
|
|
645
|
+
'ssh/no-credentials',
|
|
646
|
+
`No usable credential for ${server.host}: no password given, no identity file found, and no ssh-agent socket.`,
|
|
647
|
+
{ serverId },
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const client = new Client()
|
|
653
|
+
const session = { client, server, connected: false, cpuSample: undefined, since: Date.now() }
|
|
654
|
+
this.sessions.set(serverId, session)
|
|
655
|
+
|
|
656
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
657
|
+
let settled = false
|
|
658
|
+
const settle = (error) => {
|
|
659
|
+
if (settled) return
|
|
660
|
+
settled = true
|
|
661
|
+
if (error === undefined) resolvePromise()
|
|
662
|
+
else rejectPromise(error)
|
|
663
|
+
}
|
|
664
|
+
const markClosed = () => {
|
|
665
|
+
if (this.sessions.get(serverId) !== session) return
|
|
666
|
+
session.connected = false
|
|
667
|
+
session.client = undefined
|
|
668
|
+
}
|
|
669
|
+
client.on('ready', () => settle())
|
|
670
|
+
client.on('error', (error) => {
|
|
671
|
+
markClosed()
|
|
672
|
+
settle(error)
|
|
673
|
+
})
|
|
674
|
+
client.on('close', () => {
|
|
675
|
+
markClosed()
|
|
676
|
+
settle(remoteFailure('ssh/closed', 'The SSH connection closed before it was ready.', { serverId }))
|
|
677
|
+
})
|
|
678
|
+
try {
|
|
679
|
+
client.connect(options)
|
|
680
|
+
} catch (error) {
|
|
681
|
+
settle(error)
|
|
682
|
+
}
|
|
683
|
+
}).catch((error) => {
|
|
684
|
+
discardSession(this, serverId, session)
|
|
685
|
+
throw this.describe(error, server)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
session.connected = true
|
|
689
|
+
let probe
|
|
690
|
+
try {
|
|
691
|
+
probe = await this.run(
|
|
692
|
+
session,
|
|
693
|
+
'echo "###HOST"; hostname 2>/dev/null; echo "###UNAME"; uname -sr 2>/dev/null; '
|
|
694
|
+
+ 'echo "###STAT"; head -1 /proc/stat 2>/dev/null; echo "###END"',
|
|
695
|
+
15000,
|
|
696
|
+
)
|
|
697
|
+
} catch (error) {
|
|
698
|
+
discardSession(this, serverId, session)
|
|
699
|
+
throw this.describe(error, server)
|
|
700
|
+
}
|
|
701
|
+
const sections = splitSections(probe.stdout)
|
|
702
|
+
session.hostname = sectionLine(sections, 'HOST')
|
|
703
|
+
session.os = sectionLine(sections, 'UNAME')
|
|
704
|
+
// Seed the CPU baseline here so the very first metrics poll already carries a
|
|
705
|
+
// percentage instead of waiting one interval for a second sample.
|
|
706
|
+
session.cpuSample = parseCpuStat(sectionLine(sections, 'STAT'))
|
|
707
|
+
return { server: this.publicServer(server), hostname: session.hostname, os: session.os }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Load an identity file, preferring the requested one and falling back to the
|
|
712
|
+
* usual defaults.
|
|
713
|
+
* @param identityFile - explicit path, or undefined.
|
|
714
|
+
* @param passphrase - optional key passphrase.
|
|
715
|
+
* @returns the private key buffer, or undefined when none could be read.
|
|
716
|
+
*/
|
|
717
|
+
loadIdentity(identityFile, passphrase) {
|
|
718
|
+
const candidates = []
|
|
719
|
+
if (identityFile !== undefined) candidates.push(identityFile)
|
|
720
|
+
for (const name of DEFAULT_IDENTITIES) candidates.push(join(homedir(), '.ssh', name))
|
|
721
|
+
for (const candidate of candidates) {
|
|
722
|
+
try {
|
|
723
|
+
if (!existsSync(candidate)) continue
|
|
724
|
+
const stat = statSync(candidate)
|
|
725
|
+
if (!stat.isFile() || stat.size === 0) continue
|
|
726
|
+
const key = readFileSync(candidate)
|
|
727
|
+
return passphrase === undefined ? key : { key, passphrase }
|
|
728
|
+
} catch {
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return undefined
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Report every live session without touching the network.
|
|
737
|
+
* @param input - unused; the wire requires one field per declared parameter.
|
|
738
|
+
* @returns the connection states, in connect order.
|
|
739
|
+
*/
|
|
740
|
+
async status(input) {
|
|
741
|
+
void input
|
|
742
|
+
const sessions = []
|
|
743
|
+
for (const [serverId, session] of this.sessions) {
|
|
744
|
+
sessions.push({
|
|
745
|
+
serverId,
|
|
746
|
+
connected: session.connected === true,
|
|
747
|
+
server: session.server === undefined ? undefined : this.publicServer(session.server),
|
|
748
|
+
hostname: session.hostname,
|
|
749
|
+
os: session.os,
|
|
750
|
+
since: session.since,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
return { sessions }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Close one session, or every session when no id is given.
|
|
758
|
+
* @param input - `{ serverId? }`.
|
|
759
|
+
* @returns the ids that were closed.
|
|
760
|
+
*/
|
|
761
|
+
async disconnect(input) {
|
|
762
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
763
|
+
const requested = typeof request.serverId === 'string' ? request.serverId : ''
|
|
764
|
+
const targets = requested.length > 0
|
|
765
|
+
? (this.sessions.has(requested) ? [[requested, this.sessions.get(requested)]] : [])
|
|
766
|
+
: [...this.sessions]
|
|
767
|
+
const closed = []
|
|
768
|
+
for (const [serverId, session] of targets) {
|
|
769
|
+
if (this.sessions.get(serverId) === session) this.sessions.delete(serverId)
|
|
770
|
+
closed.push(serverId)
|
|
771
|
+
// Every terminal on this server goes with it; a PTY cannot outlive the
|
|
772
|
+
// connection that carries it.
|
|
773
|
+
for (const [termId, terminal] of this.terminalsOf(serverId)) {
|
|
774
|
+
this.terminals.delete(terminalKey(serverId, termId))
|
|
775
|
+
terminal.exited = true
|
|
776
|
+
try {
|
|
777
|
+
terminal.stream.end()
|
|
778
|
+
} catch {
|
|
779
|
+
// A PTY whose channel is gone needs no teardown.
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (session.client !== undefined) {
|
|
783
|
+
try {
|
|
784
|
+
session.client.end()
|
|
785
|
+
} catch {
|
|
786
|
+
// A connection that is already gone needs no further teardown.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { closedIds: closed }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Resolve one session by the request's `serverId`, falling back to the only
|
|
795
|
+
* live session so a single-target page needs no id.
|
|
796
|
+
* @param request - the Remote input object.
|
|
797
|
+
* @returns the live session.
|
|
798
|
+
*/
|
|
799
|
+
sessionFor(request) {
|
|
800
|
+
const requested = typeof request?.serverId === 'string' ? request.serverId : ''
|
|
801
|
+
if (requested.length > 0) {
|
|
802
|
+
const session = this.sessions.get(requested)
|
|
803
|
+
if (session === undefined || session.connected !== true || session.client === undefined) {
|
|
804
|
+
throw remoteFailure('ssh/not-connected', `Not connected to "${requested}".`, { serverId: requested })
|
|
805
|
+
}
|
|
806
|
+
return session
|
|
807
|
+
}
|
|
808
|
+
if (this.sessions.size === 1) {
|
|
809
|
+
const only = [...this.sessions.values()][0]
|
|
810
|
+
if (only.connected === true && only.client !== undefined) return only
|
|
811
|
+
}
|
|
812
|
+
throw remoteFailure(
|
|
813
|
+
'ssh/not-connected',
|
|
814
|
+
this.sessions.size === 0
|
|
815
|
+
? 'Not connected to any server.'
|
|
816
|
+
: 'This request must name a server: more than one is connected.',
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* List one remote directory. Read-only: only `stat` and globbing run remotely.
|
|
822
|
+
* @param input - `{ path }`.
|
|
823
|
+
* @returns the resolved path and its entries.
|
|
824
|
+
*/
|
|
825
|
+
async listDir(input) {
|
|
826
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
827
|
+
const requested = typeof request.path === 'string' && request.path.trim().length > 0
|
|
828
|
+
? request.path.trim()
|
|
829
|
+
: '~'
|
|
830
|
+
const session = this.sessionFor(request)
|
|
831
|
+
const target = remotePathExpr(requested)
|
|
832
|
+
const script = [
|
|
833
|
+
`cd -- ${target} 2>/dev/null || { echo "###ERR"; echo "no such directory"; echo "###END"; exit 0; }`,
|
|
834
|
+
'echo "###PWD"',
|
|
835
|
+
'pwd -P',
|
|
836
|
+
'echo "###ENTRIES"',
|
|
837
|
+
'if ! stat -c "%f|%s|%Y|%n" -- . >/dev/null 2>&1; then',
|
|
838
|
+
' echo "###ERR"',
|
|
839
|
+
' echo "this server has no GNU stat; the read-only browser needs it"',
|
|
840
|
+
' echo "###END"',
|
|
841
|
+
' exit 0',
|
|
842
|
+
'fi',
|
|
843
|
+
'for p in ./* ./.[!.]* ./..?*; do',
|
|
844
|
+
' [ -e "$p" ] || [ -L "$p" ] || continue',
|
|
845
|
+
` stat -c '%f|%s|%Y|%n' -- "$p" 2>/dev/null || printf '?|0|0|%s\\n' "$p"`,
|
|
846
|
+
'done',
|
|
847
|
+
'echo "###END"',
|
|
848
|
+
].join('\n')
|
|
849
|
+
const result = await this.run(session, script, 20000)
|
|
850
|
+
const sections = splitSections(result.stdout)
|
|
851
|
+
const error = sectionLine(sections, 'ERR')
|
|
852
|
+
if (error.length > 0) {
|
|
853
|
+
throw remoteFailure('ssh/no-such-directory', `Cannot open ${requested}: ${error}`, { path: requested })
|
|
854
|
+
}
|
|
855
|
+
const path = sectionLine(sections, 'PWD')
|
|
856
|
+
const entries = []
|
|
857
|
+
for (const line of sectionLines(sections, 'ENTRIES')) {
|
|
858
|
+
const entry = parseStatRow(line)
|
|
859
|
+
if (entry === undefined) continue
|
|
860
|
+
entries.push(entry)
|
|
861
|
+
}
|
|
862
|
+
entries.sort((left, right) => {
|
|
863
|
+
const leftDir = left.kind === 'directory' ? 0 : 1
|
|
864
|
+
const rightDir = right.kind === 'directory' ? 0 : 1
|
|
865
|
+
if (leftDir !== rightDir) return leftDir - rightDir
|
|
866
|
+
return left.name.localeCompare(right.name)
|
|
867
|
+
})
|
|
868
|
+
return {
|
|
869
|
+
path: path.length > 0 ? path : requested,
|
|
870
|
+
entries: entries.slice(0, MAX_DIR_ENTRIES),
|
|
871
|
+
truncated: entries.length > MAX_DIR_ENTRIES,
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Read the head of one text file. Read-only.
|
|
877
|
+
* @param input - `{ path, maxBytes? }`.
|
|
878
|
+
* @returns the preview and whether it was clipped or is binary.
|
|
879
|
+
*/
|
|
880
|
+
async readText(input) {
|
|
881
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
882
|
+
const requested = typeof request.path === 'string' ? request.path.trim() : ''
|
|
883
|
+
if (requested.length === 0) throw remoteFailure('ssh/bad-request', 'A file path is required.')
|
|
884
|
+
const maxBytes = clampNumber(request.maxBytes, MAX_PREVIEW_BYTES, 1024, MAX_PREVIEW_BYTES)
|
|
885
|
+
const session = this.sessionFor(request)
|
|
886
|
+
const target = remotePathExpr(requested)
|
|
887
|
+
const script = [
|
|
888
|
+
`[ -f ${target} ] || { echo "###ERR"; echo "not a regular file"; echo "###END"; exit 0; }`,
|
|
889
|
+
'echo "###SIZE"',
|
|
890
|
+
`stat -c '%s' -- ${target} 2>/dev/null || echo 0`,
|
|
891
|
+
'echo "###DATA"',
|
|
892
|
+
`head -c ${maxBytes} -- ${target} 2>/dev/null`,
|
|
893
|
+
'echo',
|
|
894
|
+
'echo "###END"',
|
|
895
|
+
].join('\n')
|
|
896
|
+
const result = await this.run(session, script, 25000)
|
|
897
|
+
const sections = splitSections(result.stdout)
|
|
898
|
+
const error = sectionLine(sections, 'ERR')
|
|
899
|
+
if (error.length > 0) {
|
|
900
|
+
throw remoteFailure('ssh/not-a-file', `Cannot read ${requested}: ${error}`, { path: requested })
|
|
901
|
+
}
|
|
902
|
+
const sizeBytes = Number(sectionLine(sections, 'SIZE')) || 0
|
|
903
|
+
const text = sectionLines(sections, 'DATA').join('\n')
|
|
904
|
+
return {
|
|
905
|
+
path: requested,
|
|
906
|
+
text: text.includes('\u0000') ? '' : text,
|
|
907
|
+
binary: text.includes('\u0000'),
|
|
908
|
+
truncated: sizeBytes > maxBytes || result.truncated,
|
|
909
|
+
sizeBytes,
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Sample one remote machine once: CPU, memory, load, GPU devices, disk, top
|
|
915
|
+
* processes. CPU utilization needs two samples, so the previous `/proc/stat`
|
|
916
|
+
* reading is kept per session and the figure appears from the second poll on.
|
|
917
|
+
* @param input - `{ serverId? }`.
|
|
918
|
+
* @returns the snapshot.
|
|
919
|
+
*/
|
|
920
|
+
async metrics(input) {
|
|
921
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
922
|
+
const session = this.sessionFor(request)
|
|
923
|
+
const result = await this.run(session, METRICS_SCRIPT, 25000)
|
|
924
|
+
const sections = splitSections(result.stdout)
|
|
925
|
+
const stat = parseCpuStat(sectionLine(sections, 'STAT'))
|
|
926
|
+
const percent = cpuPercent(session.cpuSample, stat)
|
|
927
|
+
if (stat !== undefined) session.cpuSample = stat
|
|
928
|
+
|
|
929
|
+
const gpuLines = sectionLines(sections, 'GPU')
|
|
930
|
+
const gpuSupported = !(gpuLines.length === 1 && gpuLines[0].trim() === 'NOGPU')
|
|
931
|
+
const gpus = gpuSupported ? gpuLines.map(parseGpuRow).filter(Boolean) : []
|
|
932
|
+
|
|
933
|
+
const loadText = sectionLine(sections, 'LOAD')
|
|
934
|
+
const loadParts = loadText.split(/\s+/)
|
|
935
|
+
const load = [0, 1, 2].map((index) => {
|
|
936
|
+
const value = Number(loadParts[index])
|
|
937
|
+
return Number.isFinite(value) ? value : 0
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
const processes = sectionLines(sections, 'TOP')
|
|
941
|
+
.map(parseProcessRow)
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.slice(0, 16)
|
|
944
|
+
|
|
945
|
+
// GPU processes: join the device uuid back to its index, and the pid back to
|
|
946
|
+
// its owner and command line, so a row reads like nvitop's process list.
|
|
947
|
+
//
|
|
948
|
+
// nvidia-smi lists compute processes in an arbitrary order, not by device,
|
|
949
|
+
// so the rows are sorted by card and then by reserved memory. Capping the
|
|
950
|
+
// list here would otherwise drop whichever cards happened to land last.
|
|
951
|
+
const uuidToIndex = new Map()
|
|
952
|
+
for (const row of sectionLines(sections, 'GPUUUID').map(parseGpuUuidRow)) {
|
|
953
|
+
if (row !== undefined) uuidToIndex.set(row.uuid, row.index)
|
|
954
|
+
}
|
|
955
|
+
const owners = new Map()
|
|
956
|
+
for (const row of sectionLines(sections, 'GPUAPPOWNERS').map(parseGpuAppOwnerRow)) {
|
|
957
|
+
if (row !== undefined) owners.set(row.pid, row)
|
|
958
|
+
}
|
|
959
|
+
const gpuProcesses = sectionLines(sections, 'GPUAPPS')
|
|
960
|
+
.map(parseGpuAppRow)
|
|
961
|
+
.filter((row) => row !== undefined)
|
|
962
|
+
.map((row) => {
|
|
963
|
+
const owner = owners.get(row.pid)
|
|
964
|
+
return {
|
|
965
|
+
pid: row.pid,
|
|
966
|
+
gpuIndex: uuidToIndex.get(row.uuid),
|
|
967
|
+
usedMiB: row.usedMiB,
|
|
968
|
+
user: owner?.user,
|
|
969
|
+
command: owner?.command,
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
.sort((left, right) => (
|
|
973
|
+
(left.gpuIndex ?? Number.MAX_SAFE_INTEGER) - (right.gpuIndex ?? Number.MAX_SAFE_INTEGER)
|
|
974
|
+
|| (right.usedMiB ?? 0) - (left.usedMiB ?? 0)
|
|
975
|
+
|| left.pid - right.pid
|
|
976
|
+
))
|
|
977
|
+
.slice(0, MAX_GPU_PROCESSES)
|
|
978
|
+
|
|
979
|
+
// Per-device rollup, which is what makes a card answer "who is on this GPU".
|
|
980
|
+
for (const device of gpus) {
|
|
981
|
+
const mine = gpuProcesses.filter((process) => process.gpuIndex === device.index)
|
|
982
|
+
device.processCount = mine.length
|
|
983
|
+
device.processMemoryMiB = mine.reduce((sum, process) => sum + (process.usedMiB ?? 0), 0)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const diskText = sectionLine(sections, 'DISK')
|
|
987
|
+
const diskFields = diskText.split(/\s+/)
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
sampledAt: Date.now(),
|
|
991
|
+
hostname: sectionLine(sections, 'HOST'),
|
|
992
|
+
os: sectionLine(sections, 'UNAME'),
|
|
993
|
+
uptimeSeconds: Number(sectionLine(sections, 'UPTIME')) || undefined,
|
|
994
|
+
cpu: {
|
|
995
|
+
percent,
|
|
996
|
+
cores: Number(sectionLine(sections, 'NPROC')) || undefined,
|
|
997
|
+
load,
|
|
998
|
+
},
|
|
999
|
+
memory: parseMemory(sectionLines(sections, 'MEM')),
|
|
1000
|
+
gpu: { supported: gpuSupported, devices: gpus },
|
|
1001
|
+
gpuProcesses,
|
|
1002
|
+
disk: diskFields.length >= 6
|
|
1003
|
+
? {
|
|
1004
|
+
filesystem: diskFields[0],
|
|
1005
|
+
size: diskFields[1],
|
|
1006
|
+
used: diskFields[2],
|
|
1007
|
+
available: diskFields[3],
|
|
1008
|
+
usedPercent: Number.parseInt(diskFields[4], 10) || 0,
|
|
1009
|
+
mount: diskFields[5],
|
|
1010
|
+
}
|
|
1011
|
+
: undefined,
|
|
1012
|
+
processes,
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Run one command on the remote host and capture its output.
|
|
1018
|
+
* @param input - `{ command, timeoutMs? }`.
|
|
1019
|
+
* @returns stdout, stderr, exit code and timing.
|
|
1020
|
+
*/
|
|
1021
|
+
async exec(input) {
|
|
1022
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1023
|
+
const command = typeof request.command === 'string' ? request.command : ''
|
|
1024
|
+
if (command.trim().length === 0) throw remoteFailure('ssh/bad-request', 'A command is required.')
|
|
1025
|
+
const timeoutMs = clampNumber(request.timeoutMs, this.settings.execTimeoutMs, 1000, 600000)
|
|
1026
|
+
const session = this.sessionFor(request)
|
|
1027
|
+
const started = Date.now()
|
|
1028
|
+
const result = await this.run(session, command, timeoutMs)
|
|
1029
|
+
return {
|
|
1030
|
+
command,
|
|
1031
|
+
stdout: result.stdout,
|
|
1032
|
+
stderr: result.stderr,
|
|
1033
|
+
code: result.code,
|
|
1034
|
+
timedOut: result.timedOut,
|
|
1035
|
+
durationMs: Date.now() - started,
|
|
1036
|
+
truncated: result.truncated,
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The terminals open on one server, in creation order.
|
|
1042
|
+
* @param serverId - the server id.
|
|
1043
|
+
* @returns `[termId, terminal]` pairs.
|
|
1044
|
+
*/
|
|
1045
|
+
terminalsOf(serverId) {
|
|
1046
|
+
const prefix = `${serverId}${TERM_KEY_SEPARATOR}`
|
|
1047
|
+
return [...this.terminals]
|
|
1048
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
1049
|
+
.map(([key, terminal]) => [key.slice(prefix.length), terminal])
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Resolve one terminal on the request's server.
|
|
1054
|
+
*
|
|
1055
|
+
* A request may name its terminal. When it does not, a server holding exactly
|
|
1056
|
+
* one answers for it; anything else is refused rather than guessed, because a
|
|
1057
|
+
* guess types into the wrong shell.
|
|
1058
|
+
* @param request - the Remote input.
|
|
1059
|
+
* @returns `{ session, termId, terminal }`.
|
|
1060
|
+
*/
|
|
1061
|
+
terminalFor(request) {
|
|
1062
|
+
const session = this.sessionFor(request)
|
|
1063
|
+
const serverId = session.server.id
|
|
1064
|
+
const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
|
|
1065
|
+
if (requested.length > 0) {
|
|
1066
|
+
const terminal = this.terminals.get(terminalKey(serverId, requested))
|
|
1067
|
+
if (terminal === undefined) {
|
|
1068
|
+
throw remoteFailure('ssh/no-terminal', 'That terminal is not open on this server.', {
|
|
1069
|
+
serverId,
|
|
1070
|
+
termId: requested,
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
return { session, termId: requested, terminal }
|
|
1074
|
+
}
|
|
1075
|
+
const mine = this.terminalsOf(serverId)
|
|
1076
|
+
if (mine.length === 1) return { session, termId: mine[0][0], terminal: mine[0][1] }
|
|
1077
|
+
if (mine.length === 0) {
|
|
1078
|
+
throw remoteFailure('ssh/no-terminal', 'No terminal is open on this server.', { serverId })
|
|
1079
|
+
}
|
|
1080
|
+
throw remoteFailure('ssh/ambiguous-terminal', 'Several terminals are open on this server; name one.', {
|
|
1081
|
+
serverId,
|
|
1082
|
+
termIds: mine.map(([termId]) => termId),
|
|
1083
|
+
})
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Open an interactive PTY on the session's connection, or re-attach to one
|
|
1088
|
+
* that is already open.
|
|
1089
|
+
*
|
|
1090
|
+
* The remote side gets a real terminal — the same allocation `ssh` gives an
|
|
1091
|
+
* interactive login — so prompts, colours, line editing and full-screen
|
|
1092
|
+
* programs all behave as they do in a native terminal.
|
|
1093
|
+
*
|
|
1094
|
+
* A server can hold several terminals at once (bounded by
|
|
1095
|
+
* `MAX_TERMINALS_PER_SERVER`), each an independent login with its own scrollback
|
|
1096
|
+
* and its own window size. A terminal outlives the view that opened it: leaving
|
|
1097
|
+
* the tab or switching host only detaches the Client, so coming back replays the
|
|
1098
|
+
* retained output instead of starting a fresh login. `termClose` is the explicit
|
|
1099
|
+
* teardown.
|
|
1100
|
+
* @param input - `{ serverId?, termId?, cols?, rows? }`.
|
|
1101
|
+
* @returns the terminal id, cursor and whether this re-attached.
|
|
1102
|
+
*/
|
|
1103
|
+
async termOpen(input) {
|
|
1104
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1105
|
+
const session = this.sessionFor(request)
|
|
1106
|
+
const serverId = session.server.id
|
|
1107
|
+
const client = session.client
|
|
1108
|
+
if (client === undefined) throw remoteFailure('ssh/not-connected', 'Not connected to any server.')
|
|
1109
|
+
const cols = clampNumber(request.cols, 100, 20, 500)
|
|
1110
|
+
const rows = clampNumber(request.rows, 30, 5, 200)
|
|
1111
|
+
|
|
1112
|
+
const mine = this.terminalsOf(serverId)
|
|
1113
|
+
const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
|
|
1114
|
+
const termId = requested.length > 0 ? requested : nextTerminalId(mine.map(([id]) => id))
|
|
1115
|
+
const key = terminalKey(serverId, termId)
|
|
1116
|
+
|
|
1117
|
+
const existing = this.terminals.get(key)
|
|
1118
|
+
if (existing !== undefined && !existing.exited) {
|
|
1119
|
+
// The Client owns the geometry: it is the one with a window to fit.
|
|
1120
|
+
if (cols !== existing.cols || rows !== existing.rows) {
|
|
1121
|
+
existing.cols = cols
|
|
1122
|
+
existing.rows = rows
|
|
1123
|
+
try {
|
|
1124
|
+
existing.stream.setWindow(rows, cols, 0, 0)
|
|
1125
|
+
} catch {
|
|
1126
|
+
// A channel that died between the read and the resize needs no error.
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
termId,
|
|
1131
|
+
cols,
|
|
1132
|
+
rows,
|
|
1133
|
+
seq: existing.seq,
|
|
1134
|
+
start: existing.start,
|
|
1135
|
+
attached: true,
|
|
1136
|
+
exited: false,
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (existing === undefined && mine.length >= MAX_TERMINALS_PER_SERVER) {
|
|
1140
|
+
throw remoteFailure(
|
|
1141
|
+
'ssh/too-many-terminals',
|
|
1142
|
+
`This server already has ${mine.length} terminals; close one first.`,
|
|
1143
|
+
{ serverId, limit: MAX_TERMINALS_PER_SERVER },
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
// A terminal that has exited is replaced under the same id, so the Client's
|
|
1147
|
+
// layout keeps its slot.
|
|
1148
|
+
if (existing !== undefined) this.terminals.delete(key)
|
|
1149
|
+
|
|
1150
|
+
const stream = await new Promise((resolvePromise, rejectPromise) => {
|
|
1151
|
+
try {
|
|
1152
|
+
client.shell({ term: 'xterm-256color', cols, rows }, (error, channel) => {
|
|
1153
|
+
if (error !== undefined && error !== null) rejectPromise(error)
|
|
1154
|
+
else resolvePromise(channel)
|
|
1155
|
+
})
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
rejectPromise(error)
|
|
1158
|
+
}
|
|
1159
|
+
}).catch((error) => {
|
|
1160
|
+
throw remoteFailure(
|
|
1161
|
+
'ssh/terminal-unavailable',
|
|
1162
|
+
`Cannot open a terminal on this server — ${error instanceof Error ? error.message : String(error)}.`,
|
|
1163
|
+
)
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
const terminal = {
|
|
1167
|
+
stream,
|
|
1168
|
+
cols,
|
|
1169
|
+
rows,
|
|
1170
|
+
/** Bytes appended so far; the Client's cursor into the scrollback. */
|
|
1171
|
+
seq: 0,
|
|
1172
|
+
/** First byte still retained, so a stale cursor is detectable. */
|
|
1173
|
+
start: 0,
|
|
1174
|
+
chunks: [],
|
|
1175
|
+
retained: 0,
|
|
1176
|
+
exited: false,
|
|
1177
|
+
exitCode: undefined,
|
|
1178
|
+
}
|
|
1179
|
+
const capture = (data) => {
|
|
1180
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8')
|
|
1181
|
+
terminal.chunks.push(buffer)
|
|
1182
|
+
terminal.retained += buffer.length
|
|
1183
|
+
terminal.seq += buffer.length
|
|
1184
|
+
// Keep the tail bounded; a Chatty command must not grow Host memory without
|
|
1185
|
+
// limit. The Client is told to reset when its cursor falls off the front.
|
|
1186
|
+
while (terminal.retained > MAX_TERM_SCROLLBACK_BYTES && terminal.chunks.length > 1) {
|
|
1187
|
+
const dropped = terminal.chunks.shift()
|
|
1188
|
+
terminal.retained -= dropped.length
|
|
1189
|
+
terminal.start += dropped.length
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
stream.on('data', capture)
|
|
1193
|
+
stream.stderr.on('data', capture)
|
|
1194
|
+
stream.on('close', (code) => {
|
|
1195
|
+
terminal.exited = true
|
|
1196
|
+
terminal.exitCode = code
|
|
1197
|
+
})
|
|
1198
|
+
stream.on('error', () => {
|
|
1199
|
+
terminal.exited = true
|
|
1200
|
+
})
|
|
1201
|
+
this.terminals.set(key, terminal)
|
|
1202
|
+
return { termId, cols, rows, seq: 0, start: 0, attached: false, exited: false }
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* List the terminals open on the request's server.
|
|
1207
|
+
*
|
|
1208
|
+
* The Client calls this after a reload: its own state is gone, but the Host
|
|
1209
|
+
* still holds the shells, so the layout can be rebuilt instead of replaced.
|
|
1210
|
+
* @param input - `{ serverId? }`.
|
|
1211
|
+
* @returns one entry per terminal.
|
|
1212
|
+
*/
|
|
1213
|
+
async termList(input) {
|
|
1214
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1215
|
+
const session = this.sessionFor(request)
|
|
1216
|
+
return {
|
|
1217
|
+
terminals: this.terminalsOf(session.server.id).map(([termId, terminal]) => ({
|
|
1218
|
+
termId,
|
|
1219
|
+
cols: terminal.cols,
|
|
1220
|
+
rows: terminal.rows,
|
|
1221
|
+
seq: terminal.seq,
|
|
1222
|
+
start: terminal.start,
|
|
1223
|
+
exited: terminal.exited,
|
|
1224
|
+
exitCode: terminal.exitCode,
|
|
1225
|
+
})),
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Read terminal output after a cursor.
|
|
1231
|
+
*
|
|
1232
|
+
* Polling keeps the transport request/response only; a shell shows the echo of
|
|
1233
|
+
* a keystroke within one poll interval, which is what a remote console needs.
|
|
1234
|
+
* @param input - `{ serverId?, termId?, fromSeq? }`.
|
|
1235
|
+
* @returns the new bytes as base64, plus whether the cursor must reset.
|
|
1236
|
+
*/
|
|
1237
|
+
async termRead(input) {
|
|
1238
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1239
|
+
const { termId, terminal } = this.terminalFor(request)
|
|
1240
|
+
const fromSeq = Number.isFinite(request.fromSeq) ? Math.max(0, Math.trunc(request.fromSeq)) : 0
|
|
1241
|
+
// A cursor older than the retained window can no longer be resumed: resend
|
|
1242
|
+
// everything and let the Client clear its screen first.
|
|
1243
|
+
const reset = fromSeq < terminal.start
|
|
1244
|
+
const startAt = reset ? terminal.start : fromSeq
|
|
1245
|
+
let skip = startAt - terminal.start
|
|
1246
|
+
const parts = []
|
|
1247
|
+
for (const chunk of terminal.chunks) {
|
|
1248
|
+
if (skip >= chunk.length) {
|
|
1249
|
+
skip -= chunk.length
|
|
1250
|
+
continue
|
|
1251
|
+
}
|
|
1252
|
+
parts.push(skip > 0 ? chunk.subarray(skip) : chunk)
|
|
1253
|
+
skip = 0
|
|
1254
|
+
}
|
|
1255
|
+
return {
|
|
1256
|
+
seq: terminal.seq,
|
|
1257
|
+
start: terminal.start,
|
|
1258
|
+
reset,
|
|
1259
|
+
data: Buffer.concat(parts).toString('base64'),
|
|
1260
|
+
exited: terminal.exited,
|
|
1261
|
+
exitCode: terminal.exitCode,
|
|
1262
|
+
cols: terminal.cols,
|
|
1263
|
+
rows: terminal.rows,
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Send keystrokes to one terminal.
|
|
1269
|
+
* @param input - `{ serverId?, termId?, data }` where `data` is base64.
|
|
1270
|
+
* @returns the accepted byte count.
|
|
1271
|
+
*/
|
|
1272
|
+
async termWrite(input) {
|
|
1273
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1274
|
+
const { terminal } = this.terminalFor(request)
|
|
1275
|
+
if (terminal.exited) {
|
|
1276
|
+
throw remoteFailure('ssh/no-terminal', 'That terminal has exited.', { termId: request.termId })
|
|
1277
|
+
}
|
|
1278
|
+
const payload = typeof request.data === 'string' ? Buffer.from(request.data, 'base64') : Buffer.alloc(0)
|
|
1279
|
+
if (payload.length > 0) terminal.stream.write(payload)
|
|
1280
|
+
return { written: payload.length }
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Resize one remote PTY.
|
|
1285
|
+
* @param input - `{ serverId?, termId?, cols, rows }`.
|
|
1286
|
+
* @returns the applied size.
|
|
1287
|
+
*/
|
|
1288
|
+
async termResize(input) {
|
|
1289
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1290
|
+
const { terminal } = this.terminalFor(request)
|
|
1291
|
+
if (terminal.exited) {
|
|
1292
|
+
throw remoteFailure('ssh/no-terminal', 'That terminal has exited.', { termId: request.termId })
|
|
1293
|
+
}
|
|
1294
|
+
const cols = clampNumber(request.cols, terminal.cols, 20, 500)
|
|
1295
|
+
const rows = clampNumber(request.rows, terminal.rows, 5, 200)
|
|
1296
|
+
if (cols === terminal.cols && rows === terminal.rows) return { cols, rows }
|
|
1297
|
+
terminal.cols = cols
|
|
1298
|
+
terminal.rows = rows
|
|
1299
|
+
try {
|
|
1300
|
+
terminal.stream.setWindow(rows, cols, 0, 0)
|
|
1301
|
+
} catch {
|
|
1302
|
+
// A channel that closed between the read and the resize needs no error.
|
|
1303
|
+
}
|
|
1304
|
+
return { cols, rows }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Close one terminal, or every terminal on the server, without touching the
|
|
1309
|
+
* SSH connection.
|
|
1310
|
+
* @param input - `{ serverId?, termId? }`.
|
|
1311
|
+
* @returns the ids that were closed.
|
|
1312
|
+
*/
|
|
1313
|
+
async termClose(input) {
|
|
1314
|
+
const request = input !== null && typeof input === 'object' ? input : {}
|
|
1315
|
+
const requestedServer = typeof request.serverId === 'string' ? request.serverId : ''
|
|
1316
|
+
const serverId = requestedServer.length > 0
|
|
1317
|
+
? requestedServer
|
|
1318
|
+
: (this.sessions.size === 1 ? [...this.sessions.keys()][0] : '')
|
|
1319
|
+
if (serverId.length === 0) return { closedIds: [], closed: false }
|
|
1320
|
+
const requested = typeof request.termId === 'string' ? request.termId.trim() : ''
|
|
1321
|
+
const targets = requested.length > 0
|
|
1322
|
+
? this.terminalsOf(serverId).filter(([termId]) => termId === requested)
|
|
1323
|
+
: this.terminalsOf(serverId)
|
|
1324
|
+
const closedIds = []
|
|
1325
|
+
for (const [termId, terminal] of targets) {
|
|
1326
|
+
this.terminals.delete(terminalKey(serverId, termId))
|
|
1327
|
+
terminal.exited = true
|
|
1328
|
+
try {
|
|
1329
|
+
terminal.stream.end()
|
|
1330
|
+
} catch {
|
|
1331
|
+
// Already gone.
|
|
1332
|
+
}
|
|
1333
|
+
closedIds.push(termId)
|
|
1334
|
+
}
|
|
1335
|
+
return { closedIds, closed: closedIds.length > 0 }
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Run one command over the session's connection.
|
|
1340
|
+
* @param session - the live session.
|
|
1341
|
+
* @param command - shell command text.
|
|
1342
|
+
* @param timeoutMs - ceiling before the channel is closed.
|
|
1343
|
+
* @returns captured output.
|
|
1344
|
+
*/
|
|
1345
|
+
async run(session, command, timeoutMs) {
|
|
1346
|
+
const client = session.client
|
|
1347
|
+
if (client === undefined) throw remoteFailure('ssh/not-connected', 'Not connected to any server.')
|
|
1348
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
1349
|
+
let settled = false
|
|
1350
|
+
let stdout = ''
|
|
1351
|
+
let stderr = ''
|
|
1352
|
+
let timedOut = false
|
|
1353
|
+
let truncated = false
|
|
1354
|
+
const timer = setTimeout(() => {
|
|
1355
|
+
timedOut = true
|
|
1356
|
+
try {
|
|
1357
|
+
stream?.signal('TERM')
|
|
1358
|
+
} catch {
|
|
1359
|
+
// The channel may already be gone.
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
stream?.close()
|
|
1363
|
+
} catch {
|
|
1364
|
+
// Closing a finished channel is not an error.
|
|
1365
|
+
}
|
|
1366
|
+
finish(undefined, 124)
|
|
1367
|
+
}, timeoutMs)
|
|
1368
|
+
let stream
|
|
1369
|
+
const cleanup = () => {
|
|
1370
|
+
clearTimeout(timer)
|
|
1371
|
+
}
|
|
1372
|
+
const finish = (error, code) => {
|
|
1373
|
+
if (settled) return
|
|
1374
|
+
settled = true
|
|
1375
|
+
cleanup()
|
|
1376
|
+
if (error !== undefined) {
|
|
1377
|
+
rejectPromise(error)
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
1380
|
+
resolvePromise({ stdout, stderr, code: code ?? 0, timedOut, truncated })
|
|
1381
|
+
}
|
|
1382
|
+
const append = (chunk, target) => {
|
|
1383
|
+
const text = chunk.toString('utf8')
|
|
1384
|
+
if (target === 'out') {
|
|
1385
|
+
if (stdout.length + text.length > MAX_CAPTURED_BYTES) {
|
|
1386
|
+
truncated = true
|
|
1387
|
+
stdout = `${stdout}${text}`.slice(-MAX_CAPTURED_BYTES)
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
stdout += text
|
|
1391
|
+
return
|
|
1392
|
+
}
|
|
1393
|
+
if (stderr.length + text.length > MAX_CAPTURED_BYTES) {
|
|
1394
|
+
truncated = true
|
|
1395
|
+
stderr = `${stderr}${text}`.slice(-MAX_CAPTURED_BYTES)
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
stderr += text
|
|
1399
|
+
}
|
|
1400
|
+
try {
|
|
1401
|
+
client.exec(command, (error, channel) => {
|
|
1402
|
+
if (error !== undefined && error !== null) {
|
|
1403
|
+
finish(error)
|
|
1404
|
+
return
|
|
1405
|
+
}
|
|
1406
|
+
stream = channel
|
|
1407
|
+
channel.on('data', (chunk) => append(chunk, 'out'))
|
|
1408
|
+
channel.stderr.on('data', (chunk) => append(chunk, 'err'))
|
|
1409
|
+
channel.on('close', (code) => finish(undefined, code))
|
|
1410
|
+
channel.on('error', (channelError) => finish(channelError))
|
|
1411
|
+
})
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
finish(error)
|
|
1414
|
+
}
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Strip the fields the Client does not need from a target.
|
|
1420
|
+
* @param server - one internal target.
|
|
1421
|
+
* @returns its public shape.
|
|
1422
|
+
*/
|
|
1423
|
+
publicServer(server) {
|
|
1424
|
+
return {
|
|
1425
|
+
id: server.id,
|
|
1426
|
+
label: server.label,
|
|
1427
|
+
host: server.host,
|
|
1428
|
+
port: server.port,
|
|
1429
|
+
user: server.user,
|
|
1430
|
+
source: server.source,
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Turn a technical failure into a message a person can act on.
|
|
1436
|
+
* @param error - the underlying failure.
|
|
1437
|
+
* @param server - the target it happened against.
|
|
1438
|
+
* @returns the marked Remote failure.
|
|
1439
|
+
*/
|
|
1440
|
+
describe(error, server) {
|
|
1441
|
+
if (error !== null && typeof error === 'object' && error.isDSHRemoteError === true) return error
|
|
1442
|
+
const raw = error instanceof Error ? error.message : String(error)
|
|
1443
|
+
const hint = /authentication|permission denied/i.test(raw)
|
|
1444
|
+
? ' Authentication was rejected: this target needs a password or a different key.'
|
|
1445
|
+
: /timed? ?out|ETIMEDOUT/i.test(raw)
|
|
1446
|
+
? ' The host did not answer in time.'
|
|
1447
|
+
: ''
|
|
1448
|
+
return remoteFailure('ssh/connect-failed', `Cannot connect to ${server.host}:${server.port} — ${raw}.${hint}`, {
|
|
1449
|
+
host: server.host,
|
|
1450
|
+
port: server.port,
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Drop a half-open session without touching a newer one.
|
|
1457
|
+
* @param service - the owning service.
|
|
1458
|
+
* @param serverId - the target the session belongs to.
|
|
1459
|
+
* @param session - the session that failed.
|
|
1460
|
+
*/
|
|
1461
|
+
function discardSession(service, serverId, session) {
|
|
1462
|
+
if (service.sessions.get(serverId) !== session) return
|
|
1463
|
+
service.sessions.delete(serverId)
|
|
1464
|
+
try {
|
|
1465
|
+
session.client?.end()
|
|
1466
|
+
} catch {
|
|
1467
|
+
// A connection that never came up needs no teardown.
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
Object.defineProperty(SshConsoleService.prototype, REMOTE_METHOD_DESCRIPTOR, {
|
|
1472
|
+
configurable: true,
|
|
1473
|
+
value: Object.freeze({
|
|
1474
|
+
version: 1,
|
|
1475
|
+
methods: Object.freeze(REMOTE_METHODS.map((method) => Object.freeze({
|
|
1476
|
+
method,
|
|
1477
|
+
invocation: Object.freeze({ kind: 'direct' }),
|
|
1478
|
+
}))),
|
|
1479
|
+
}),
|
|
1480
|
+
})
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Register the Remote Service and close the connection when the plugin unloads.
|
|
1484
|
+
* @param ctx - Host plugin context.
|
|
1485
|
+
* @param config - the Loader row's `config` object.
|
|
1486
|
+
*/
|
|
1487
|
+
export function apply(ctx, config) {
|
|
1488
|
+
const service = new SshConsoleService(ctx, config)
|
|
1489
|
+
ctx.provide(SERVICE_KEY, service)
|
|
1490
|
+
ctx.effect(() => () => {
|
|
1491
|
+
void service.disconnect({})
|
|
1492
|
+
}, 'ssh-console: connections')
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export { SshConsoleService }
|