dsh-ssh-workbench 0.0.0-stage → 1.7.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/CHANGELOG.md +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
package/lib/core/ssh.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH transport facade.
|
|
3
|
+
*
|
|
4
|
+
* The actual connection lifecycle lives in `broker.js` (one pooled connection
|
|
5
|
+
* per server). This module keeps the stable `createSshRuntime` surface the
|
|
6
|
+
* remote-bash router and the routes data plane use, and adds:
|
|
7
|
+
* - facts TTL cache (low-level helper; no longer exposed as a model tool),
|
|
8
|
+
* - SFTP helpers on top of the pooled connection,
|
|
9
|
+
* - chunked transfer sessions on dedicated connections (long-lived by design).
|
|
10
|
+
*/
|
|
11
|
+
import { createSessionBroker } from './broker.js'
|
|
12
|
+
import { SshError, isBenignReadOnlyExit, suggestedActionFor, toSftpError } from './errors.js'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { effectiveDialect, normalizeDialect } from './dialect.js'
|
|
15
|
+
|
|
16
|
+
export { isBenignReadOnlyExit, suggestedActionFor }
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FACTS_TTL_MS = 60000
|
|
19
|
+
|
|
20
|
+
export function dialectFromProbe(stdout, exitCode) {
|
|
21
|
+
if (exitCode !== 0) return undefined
|
|
22
|
+
const text = String(stdout ?? '').trim()
|
|
23
|
+
if (!text) return undefined
|
|
24
|
+
if (/Microsoft Windows/i.test(text)) return 'windows'
|
|
25
|
+
if (/^(Linux|Darwin|FreeBSD|OpenBSD|NetBSD|DragonFly|SunOS|AIX|HP-UX|CYGWIN|MINGW|MSYS|GNU)/im.test(text)) return 'posix'
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
function serverCacheKey(server) {
|
|
32
|
+
const ref = server.authRef ?? {}
|
|
33
|
+
return `${server.host}|${server.port}|${server.username}|${ref.type ?? ''}|${ref.path ?? ''}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createSshRuntime({ getSecret, knownHosts, config = {}, log = () => {}, ClientCtor } = {}) {
|
|
37
|
+
const broker = createSessionBroker({ getSecret, knownHosts, config, log, ClientCtor })
|
|
38
|
+
const factsTtlMs = Number.isInteger(config.factsTtlMs) ? config.factsTtlMs : DEFAULT_FACTS_TTL_MS
|
|
39
|
+
const factsCache = new Map()
|
|
40
|
+
const dialectCache = new Map()
|
|
41
|
+
const timeoutGuardCache = new Map()
|
|
42
|
+
const TIMEOUT_GUARD_TTL_MS = 5 * 60 * 1000
|
|
43
|
+
const DEFAULT_TIMEOUT_GUARD = Object.freeze({ mode: 'none', shell: '/bin/sh' })
|
|
44
|
+
// The probe runs under `sh -c`, so it works no matter what the remote login
|
|
45
|
+
// shell is (fish/csh cannot parse POSIX `if ...; then`). It reports both the
|
|
46
|
+
// timeout flavour and the login shell path, so the wrapper can reference the
|
|
47
|
+
// shell as a literal instead of relying on POSIX `${SHELL:-...}` expansion.
|
|
48
|
+
const TIMEOUT_GUARD_PROBE =
|
|
49
|
+
`sh -c 'SH=\${SHELL:-/bin/sh}; ` +
|
|
50
|
+
'if command -v timeout >/dev/null 2>&1; then ' +
|
|
51
|
+
'if timeout -k 1 1 true >/dev/null 2>&1; then printf "kill|%s" "$SH"; ' +
|
|
52
|
+
'else printf "basic|%s" "$SH"; fi; ' +
|
|
53
|
+
'else printf "none|%s" "$SH"; fi\''
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse the one-line probe response (`kill|/bin/bash`, `none|/bin/sh`, ...).
|
|
57
|
+
* Unknown/garbled output degrades to `none` (no wrapper), never to a wrapper.
|
|
58
|
+
*/
|
|
59
|
+
function parseTimeoutGuard(stdout) {
|
|
60
|
+
const line = String(stdout ?? '')
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((value) => value.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.at(-1)
|
|
65
|
+
if (!line) return { ...DEFAULT_TIMEOUT_GUARD }
|
|
66
|
+
const [mode, shell] = line.split('|')
|
|
67
|
+
if (mode !== 'kill' && mode !== 'basic' && mode !== 'none') return { ...DEFAULT_TIMEOUT_GUARD }
|
|
68
|
+
const candidateShell = typeof shell === 'string' ? shell.trim() : ''
|
|
69
|
+
return { mode, shell: candidateShell.startsWith('/') ? candidateShell : DEFAULT_TIMEOUT_GUARD.shell }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect remote timeout support once per server (TTL-cached) so a timed-out
|
|
74
|
+
* command can stop the remote process group. An `exitCode` of any value means
|
|
75
|
+
* the probe actually ran on the remote, so even a "no timeout here" answer is
|
|
76
|
+
* cached; only transport failures skip the cache. Concurrent first calls
|
|
77
|
+
* share one in-flight probe.
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<{mode: 'kill'|'basic'|'none', shell: string}>}
|
|
80
|
+
*/
|
|
81
|
+
function timeoutGuard(server) {
|
|
82
|
+
if (!server || effectiveDialect(server) === 'windows') return Promise.resolve({ ...DEFAULT_TIMEOUT_GUARD })
|
|
83
|
+
const cacheKey = serverCacheKey(server)
|
|
84
|
+
const cached = timeoutGuardCache.get(server.id)
|
|
85
|
+
if (cached && cached.cacheKey === cacheKey) {
|
|
86
|
+
if (cached.pending) return cached.pending
|
|
87
|
+
if (Date.now() - cached.at < TIMEOUT_GUARD_TTL_MS) return Promise.resolve(cached.value)
|
|
88
|
+
}
|
|
89
|
+
const pending = (async () => {
|
|
90
|
+
let value = { ...DEFAULT_TIMEOUT_GUARD }
|
|
91
|
+
let cacheable = false
|
|
92
|
+
try {
|
|
93
|
+
const result = await broker.exec(server, TIMEOUT_GUARD_PROBE, {
|
|
94
|
+
timeoutMs: 15000,
|
|
95
|
+
readOnly: true,
|
|
96
|
+
cap: 4096,
|
|
97
|
+
})
|
|
98
|
+
if (result.exitCode !== null && result.exitCode !== undefined) {
|
|
99
|
+
value = parseTimeoutGuard(result.stdout)
|
|
100
|
+
cacheable = true
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Transport failure says nothing about the remote: retry next time.
|
|
104
|
+
}
|
|
105
|
+
const current = timeoutGuardCache.get(server.id)
|
|
106
|
+
if (current && current.pending === pending) {
|
|
107
|
+
if (cacheable) timeoutGuardCache.set(server.id, { cacheKey, value, at: Date.now() })
|
|
108
|
+
else timeoutGuardCache.delete(server.id)
|
|
109
|
+
}
|
|
110
|
+
return value
|
|
111
|
+
})()
|
|
112
|
+
timeoutGuardCache.set(server.id, { cacheKey, pending, at: Date.now() })
|
|
113
|
+
return pending
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const DIALECT_TTL_MS = 10 * 60 * 1000
|
|
117
|
+
const DIALECT_PROBE_TIMEOUT_MS = 8000
|
|
118
|
+
const POSIX_PROBE = 'uname -s'
|
|
119
|
+
const WINDOWS_PROBE = 'cmd /c ver'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify one dialect probe output. Both probes are single, shell-native
|
|
123
|
+
* commands, so a wrong guess only costs one failed SSH exec — never a
|
|
124
|
+
* command against the wrong dialect.
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* Detect the target dialect once per server (TTL-cached) instead of trusting
|
|
128
|
+
* the hand-maintained `platform` hint. `uname -s` answers on POSIX targets;
|
|
129
|
+
* `cmd /c ver` answers on Windows targets regardless of whether sshd's
|
|
130
|
+
* default shell is `cmd.exe` or PowerShell.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<'posix'|'windows'|undefined>}
|
|
133
|
+
*/
|
|
134
|
+
function probeDialect(server) {
|
|
135
|
+
if (!server) return Promise.resolve(undefined)
|
|
136
|
+
const cacheKey = serverCacheKey(server)
|
|
137
|
+
const cached = dialectCache.get(server.id)
|
|
138
|
+
if (cached && cached.cacheKey === cacheKey) {
|
|
139
|
+
if (cached.pending) return cached.pending
|
|
140
|
+
if (Date.now() - cached.at < DIALECT_TTL_MS) return Promise.resolve(cached.dialect)
|
|
141
|
+
}
|
|
142
|
+
const pending = (async () => {
|
|
143
|
+
let dialect
|
|
144
|
+
try {
|
|
145
|
+
const posix = await broker.exec(server, POSIX_PROBE, {
|
|
146
|
+
timeoutMs: DIALECT_PROBE_TIMEOUT_MS,
|
|
147
|
+
readOnly: true,
|
|
148
|
+
cap: 4096,
|
|
149
|
+
})
|
|
150
|
+
dialect = dialectFromProbe(posix?.stdout, posix?.exitCode ?? null)
|
|
151
|
+
} catch {
|
|
152
|
+
// Transport failure: try the other probe? No — a dead connection is a
|
|
153
|
+
// dead connection; fall through to the last-known/record hint.
|
|
154
|
+
}
|
|
155
|
+
if (!dialect) {
|
|
156
|
+
try {
|
|
157
|
+
const windows = await broker.exec(server, WINDOWS_PROBE, {
|
|
158
|
+
timeoutMs: DIALECT_PROBE_TIMEOUT_MS,
|
|
159
|
+
readOnly: true,
|
|
160
|
+
cap: 4096,
|
|
161
|
+
})
|
|
162
|
+
dialect = dialectFromProbe(windows?.stdout, windows?.exitCode ?? null)
|
|
163
|
+
} catch {
|
|
164
|
+
dialect = undefined
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const current = dialectCache.get(server.id)
|
|
168
|
+
if (current && current.pending === pending) {
|
|
169
|
+
if (dialect) dialectCache.set(server.id, { cacheKey, dialect, at: Date.now() })
|
|
170
|
+
else dialectCache.delete(server.id)
|
|
171
|
+
}
|
|
172
|
+
return dialect
|
|
173
|
+
})()
|
|
174
|
+
dialectCache.set(server.id, { cacheKey, pending, at: Date.now() })
|
|
175
|
+
return pending
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function facts(server, options = {}) {
|
|
179
|
+
const cacheKey = serverCacheKey(server)
|
|
180
|
+
const cached = factsCache.get(server.id)
|
|
181
|
+
if (
|
|
182
|
+
options.refresh !== true &&
|
|
183
|
+
cached &&
|
|
184
|
+
cached.cacheKey === cacheKey &&
|
|
185
|
+
cached.facts?.dialect &&
|
|
186
|
+
Date.now() - cached.at < factsTtlMs
|
|
187
|
+
) {
|
|
188
|
+
return {
|
|
189
|
+
outcome: 'success',
|
|
190
|
+
exitCode: 0,
|
|
191
|
+
signal: null,
|
|
192
|
+
stdout: '',
|
|
193
|
+
stderr: '',
|
|
194
|
+
durationMs: 0,
|
|
195
|
+
truncated: { stdout: false, stderr: false },
|
|
196
|
+
facts: cached.facts,
|
|
197
|
+
cached: true,
|
|
198
|
+
collectedAt: cached.collectedAt,
|
|
199
|
+
warm: true,
|
|
200
|
+
timings: { connectMs: 0, channelMs: 0, commandMs: 0, totalMs: 0 },
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Probe before collecting: the facts script itself is dialect-specific.
|
|
204
|
+
let dialect = options.dialect
|
|
205
|
+
if (dialect !== 'posix' && dialect !== 'windows') {
|
|
206
|
+
try {
|
|
207
|
+
dialect = await probeDialect(server)
|
|
208
|
+
} catch {
|
|
209
|
+
dialect = undefined
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
dialect = dialect === 'windows' ? 'windows' : dialect === 'posix' ? 'posix' : normalizeDialect(server.platform)
|
|
213
|
+
const script =
|
|
214
|
+
dialect === 'windows'
|
|
215
|
+
? 'echo __SDH_HOST__ & hostname & echo __SDH_OS__ & ver & echo __SDH_UPTIME__ & echo n/a & echo __SDH_MEM__ & wmic OS get FreePhysicalMemory /value 2>nul & echo __SDH_DISK__ & wmic logicaldisk get DeviceID,Size,FreeSpace 2>nul'
|
|
216
|
+
: "echo __SDH_HOST__; hostname 2>/dev/null; echo __SDH_OS__; uname -sr 2>/dev/null; echo __SDH_UPTIME__; (uptime -p 2>/dev/null || uptime 2>/dev/null); echo __SDH_MEM__; (free -m 2>/dev/null | sed -n '1,2p' || vm_stat 2>/dev/null | head -5); echo __SDH_DISK__; df -Pk / 2>/dev/null | tail -n 2"
|
|
217
|
+
const result = await broker.exec(server, script, {
|
|
218
|
+
timeoutMs: options.timeoutMs ?? 20000,
|
|
219
|
+
signal: options.signal,
|
|
220
|
+
readOnly: true,
|
|
221
|
+
cap: options.cap,
|
|
222
|
+
})
|
|
223
|
+
const parsed = parseFacts(result.stdout, dialect)
|
|
224
|
+
if (result.outcome === 'success') {
|
|
225
|
+
factsCache.set(server.id, {
|
|
226
|
+
cacheKey,
|
|
227
|
+
facts: parsed,
|
|
228
|
+
collectedAt: new Date().toISOString(),
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
return { ...result, facts: parsed, cached: false, collectedAt: new Date().toISOString() }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── SFTP on the pooled connection ─────────────────────────────────────────
|
|
236
|
+
function withSftp(server, handler, options = {}) {
|
|
237
|
+
return broker.withSftp(server, handler, options)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function statOf(attrs) {
|
|
241
|
+
return {
|
|
242
|
+
size: attrs.size,
|
|
243
|
+
mode: attrs.mode,
|
|
244
|
+
uid: attrs.uid,
|
|
245
|
+
gid: attrs.gid,
|
|
246
|
+
atime: attrs.atime,
|
|
247
|
+
mtime: attrs.mtime,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function sftpList(server, remotePath) {
|
|
252
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
253
|
+
sftp.readdir(remotePath, (error, entries) => {
|
|
254
|
+
if (error) return reject(toSftpError(error))
|
|
255
|
+
resolve(entries.map((entry) => ({
|
|
256
|
+
name: entry.filename,
|
|
257
|
+
path: `${remotePath.replace(/\/+$/, '')}/${entry.filename}`.replace(/^\/+/, remotePath.startsWith('/') ? '/' : ''),
|
|
258
|
+
type: entry.longname.startsWith('d') ? 'dir' : entry.longname.startsWith('l') ? 'link' : 'file',
|
|
259
|
+
longname: entry.longname,
|
|
260
|
+
...statOf(entry.attrs),
|
|
261
|
+
})))
|
|
262
|
+
})
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function sftpStat(server, remotePath) {
|
|
267
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
268
|
+
sftp.stat(remotePath, (error, attrs) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, ...statOf(attrs) })))
|
|
269
|
+
}))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function clampReadBytes(value, fallback) {
|
|
273
|
+
const hardCap = 64 * 1024 * 1024
|
|
274
|
+
return Number.isInteger(value) ? Math.max(1024, Math.min(value, hardCap)) : fallback
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read a remote file into a Buffer (binary-safe, bounded by maxBytes). */
|
|
278
|
+
async function readRemoteBuffer(server, remotePath, maxBytes) {
|
|
279
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
280
|
+
try {
|
|
281
|
+
sftp.stat(remotePath, (error, attrs) => {
|
|
282
|
+
if (error) return reject(toSftpError(error))
|
|
283
|
+
const chunks = []
|
|
284
|
+
let size = 0
|
|
285
|
+
let truncated = false
|
|
286
|
+
const stream = sftp.createReadStream(remotePath)
|
|
287
|
+
stream.on('data', (chunk) => {
|
|
288
|
+
const remaining = maxBytes - size
|
|
289
|
+
if (remaining > 0) {
|
|
290
|
+
chunks.push(chunk.subarray(0, Math.min(chunk.length, remaining)))
|
|
291
|
+
size += Math.min(chunk.length, remaining)
|
|
292
|
+
}
|
|
293
|
+
if (chunk.length > remaining) {
|
|
294
|
+
truncated = true
|
|
295
|
+
stream.destroy()
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
stream.once('error', (error2) => reject(toSftpError(error2)))
|
|
299
|
+
stream.once('close', () => {
|
|
300
|
+
const buffer = Buffer.concat(chunks)
|
|
301
|
+
resolve({
|
|
302
|
+
path: remotePath,
|
|
303
|
+
size: attrs.size,
|
|
304
|
+
bytesRead: buffer.length,
|
|
305
|
+
buffer,
|
|
306
|
+
truncated: truncated || attrs.size > buffer.length,
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
} catch (error) {
|
|
311
|
+
reject(toSftpError(error))
|
|
312
|
+
}
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function sftpRead(server, remotePath, options = {}) {
|
|
317
|
+
// 64 MiB hard cap covers downloads/previews while keeping one JSON response bounded.
|
|
318
|
+
const maxBytes = clampReadBytes(options.maxBytes, 262144)
|
|
319
|
+
const result = await readRemoteBuffer(server, remotePath, maxBytes)
|
|
320
|
+
const binary = result.buffer.includes(0)
|
|
321
|
+
const formatted = {
|
|
322
|
+
path: result.path,
|
|
323
|
+
size: result.size,
|
|
324
|
+
bytesRead: result.bytesRead,
|
|
325
|
+
truncated: result.truncated,
|
|
326
|
+
binary,
|
|
327
|
+
content: binary ? undefined : result.buffer.toString('utf8'),
|
|
328
|
+
}
|
|
329
|
+
if (options.base64 === true) formatted.base64 = result.buffer.toString('base64')
|
|
330
|
+
return formatted
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Binary-safe read used by agent upload/download flows. */
|
|
334
|
+
async function sftpReadBuffer(server, remotePath, options = {}) {
|
|
335
|
+
const maxBytes = clampReadBytes(options.maxBytes, 64 * 1024 * 1024)
|
|
336
|
+
return await readRemoteBuffer(server, remotePath, maxBytes)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function sftpWrite(server, remotePath, content) {
|
|
340
|
+
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
|
|
341
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
342
|
+
sftp.writeFile(remotePath, buffer, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, bytes: buffer.length })))
|
|
343
|
+
}))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function sftpMkdir(server, remotePath) {
|
|
347
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
348
|
+
sftp.mkdir(remotePath, (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath })))
|
|
349
|
+
}))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function sftpRemove(server, remotePath, isDir = false) {
|
|
353
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
354
|
+
const done = (error) => (error ? reject(toSftpError(error)) : resolve({ path: remotePath, removed: true }))
|
|
355
|
+
if (isDir) sftp.rmdir(remotePath, done)
|
|
356
|
+
else sftp.unlink(remotePath, done)
|
|
357
|
+
}))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function sftpRename(server, fromPath, toPath) {
|
|
361
|
+
return await withSftp(server, (sftp) => new Promise((resolve, reject) => {
|
|
362
|
+
sftp.rename(fromPath, toPath, (error) => (error ? reject(toSftpError(error)) : resolve({ from: fromPath, to: toPath })))
|
|
363
|
+
}))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── chunked transfer sessions (dedicated connection, idle-closed) ─────────
|
|
367
|
+
const transferSessions = new Map()
|
|
368
|
+
let transferSessionSeq = 0
|
|
369
|
+
const TRANSFER_IDLE_MS = 60000
|
|
370
|
+
const MAX_TRANSFER_SESSIONS = 8
|
|
371
|
+
const MAX_TRANSFER_CHUNK = 4 * 1024 * 1024
|
|
372
|
+
const TRANSFER_CHUNK_TIMEOUT_MS = Number.isInteger(config.transferChunkTimeoutMs)
|
|
373
|
+
? Math.max(100, config.transferChunkTimeoutMs)
|
|
374
|
+
: 30000
|
|
375
|
+
|
|
376
|
+
/** Accept only an absolute home path; `~`/`.` are not real answers. */
|
|
377
|
+
function normalizeSftpHome(value) {
|
|
378
|
+
if (typeof value !== 'string') return undefined
|
|
379
|
+
let trimmed = value.trim()
|
|
380
|
+
if (!trimmed || trimmed === '.' || trimmed === '~') return undefined
|
|
381
|
+
if (trimmed.startsWith('/')) {
|
|
382
|
+
// Some SFTP servers answer REALPATH "." with "/." or a trailing slash.
|
|
383
|
+
trimmed = path.posix.normalize(trimmed)
|
|
384
|
+
if (trimmed === '.' || trimmed === '/.') return undefined
|
|
385
|
+
return trimmed === '/' ? '/' : trimmed.replace(/\/+$/, '')
|
|
386
|
+
}
|
|
387
|
+
// Windows servers may report a drive-letter path.
|
|
388
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return trimmed.replace(/[\\/]+$/, '') || '/'
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve a server's login home directory.
|
|
394
|
+
*
|
|
395
|
+
* SFTP sessions start in the login home, so `realpath('.')` is the canonical
|
|
396
|
+
* home (e.g. `/root` for web-1). A few SFTP servers reject relative paths in
|
|
397
|
+
* REALPATH; fall back to `realpath('~')` (OpenSSH expands the tilde) and then
|
|
398
|
+
* to a readable `SFTP_FAILED` error. Never silently answer `/`: on a chrooted
|
|
399
|
+
* or restricted account that would point the file panel at a directory the
|
|
400
|
+
* user cannot list (U5).
|
|
401
|
+
*
|
|
402
|
+
* @param {object} server normalized inventory record
|
|
403
|
+
* @returns {Promise<string>} absolute home directory
|
|
404
|
+
*/
|
|
405
|
+
async function sftpHome(server) {
|
|
406
|
+
const label = server?.name ?? server?.id ?? 'server'
|
|
407
|
+
let lastError
|
|
408
|
+
for (const candidate of ['.', '~']) {
|
|
409
|
+
try {
|
|
410
|
+
const resolved = await withSftp(
|
|
411
|
+
server,
|
|
412
|
+
(sftp) =>
|
|
413
|
+
new Promise((resolve, reject) => {
|
|
414
|
+
try {
|
|
415
|
+
sftp.realpath(candidate, (error, resolvedPath) => {
|
|
416
|
+
if (error) return reject(toSftpError(error))
|
|
417
|
+
resolve(resolvedPath)
|
|
418
|
+
})
|
|
419
|
+
} catch (error) {
|
|
420
|
+
reject(toSftpError(error))
|
|
421
|
+
}
|
|
422
|
+
}),
|
|
423
|
+
{ timeoutMs: TRANSFER_CHUNK_TIMEOUT_MS },
|
|
424
|
+
)
|
|
425
|
+
const home = normalizeSftpHome(resolved)
|
|
426
|
+
if (home) return home
|
|
427
|
+
lastError = new Error(`realpath("${candidate}") returned ${JSON.stringify(resolved)}`)
|
|
428
|
+
} catch (error) {
|
|
429
|
+
lastError = toSftpError(error)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
throw new SshError(
|
|
433
|
+
'SFTP_FAILED',
|
|
434
|
+
`could not resolve the login home directory for "${label}": ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Guard one SFTP chunk callback with a timeout. A half-open connection must
|
|
440
|
+
* not leave the file panel stuck at "uploading" forever; on timeout the
|
|
441
|
+
* session is closed so the client can resume from its last contiguous offset.
|
|
442
|
+
*/
|
|
443
|
+
async function withTransferWatchdog(operation, label, onTimeout) {
|
|
444
|
+
let timer
|
|
445
|
+
try {
|
|
446
|
+
return await Promise.race([
|
|
447
|
+
operation,
|
|
448
|
+
new Promise((_resolve, reject) => {
|
|
449
|
+
timer = setTimeout(() => {
|
|
450
|
+
try {
|
|
451
|
+
onTimeout?.()
|
|
452
|
+
} catch {
|
|
453
|
+
// closing a broken session is best effort
|
|
454
|
+
}
|
|
455
|
+
reject(new SshError('SFTP_TIMEOUT', `${label} timed out after ${TRANSFER_CHUNK_TIMEOUT_MS}ms`))
|
|
456
|
+
}, TRANSFER_CHUNK_TIMEOUT_MS)
|
|
457
|
+
// Keep the watchdog referenced while the chunk is in flight: an
|
|
458
|
+
// unref'd timer lets a test/host process exit before the timeout
|
|
459
|
+
// can fire, which both hides the error and leaks the pending promise.
|
|
460
|
+
}),
|
|
461
|
+
])
|
|
462
|
+
} finally {
|
|
463
|
+
clearTimeout(timer)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function armTransferIdle(session) {
|
|
468
|
+
if (session.timer) clearTimeout(session.timer)
|
|
469
|
+
session.timer = setTimeout(() => {
|
|
470
|
+
void closeTransferSession(session.id, true)
|
|
471
|
+
}, TRANSFER_IDLE_MS)
|
|
472
|
+
if (session.timer && typeof session.timer.unref === 'function') session.timer.unref()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function openTransferConnection(server) {
|
|
476
|
+
const { client } = await broker.connectDedicated(server)
|
|
477
|
+
try {
|
|
478
|
+
const sftp = await new Promise((resolve, reject) => {
|
|
479
|
+
client.sftp((error, session) => (error ? reject(toSftpError(error)) : resolve(session)))
|
|
480
|
+
})
|
|
481
|
+
return { connection: { client }, sftp }
|
|
482
|
+
} catch (error) {
|
|
483
|
+
try {
|
|
484
|
+
client.end()
|
|
485
|
+
} catch {
|
|
486
|
+
// already closed
|
|
487
|
+
}
|
|
488
|
+
throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function openRemoteHandle(sftp, remotePath, flags) {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
sftp.open(remotePath, flags, (error, handle) => (error ? reject(toSftpError(error)) : resolve(handle)))
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function closeTransferSession(id, silent = false, options = {}) {
|
|
499
|
+
const session = transferSessions.get(id)
|
|
500
|
+
if (!session) {
|
|
501
|
+
if (silent) return undefined
|
|
502
|
+
throw new SshError('TRANSFER_SESSION', `unknown transfer session ${id}`)
|
|
503
|
+
}
|
|
504
|
+
transferSessions.delete(id)
|
|
505
|
+
if (session.timer) clearTimeout(session.timer)
|
|
506
|
+
try {
|
|
507
|
+
if (session.handle) {
|
|
508
|
+
await new Promise((resolve) => session.sftp.close(session.handle, () => resolve()))
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// closing a broken handle is best effort
|
|
512
|
+
}
|
|
513
|
+
let verified = false
|
|
514
|
+
try {
|
|
515
|
+
// An explicit close of an upload verifies the remote size, so a dropped
|
|
516
|
+
// final chunk cannot be reported as a successful transfer.
|
|
517
|
+
if (!silent && options.verify !== false && session.kind === 'write' && session.bytes > 0) {
|
|
518
|
+
const attrs = await new Promise((resolve, reject) => {
|
|
519
|
+
try {
|
|
520
|
+
session.sftp.stat(session.path, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
|
|
521
|
+
} catch (error) {
|
|
522
|
+
reject(toSftpError(error))
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
if (Number.isInteger(attrs?.size) && attrs.size !== session.bytes) {
|
|
526
|
+
throw new SshError(
|
|
527
|
+
'SFTP_SIZE_MISMATCH',
|
|
528
|
+
`remote size ${attrs.size} does not match the ${session.bytes} bytes written to ${session.path}`,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
verified = true
|
|
532
|
+
}
|
|
533
|
+
} finally {
|
|
534
|
+
try {
|
|
535
|
+
session.connection.client.end()
|
|
536
|
+
} catch {
|
|
537
|
+
// already closed
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { session: id, path: session.path, bytes: session.bytes ?? 0, server: session.server, ...(verified ? { verified: true } : {}) }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function sftpOpenWrite(server, remotePath, truncate = true) {
|
|
544
|
+
const { connection, sftp } = await openTransferConnection(server)
|
|
545
|
+
try {
|
|
546
|
+
// 'w' truncates; 'r+' keeps the existing .part bytes for resume.
|
|
547
|
+
const handle = await openRemoteHandle(sftp, remotePath, truncate ? 'w' : 'r+')
|
|
548
|
+
const id = `sftp-write-${++transferSessionSeq}`
|
|
549
|
+
const session = {
|
|
550
|
+
id,
|
|
551
|
+
kind: 'write',
|
|
552
|
+
server: server.id,
|
|
553
|
+
path: remotePath,
|
|
554
|
+
sftp,
|
|
555
|
+
connection,
|
|
556
|
+
handle,
|
|
557
|
+
bytes: 0,
|
|
558
|
+
touchedAt: Date.now(),
|
|
559
|
+
}
|
|
560
|
+
transferSessions.set(id, session)
|
|
561
|
+
if (transferSessions.size > MAX_TRANSFER_SESSIONS) {
|
|
562
|
+
const oldest = [...transferSessions.values()]
|
|
563
|
+
.filter((entry) => entry.id !== id)
|
|
564
|
+
.sort((left, right) => left.touchedAt - right.touchedAt)[0]
|
|
565
|
+
if (oldest) await closeTransferSession(oldest.id, true)
|
|
566
|
+
}
|
|
567
|
+
armTransferIdle(session)
|
|
568
|
+
return { session: id, path: remotePath }
|
|
569
|
+
} catch (error) {
|
|
570
|
+
try {
|
|
571
|
+
connection.client.end()
|
|
572
|
+
} catch {
|
|
573
|
+
// already closed
|
|
574
|
+
}
|
|
575
|
+
throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function sftpWriteChunk(id, offset, buffer) {
|
|
580
|
+
const session = transferSessions.get(id)
|
|
581
|
+
if (!session || session.kind !== 'write') throw new SshError('TRANSFER_SESSION', `write session '${id}' is not open`)
|
|
582
|
+
if (!Buffer.isBuffer(buffer)) throw new SshError('TRANSFER_FAILED', 'write chunk must be a Buffer')
|
|
583
|
+
if (buffer.length > MAX_TRANSFER_CHUNK) throw new SshError('TRANSFER_FAILED', `chunk exceeds ${MAX_TRANSFER_CHUNK} bytes`)
|
|
584
|
+
const position = Number.isInteger(offset) && offset >= 0 ? offset : 0
|
|
585
|
+
await withTransferWatchdog(
|
|
586
|
+
new Promise((resolve, reject) => {
|
|
587
|
+
session.sftp.write(session.handle, buffer, 0, buffer.length, position, (error) => (error ? reject(toSftpError(error)) : resolve()))
|
|
588
|
+
}),
|
|
589
|
+
'SFTP write chunk',
|
|
590
|
+
() => {
|
|
591
|
+
void closeTransferSession(id, true)
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
session.bytes = Math.max(session.bytes, position + buffer.length)
|
|
595
|
+
session.touchedAt = Date.now()
|
|
596
|
+
armTransferIdle(session)
|
|
597
|
+
return { session: id, bytes: buffer.length }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function sftpOpenRead(server, remotePath) {
|
|
601
|
+
const { connection, sftp } = await openTransferConnection(server)
|
|
602
|
+
try {
|
|
603
|
+
const handle = await openRemoteHandle(sftp, remotePath, 'r')
|
|
604
|
+
const attrs = await new Promise((resolve, reject) => {
|
|
605
|
+
sftp.fstat(handle, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
|
|
606
|
+
})
|
|
607
|
+
const id = `sftp-read-${++transferSessionSeq}`
|
|
608
|
+
const session = {
|
|
609
|
+
id,
|
|
610
|
+
kind: 'read',
|
|
611
|
+
server: server.id,
|
|
612
|
+
path: remotePath,
|
|
613
|
+
sftp,
|
|
614
|
+
connection,
|
|
615
|
+
handle,
|
|
616
|
+
size: attrs.size,
|
|
617
|
+
mtime: attrs.mtime,
|
|
618
|
+
touchedAt: Date.now(),
|
|
619
|
+
}
|
|
620
|
+
transferSessions.set(id, session)
|
|
621
|
+
if (transferSessions.size > MAX_TRANSFER_SESSIONS) {
|
|
622
|
+
const oldest = [...transferSessions.values()]
|
|
623
|
+
.filter((entry) => entry.id !== id)
|
|
624
|
+
.sort((left, right) => left.touchedAt - right.touchedAt)[0]
|
|
625
|
+
if (oldest) await closeTransferSession(oldest.id, true)
|
|
626
|
+
}
|
|
627
|
+
armTransferIdle(session)
|
|
628
|
+
return { session: id, path: remotePath, size: attrs.size, mtime: attrs.mtime }
|
|
629
|
+
} catch (error) {
|
|
630
|
+
try {
|
|
631
|
+
connection.client.end()
|
|
632
|
+
} catch {
|
|
633
|
+
// already closed
|
|
634
|
+
}
|
|
635
|
+
throw error instanceof SshError ? error : new SshError('SFTP_FAILED', error instanceof Error ? error.message : String(error))
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function sftpReadChunk(id, offset, length) {
|
|
640
|
+
const session = transferSessions.get(id)
|
|
641
|
+
if (!session || session.kind !== 'read') throw new SshError('TRANSFER_SESSION', `read session '${id}' is not open`)
|
|
642
|
+
const position = Number.isInteger(offset) && offset >= 0 ? offset : 0
|
|
643
|
+
const wanted = Number.isInteger(length) && length > 0 ? Math.min(length, MAX_TRANSFER_CHUNK) : 256 * 1024
|
|
644
|
+
const buffer = Buffer.alloc(wanted)
|
|
645
|
+
const bytesRead = await withTransferWatchdog(
|
|
646
|
+
new Promise((resolve, reject) => {
|
|
647
|
+
session.sftp.read(session.handle, buffer, 0, wanted, position, (error, value) => (error ? reject(toSftpError(error)) : resolve(value)))
|
|
648
|
+
}),
|
|
649
|
+
'SFTP read chunk',
|
|
650
|
+
() => {
|
|
651
|
+
void closeTransferSession(id, true)
|
|
652
|
+
},
|
|
653
|
+
)
|
|
654
|
+
session.touchedAt = Date.now()
|
|
655
|
+
armTransferIdle(session)
|
|
656
|
+
return { session: id, bytesRead, base64: buffer.subarray(0, bytesRead).toString('base64') }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
/** Close every chunked transfer session that belongs to one server (config change/removal). */
|
|
662
|
+
async function closeTransferSessionsForServer(serverId) {
|
|
663
|
+
let closed = 0
|
|
664
|
+
for (const [id, session] of [...transferSessions.entries()]) {
|
|
665
|
+
if (session.server !== serverId) continue
|
|
666
|
+
try {
|
|
667
|
+
await closeTransferSession(id, true)
|
|
668
|
+
closed += 1
|
|
669
|
+
} catch {
|
|
670
|
+
// best effort: the server record is already being replaced/removed
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return closed
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function close() {
|
|
677
|
+
for (const id of [...transferSessions.keys()]) {
|
|
678
|
+
try {
|
|
679
|
+
await closeTransferSession(id, true)
|
|
680
|
+
} catch {
|
|
681
|
+
// best effort
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
await broker.closeAll()
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
preload: () => broker.preload(),
|
|
689
|
+
exec: (server, command, options) => broker.exec(server, command, options),
|
|
690
|
+
test: async (server) => {
|
|
691
|
+
const result = await broker.test(server)
|
|
692
|
+
if (!result?.ok) return result
|
|
693
|
+
// Report the probed dialect, not the record hint: the UI and the target
|
|
694
|
+
// context both use this value to pick the command syntax.
|
|
695
|
+
let dialect
|
|
696
|
+
try {
|
|
697
|
+
dialect = await probeDialect(server)
|
|
698
|
+
} catch {
|
|
699
|
+
dialect = undefined
|
|
700
|
+
}
|
|
701
|
+
return dialect ? { ...result, platform: dialect, dialect } : result
|
|
702
|
+
},
|
|
703
|
+
facts,
|
|
704
|
+
probeDialect,
|
|
705
|
+
timeoutGuard,
|
|
706
|
+
shell: (server, options) => broker.shell(server, options),
|
|
707
|
+
withSftp,
|
|
708
|
+
sftpList,
|
|
709
|
+
sftpStat,
|
|
710
|
+
sftpHome,
|
|
711
|
+
sftpRead,
|
|
712
|
+
sftpReadBuffer,
|
|
713
|
+
sftpWrite,
|
|
714
|
+
sftpMkdir,
|
|
715
|
+
sftpRemove,
|
|
716
|
+
sftpRename,
|
|
717
|
+
sftpOpenWrite,
|
|
718
|
+
sftpWriteChunk,
|
|
719
|
+
sftpOpenRead,
|
|
720
|
+
sftpReadChunk,
|
|
721
|
+
sftpCloseTransfer: (id, options) => closeTransferSession(id, false, options),
|
|
722
|
+
closeTransferSessionsForServer,
|
|
723
|
+
warm: (server) => broker.warm(server),
|
|
724
|
+
invalidate: (serverId) => {
|
|
725
|
+
factsCache.delete(serverId)
|
|
726
|
+
dialectCache.delete(serverId)
|
|
727
|
+
timeoutGuardCache.delete(serverId)
|
|
728
|
+
broker.invalidate(serverId)
|
|
729
|
+
},
|
|
730
|
+
close,
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function parseFacts(stdout, platform = 'posix') {
|
|
735
|
+
const sections = {}
|
|
736
|
+
let current = null
|
|
737
|
+
for (const line of String(stdout ?? '').split(/\r?\n/)) {
|
|
738
|
+
const marker = /^__SDH_([A-Z]+)__$/.exec(line.trim())
|
|
739
|
+
if (marker) {
|
|
740
|
+
current = marker[1].toLowerCase()
|
|
741
|
+
sections[current] = []
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
if (current) sections[current].push(line)
|
|
745
|
+
}
|
|
746
|
+
const clean = (key) => (sections[key] ?? []).map((line) => line.trim()).filter(Boolean).join(' | ')
|
|
747
|
+
const dialect = normalizeDialect(platform)
|
|
748
|
+
const facts = {
|
|
749
|
+
platform: dialect,
|
|
750
|
+
dialect,
|
|
751
|
+
collectedAt: new Date().toISOString(),
|
|
752
|
+
}
|
|
753
|
+
if (clean('host')) facts.hostname = clean('host')
|
|
754
|
+
if (clean('os')) facts.kernel = clean('os')
|
|
755
|
+
if (clean('uptime')) facts.uptime = clean('uptime')
|
|
756
|
+
if (clean('mem')) facts.memory = clean('mem').slice(0, 500)
|
|
757
|
+
if (clean('disk')) facts.rootDisk = clean('disk').slice(0, 500)
|
|
758
|
+
return facts
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|