dsh-winrm 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/README.zh.md +14 -7
- package/lib/client.js +73 -73
- package/lib/index.js +1184 -134
- package/lib/types/engine/client.d.ts +53 -6
- package/lib/types/engine/wsman.d.ts +153 -0
- package/lib/types/engine.d.ts +4 -4
- package/lib/types/powershell.d.ts +194 -27
- package/lib/types/smb.d.ts +24 -5
- package/package.json +17 -16
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +460 -24
- package/src/engine/wsman.ts +357 -0
- package/src/engine.ts +104 -40
- package/src/powershell.ts +491 -60
- package/src/smb.ts +57 -5
- package/src/tools.ts +2 -2
package/src/engine/client.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* WinRM transport bridge.
|
|
3
3
|
*
|
|
4
4
|
* Runs each PowerShell payload over a native Node.js WinRM client
|
|
5
|
-
* (winrm-client) — no Python/pywinrm subprocess. The script
|
|
6
|
-
*
|
|
7
|
-
* any codepage output survives the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
|
|
6
|
+
* the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
|
|
7
|
+
* Chinese and any codepage output survives the transport losslessly and the
|
|
8
|
+
* command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
|
|
9
|
+
* mangle. See powershell.ts for why the command line cannot carry the script.
|
|
10
10
|
*
|
|
11
11
|
* Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
|
|
12
12
|
* first, then Basic) with fallback, so a host that only enables one scheme
|
|
@@ -14,10 +14,28 @@
|
|
|
14
14
|
* never appear in a child-process argument list.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { createGunzip, gzipSync } from 'node:zlib'
|
|
17
18
|
import { Command, Shell } from 'winrm-client'
|
|
18
19
|
import type { WinHostEntry } from '../protocol.ts'
|
|
19
|
-
import {
|
|
20
|
-
|
|
20
|
+
import {
|
|
21
|
+
buildStreamFrame,
|
|
22
|
+
parseEnvelope,
|
|
23
|
+
powershellCommandLine,
|
|
24
|
+
psFileSize,
|
|
25
|
+
psReadChunk,
|
|
26
|
+
psReceiveStream,
|
|
27
|
+
psSendStream,
|
|
28
|
+
psWriteChunk,
|
|
29
|
+
scriptCommandLine,
|
|
30
|
+
scriptPayload,
|
|
31
|
+
splitScriptPayload,
|
|
32
|
+
STREAM_FRAME_GZIP,
|
|
33
|
+
STREAM_FRAME_HEADER,
|
|
34
|
+
STREAM_FRAME_RAW,
|
|
35
|
+
streamCommandLine,
|
|
36
|
+
stripClixml,
|
|
37
|
+
} from '../powershell.ts'
|
|
38
|
+
import { isEnvelopeTooLarge, loadSendHttp, receiveOutput, sendStdinChunk, type WsmanTarget } from './wsman.ts'
|
|
21
39
|
/** Connection parameters for one target (transport-agnostic projection of a host entry). */
|
|
22
40
|
export interface WinRMParams {
|
|
23
41
|
host: string
|
|
@@ -86,11 +104,11 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
|
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
/**
|
|
89
|
-
* One shell lifecycle: create → run the
|
|
90
|
-
* command completes → delete. Bounded by
|
|
91
|
-
* delete is fired without blocking
|
|
107
|
+
* One shell lifecycle: create → run the constant envelope script → push the
|
|
108
|
+
* payload on stdin → receive until the command completes → delete. Bounded by
|
|
109
|
+
* an overall deadline; on timeout the delete is fired without blocking.
|
|
92
110
|
* @param conn - connection parameters.
|
|
93
|
-
* @param
|
|
111
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
94
112
|
* @param timeoutMs - command execution budget measured from just after Execute.
|
|
95
113
|
* @param authMethod - the auth scheme for this attempt.
|
|
96
114
|
* @returns stdout/stderr captured from the WinRS streams.
|
|
@@ -98,7 +116,7 @@ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
|
|
|
98
116
|
*/
|
|
99
117
|
async function attemptOnce(
|
|
100
118
|
conn: WinRMParams,
|
|
101
|
-
|
|
119
|
+
payload: string,
|
|
102
120
|
timeoutMs: number,
|
|
103
121
|
authMethod: WinrmAuth,
|
|
104
122
|
): Promise<{ stdout: string; stderr: string }> {
|
|
@@ -117,8 +135,10 @@ async function attemptOnce(
|
|
|
117
135
|
void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
118
136
|
throw new WinrmTimedOut()
|
|
119
137
|
}
|
|
120
|
-
const
|
|
121
|
-
const
|
|
138
|
+
const commandId = await Command.doExecuteCommand({ ...base, shellId, command: powershellCommandLine() })
|
|
139
|
+
for (const piece of splitScriptPayload(payload)) {
|
|
140
|
+
await Command.doSendInput({ ...base, shellId, commandId, input: piece })
|
|
141
|
+
}
|
|
122
142
|
const receive = { ...base, shellId, commandId }
|
|
123
143
|
const commandDeadline = Date.now() + timeoutMs
|
|
124
144
|
let stdout = ''
|
|
@@ -156,21 +176,21 @@ async function attemptOnce(
|
|
|
156
176
|
* succeeds. A timeout aborts immediately (retrying auth would waste the
|
|
157
177
|
* remaining budget on a reachable-but-slow host).
|
|
158
178
|
* @param conn - connection parameters.
|
|
159
|
-
* @param
|
|
179
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
160
180
|
* @param timeoutMs - command execution budget.
|
|
161
181
|
* @returns captured stdout/stderr.
|
|
162
182
|
* @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
|
|
163
183
|
*/
|
|
164
184
|
async function runWinrm(
|
|
165
185
|
conn: WinRMParams,
|
|
166
|
-
|
|
186
|
+
payload: string,
|
|
167
187
|
timeoutMs: number,
|
|
168
188
|
): Promise<{ stdout: string; stderr: string }> {
|
|
169
189
|
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
170
190
|
let lastError: unknown
|
|
171
191
|
for (const authMethod of candidates) {
|
|
172
192
|
try {
|
|
173
|
-
return await attemptOnce(conn,
|
|
193
|
+
return await attemptOnce(conn, payload, timeoutMs, authMethod)
|
|
174
194
|
} catch (error) {
|
|
175
195
|
if (error instanceof WinrmTimedOut) throw error
|
|
176
196
|
lastError = error
|
|
@@ -186,11 +206,11 @@ export async function runScript(
|
|
|
186
206
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; durationMs: number }> {
|
|
187
207
|
const started = Date.now()
|
|
188
208
|
const timeoutMs = options.timeoutMs ?? 60_000
|
|
189
|
-
const
|
|
209
|
+
const payload = scriptPayload(script)
|
|
190
210
|
|
|
191
211
|
let outcome: { stdout: string; stderr: string }
|
|
192
212
|
try {
|
|
193
|
-
outcome = await runWinrm(conn,
|
|
213
|
+
outcome = await runWinrm(conn, payload, timeoutMs)
|
|
194
214
|
} catch (error) {
|
|
195
215
|
if (error instanceof WinrmTimedOut) {
|
|
196
216
|
return { stdout: '', stderr: '', exitCode: null, timedOut: true, durationMs: Date.now() - started }
|
|
@@ -207,10 +227,10 @@ export async function runScript(
|
|
|
207
227
|
const exitCode = parsed !== null ? parsed.exitCode : 1
|
|
208
228
|
// A script that reached the envelope leaves only the host's CLIXML records
|
|
209
229
|
// on stderr; an aborted one keeps its stderr untouched for diagnosis.
|
|
210
|
-
const stderr = parsed !== null ? stripClixml(
|
|
230
|
+
const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr
|
|
211
231
|
options.onChunk?.({ output: raw, stderr })
|
|
212
232
|
return {
|
|
213
|
-
stdout: parsed !== null ? parsed.
|
|
233
|
+
stdout: parsed !== null ? parsed.stdout : raw,
|
|
214
234
|
stderr,
|
|
215
235
|
exitCode,
|
|
216
236
|
timedOut: false,
|
|
@@ -242,10 +262,18 @@ export async function testConnection(conn: WinRMParams): Promise<{ ok: boolean;
|
|
|
242
262
|
|
|
243
263
|
export const TRANSFER_CHUNK = 48 * 1024
|
|
244
264
|
|
|
245
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Size of a remote file, or null when the path does not exist or is a
|
|
267
|
+
* directory. The distinction matters: a caller that treats "absent" as "empty"
|
|
268
|
+
* writes an empty local file and reports success for a download that copied
|
|
269
|
+
* nothing.
|
|
270
|
+
*/
|
|
271
|
+
export async function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs = 60_000): Promise<number | null> {
|
|
246
272
|
const result = await runScript(conn, psFileSize(remotePath), { timeoutMs })
|
|
247
|
-
const
|
|
248
|
-
|
|
273
|
+
const text = result.stdout.trim()
|
|
274
|
+
if (text === '') return null
|
|
275
|
+
const value = Number.parseInt(text, 10)
|
|
276
|
+
return Number.isFinite(value) && value >= 0 ? value : null
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
export async function downloadChunks(
|
|
@@ -256,6 +284,7 @@ export async function downloadChunks(
|
|
|
256
284
|
timeoutMs = 60_000,
|
|
257
285
|
): Promise<number> {
|
|
258
286
|
const total = await remoteFileSize(conn, remotePath, timeoutMs)
|
|
287
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`)
|
|
259
288
|
if (total === 0) return 0
|
|
260
289
|
let offset = 0
|
|
261
290
|
for (;;) {
|
|
@@ -289,3 +318,410 @@ export async function uploadBuffer(
|
|
|
289
318
|
}
|
|
290
319
|
return written
|
|
291
320
|
}
|
|
321
|
+
|
|
322
|
+
/** Raw slice size for the streaming path: 256 KiB, comfortably inside `MAX_SEND_SLICE_BYTES`. */
|
|
323
|
+
const STREAM_CHUNK_BYTES = 262_144
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Compress a slice only when it actually shrinks.
|
|
327
|
+
*
|
|
328
|
+
* Gzip on already-compressed input (zips, images, installers) produces output
|
|
329
|
+
* marginally *larger* than its input, and the remote side then pays to inflate
|
|
330
|
+
* it for nothing — measured at ~3.8x the wall time of sending the same bytes
|
|
331
|
+
* raw. Local compression is cheap (~3.4 ms per 256 KiB even when it fails to
|
|
332
|
+
* compress), so probing every slice costs almost nothing and the ratio is the
|
|
333
|
+
* only thing that decides.
|
|
334
|
+
*/
|
|
335
|
+
const STREAM_GZIP_MIN_RATIO = 0.85
|
|
336
|
+
|
|
337
|
+
/** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
|
|
338
|
+
const STREAM_SEND_TIMEOUT_MS = 120_000
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One streaming transfer over a single shell: create → run the receiver →
|
|
342
|
+
* push the receiver script, the header frame and every data frame down stdin →
|
|
343
|
+
* receive the envelope → delete the shell.
|
|
344
|
+
*
|
|
345
|
+
* The receiver script is the first thing on stdin because it does not fit the
|
|
346
|
+
* command line (see `streamCommandLine`); the bootstrap in the command line
|
|
347
|
+
* reads it and runs it in-process so it can keep reading the same stream.
|
|
348
|
+
* @param conn - connection parameters.
|
|
349
|
+
* @param remotePath - destination path on the target.
|
|
350
|
+
* @param data - the file contents to send.
|
|
351
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
352
|
+
* @param timeoutMs - whole-transfer budget.
|
|
353
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
354
|
+
* @param sendHttp - the resolved library send function.
|
|
355
|
+
* @returns the number of file bytes the receiver acknowledged writing.
|
|
356
|
+
* @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
|
|
357
|
+
*/
|
|
358
|
+
async function streamOnce(
|
|
359
|
+
conn: WinRMParams,
|
|
360
|
+
remotePath: string,
|
|
361
|
+
data: Buffer,
|
|
362
|
+
onProgress: ((transferred: number, total: number) => void) | undefined,
|
|
363
|
+
timeoutMs: number,
|
|
364
|
+
authMethod: WinrmAuth,
|
|
365
|
+
sendHttp: ReturnType<typeof loadSendHttp> & object,
|
|
366
|
+
): Promise<number> {
|
|
367
|
+
const base = baseParams(conn, authMethod)
|
|
368
|
+
const target: WsmanTarget = {
|
|
369
|
+
host: conn.host,
|
|
370
|
+
port: conn.port,
|
|
371
|
+
path: conn.path,
|
|
372
|
+
username: conn.username,
|
|
373
|
+
password: conn.password,
|
|
374
|
+
authMethod,
|
|
375
|
+
useHttps: conn.useHttps ?? false,
|
|
376
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true,
|
|
377
|
+
}
|
|
378
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
|
|
379
|
+
const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
|
|
380
|
+
const total = data.length
|
|
381
|
+
|
|
382
|
+
const inner = async (): Promise<number> => {
|
|
383
|
+
const shellId = await Shell.doCreateShell(base)
|
|
384
|
+
state.shellId = shellId
|
|
385
|
+
if (state.settled) {
|
|
386
|
+
void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
387
|
+
throw new WinrmTimedOut()
|
|
388
|
+
}
|
|
389
|
+
const commandId = await Command.doExecuteCommand({ ...base, shellId, command: streamCommandLine() })
|
|
390
|
+
|
|
391
|
+
// 1. The receiver itself, in the `<byteCount>\n<script>` shape the
|
|
392
|
+
// bootstrap expects. This is not a DSHZ frame — it is what the
|
|
393
|
+
// command line's bootstrap reads before any framing starts.
|
|
394
|
+
await sendStdinChunk(target, shellId, commandId, Buffer.from(scriptPayload(psReceiveStream()), 'utf8'), sendHttp, STREAM_SEND_TIMEOUT_MS)
|
|
395
|
+
|
|
396
|
+
// 2. The transfer header: total byte count and destination path, which is
|
|
397
|
+
// how the receiver learns both without them touching the command line.
|
|
398
|
+
const header = Buffer.from(String(total) + '\n' + remotePath, 'utf8')
|
|
399
|
+
await sendStdinChunk(target, shellId, commandId, buildStreamFrame(header, STREAM_FRAME_HEADER), sendHttp, STREAM_SEND_TIMEOUT_MS)
|
|
400
|
+
|
|
401
|
+
// 3. Data frames. Slice size halves on a size rejection and recovers
|
|
402
|
+
// afterwards, so a host with a smaller envelope ceiling still converges
|
|
403
|
+
// without the caller knowing its limit.
|
|
404
|
+
let offset = 0
|
|
405
|
+
let slice = STREAM_CHUNK_BYTES
|
|
406
|
+
let ceiling = STREAM_CHUNK_BYTES
|
|
407
|
+
while (offset < total) {
|
|
408
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
|
|
409
|
+
const take = Math.min(slice, total - offset)
|
|
410
|
+
const chunk = data.subarray(offset, offset + take)
|
|
411
|
+
const gz = gzipSync(chunk, { level: 6 })
|
|
412
|
+
const compress = gz.length < chunk.length * STREAM_GZIP_MIN_RATIO
|
|
413
|
+
const frame = buildStreamFrame(compress ? gz : chunk, compress ? STREAM_FRAME_GZIP : STREAM_FRAME_RAW)
|
|
414
|
+
try {
|
|
415
|
+
await sendStdinChunk(target, shellId, commandId, frame, sendHttp, STREAM_SEND_TIMEOUT_MS)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (isEnvelopeTooLarge(error) && slice > 16_384) {
|
|
418
|
+
slice = Math.max(16_384, Math.floor(slice / 2))
|
|
419
|
+
ceiling = slice
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
throw error
|
|
423
|
+
}
|
|
424
|
+
offset += take
|
|
425
|
+
onProgress?.(offset, total)
|
|
426
|
+
if (slice < ceiling) slice = Math.min(ceiling, slice * 2)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 4. The receiver stops on its own once the declared byte count lands, so
|
|
430
|
+
// there is nothing to close — just wait for its envelope.
|
|
431
|
+
const receive = { ...base, shellId, commandId }
|
|
432
|
+
const commandDeadline = Date.now() + timeoutMs
|
|
433
|
+
let stdout = ''
|
|
434
|
+
let stderr = ''
|
|
435
|
+
for (;;) {
|
|
436
|
+
if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
|
|
437
|
+
const chunk = await Command.doReceiveOutputNonBlocking(receive)
|
|
438
|
+
stdout += chunk.output
|
|
439
|
+
stderr += chunk.stderr
|
|
440
|
+
if (chunk.isComplete) break
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const parsed = parseEnvelope(stdout)
|
|
444
|
+
if (parsed === null) {
|
|
445
|
+
throw new Error('streaming upload did not report completion: ' + (stripClixml(stderr) || stdout).trim())
|
|
446
|
+
}
|
|
447
|
+
if (parsed.exitCode !== 0) {
|
|
448
|
+
throw new Error('streaming upload failed on the target: ' + parsed.stdout.trim())
|
|
449
|
+
}
|
|
450
|
+
const written = Number.parseInt(parsed.stdout.trim(), 10)
|
|
451
|
+
if (!Number.isFinite(written) || written !== total) {
|
|
452
|
+
throw new Error(`streaming upload wrote ${parsed.stdout.trim()} of ${total} bytes`)
|
|
453
|
+
}
|
|
454
|
+
return written
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
return await raceDeadline(inner(), hardDeadline - Date.now())
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
|
|
461
|
+
state.timedOut = true
|
|
462
|
+
throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut()
|
|
463
|
+
}
|
|
464
|
+
throw error
|
|
465
|
+
} finally {
|
|
466
|
+
state.settled = true
|
|
467
|
+
const shellId = state.shellId
|
|
468
|
+
if (shellId !== undefined) {
|
|
469
|
+
const deletion = Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
470
|
+
if (state.timedOut) void deletion
|
|
471
|
+
else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)])
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Upload a buffer over a single WinRS stdin stream.
|
|
478
|
+
*
|
|
479
|
+
* This replaces the per-chunk shell cycle: one shell, one command and one
|
|
480
|
+
* `Send` per 256 KiB, against a receiver that drains the stream straight into
|
|
481
|
+
* the file. Measured at ~10 s for 2 MiB where the old path took 310 s.
|
|
482
|
+
*
|
|
483
|
+
* Returns null — rather than throwing — when the transport this path depends
|
|
484
|
+
* on is unavailable (a future `winrm-client` layout that drops the internal
|
|
485
|
+
* module it borrows), so the caller can fall back to {@link uploadBuffer}
|
|
486
|
+
* instead of failing the transfer.
|
|
487
|
+
* @param conn - connection parameters.
|
|
488
|
+
* @param remotePath - destination path on the target.
|
|
489
|
+
* @param data - the file contents to send.
|
|
490
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
491
|
+
* @param timeoutMs - whole-transfer budget.
|
|
492
|
+
* @returns bytes written, or null when the streaming path is unavailable.
|
|
493
|
+
*/
|
|
494
|
+
export async function uploadStream(
|
|
495
|
+
conn: WinRMParams,
|
|
496
|
+
remotePath: string,
|
|
497
|
+
data: Buffer,
|
|
498
|
+
onProgress?: (transferred: number, total: number) => void,
|
|
499
|
+
timeoutMs = 600_000,
|
|
500
|
+
): Promise<number | null> {
|
|
501
|
+
const sendHttp = loadSendHttp()
|
|
502
|
+
if (sendHttp === null) return null
|
|
503
|
+
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
504
|
+
let lastError: unknown
|
|
505
|
+
for (const authMethod of candidates) {
|
|
506
|
+
try {
|
|
507
|
+
return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp)
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error instanceof WinrmTimedOut) throw error
|
|
510
|
+
lastError = error
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Smallest byte range worth giving a worker; below this the shell setup dominates. */
|
|
517
|
+
const DOWNLOAD_MIN_RANGE_BYTES = 524_288
|
|
518
|
+
/** Most shells to open for one download. Measured 3.9x on 16 MiB at four; more adds target load. */
|
|
519
|
+
const DOWNLOAD_MAX_WORKERS = 4
|
|
520
|
+
/** Per-Receive HTTP timeout. The server long-polls, so this only bounds a stalled transport. */
|
|
521
|
+
const DOWNLOAD_RECEIVE_TIMEOUT_MS = 120_000
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* One worker's slice of a download: own shell, own command, own byte range.
|
|
525
|
+
* @param conn - connection parameters.
|
|
526
|
+
* @param remotePath - file being read.
|
|
527
|
+
* @param start - first byte offset of this worker's range.
|
|
528
|
+
* @param length - bytes this worker must deliver.
|
|
529
|
+
* @param onData - receives each decompressed piece together with its file offset.
|
|
530
|
+
* @param timeoutMs - budget for the whole range.
|
|
531
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
532
|
+
* @param sendHttp - the resolved library HTTP function.
|
|
533
|
+
* @returns the number of file bytes this worker delivered.
|
|
534
|
+
*/
|
|
535
|
+
async function downloadWorkerOnce(
|
|
536
|
+
conn: WinRMParams,
|
|
537
|
+
remotePath: string,
|
|
538
|
+
start: number,
|
|
539
|
+
length: number,
|
|
540
|
+
onData: (offset: number, data: Buffer) => void,
|
|
541
|
+
timeoutMs: number,
|
|
542
|
+
authMethod: WinrmAuth,
|
|
543
|
+
sendHttp: ReturnType<typeof loadSendHttp> & object,
|
|
544
|
+
): Promise<number> {
|
|
545
|
+
const base = baseParams(conn, authMethod)
|
|
546
|
+
const target: WsmanTarget = {
|
|
547
|
+
host: conn.host,
|
|
548
|
+
port: conn.port,
|
|
549
|
+
path: conn.path,
|
|
550
|
+
username: conn.username,
|
|
551
|
+
password: conn.password,
|
|
552
|
+
authMethod,
|
|
553
|
+
useHttps: conn.useHttps ?? false,
|
|
554
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true,
|
|
555
|
+
}
|
|
556
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
|
|
557
|
+
const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
|
|
558
|
+
|
|
559
|
+
const inner = async (): Promise<number> => {
|
|
560
|
+
const shellId = await Shell.doCreateShell(base)
|
|
561
|
+
state.shellId = shellId
|
|
562
|
+
if (state.settled) {
|
|
563
|
+
void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
564
|
+
throw new WinrmTimedOut()
|
|
565
|
+
}
|
|
566
|
+
const commandId = await Command.doExecuteCommand({
|
|
567
|
+
...base,
|
|
568
|
+
shellId,
|
|
569
|
+
command: scriptCommandLine(psSendStream(remotePath, start, length)),
|
|
570
|
+
})
|
|
571
|
+
// Each worker decompresses its own gzip stream as it arrives, so memory
|
|
572
|
+
// stays bounded by one Receive response rather than by the file size. The
|
|
573
|
+
// error listener is attached up front: a decompression failure during the
|
|
574
|
+
// loop would otherwise surface as an unhandled 'error' event and kill the
|
|
575
|
+
// process instead of failing this transfer. `written` is only trusted once
|
|
576
|
+
// the inflater has ended, since 'data' fires asynchronously.
|
|
577
|
+
const inflater = createGunzip()
|
|
578
|
+
let inflateError: Error | undefined
|
|
579
|
+
inflater.on('error', (error: Error) => { inflateError = error })
|
|
580
|
+
let written = 0
|
|
581
|
+
let stderr = ''
|
|
582
|
+
inflater.on('data', (piece: Buffer) => {
|
|
583
|
+
onData(start + written, piece)
|
|
584
|
+
written += piece.length
|
|
585
|
+
})
|
|
586
|
+
let stdoutEnded = false
|
|
587
|
+
let exitCode: number | undefined
|
|
588
|
+
while (!stdoutEnded) {
|
|
589
|
+
// A worker that lost the deadline race keeps running in the background
|
|
590
|
+
// unless it checks; without this it would go on fetching and pushing
|
|
591
|
+
// bytes into a file the caller has already given up on.
|
|
592
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
|
|
593
|
+
const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS)
|
|
594
|
+
let sawData = false
|
|
595
|
+
for (const stream of result.streams) {
|
|
596
|
+
if (stream.data.length === 0) {
|
|
597
|
+
if (stream.name === 'stdout' && stream.end) stdoutEnded = true
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
sawData = true
|
|
601
|
+
if (stream.name === 'stdout') {
|
|
602
|
+
inflater.write(stream.data)
|
|
603
|
+
if (stream.end) stdoutEnded = true
|
|
604
|
+
} else {
|
|
605
|
+
stderr += stream.data.toString('utf8')
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (inflateError !== undefined) throw inflateError
|
|
609
|
+
if (result.exitCode !== undefined) exitCode = result.exitCode
|
|
610
|
+
// A finished command whose final response carried nothing means the
|
|
611
|
+
// streams are drained; without this the loop would spin on CommandState
|
|
612
|
+
// alone when the payload ended exactly on a response boundary.
|
|
613
|
+
if (!sawData && exitCode !== undefined) break
|
|
614
|
+
}
|
|
615
|
+
await new Promise<void>((resolve, reject) => {
|
|
616
|
+
inflater.on('error', reject)
|
|
617
|
+
inflater.on('end', resolve)
|
|
618
|
+
inflater.end()
|
|
619
|
+
})
|
|
620
|
+
if (inflateError !== undefined) throw inflateError
|
|
621
|
+
if (exitCode !== undefined && exitCode !== 0) {
|
|
622
|
+
throw new Error('streaming download failed on the target: ' + stderr.trim())
|
|
623
|
+
}
|
|
624
|
+
if (written !== length) {
|
|
625
|
+
throw new Error(`streaming download delivered ${written} of ${length} bytes`)
|
|
626
|
+
}
|
|
627
|
+
return written
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
return await raceDeadline(inner(), hardDeadline - Date.now())
|
|
632
|
+
} catch (error) {
|
|
633
|
+
state.timedOut = error instanceof WinrmTimedOut
|
|
634
|
+
throw error
|
|
635
|
+
} finally {
|
|
636
|
+
state.settled = true
|
|
637
|
+
const shellId = state.shellId
|
|
638
|
+
if (shellId !== undefined) {
|
|
639
|
+
try {
|
|
640
|
+
await raceDeadline(Shell.doDeleteShell({ ...base, shellId }), DELETE_TIMEOUT_MS)
|
|
641
|
+
} catch {
|
|
642
|
+
// Best effort: an orphaned shell is reaped by the target eventually.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Stream a remote file to a consumer, in parallel byte ranges.
|
|
650
|
+
*
|
|
651
|
+
* The old path asked for 48 KiB per shell lifecycle, so a 2 MiB file cost 43
|
|
652
|
+
* round-trip cycles and 129 s. Measured against this target, one `Receive`
|
|
653
|
+
* returns at most 128 KiB no matter how large an envelope is declared, which
|
|
654
|
+
* makes a download latency-bound rather than bandwidth-bound; the fix is
|
|
655
|
+
* therefore to run several ranges at once. Each worker opens its own shell and
|
|
656
|
+
* streams its range gzip-compressed, and because ranges are absolute, the
|
|
657
|
+
* caller can place each piece by offset and completion order does not matter.
|
|
658
|
+
*
|
|
659
|
+
* The remote side compresses unconditionally: a compressible 16 MiB file
|
|
660
|
+
* arrives in one round trip (2.0 s), and an incompressible file was measured no
|
|
661
|
+
* slower than raw (9457 ms vs 9582 ms).
|
|
662
|
+
* @param conn - connection parameters.
|
|
663
|
+
* @param remotePath - file to read.
|
|
664
|
+
* @param onData - receives each decompressed piece with its absolute file offset.
|
|
665
|
+
* @param onProgress - receives cumulative bytes delivered and the total size.
|
|
666
|
+
* @param timeoutMs - budget for the whole download.
|
|
667
|
+
* @returns the number of bytes delivered, or null when streaming is unavailable.
|
|
668
|
+
*/
|
|
669
|
+
export async function downloadStream(
|
|
670
|
+
conn: WinRMParams,
|
|
671
|
+
remotePath: string,
|
|
672
|
+
onData: (offset: number, data: Buffer) => void,
|
|
673
|
+
onProgress?: (transferred: number, total: number) => void,
|
|
674
|
+
timeoutMs = 600_000,
|
|
675
|
+
): Promise<number | null> {
|
|
676
|
+
const sendHttp = loadSendHttp()
|
|
677
|
+
if (sendHttp === null) return null
|
|
678
|
+
const total = await remoteFileSize(conn, remotePath)
|
|
679
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`)
|
|
680
|
+
if (total === 0) return 0
|
|
681
|
+
const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)))
|
|
682
|
+
const rangeSize = Math.ceil(total / workers)
|
|
683
|
+
let transferred = 0
|
|
684
|
+
// `Promise.all` rejects on the first worker to fail, but its siblings keep
|
|
685
|
+
// running until they next notice; without a gate a stale worker would keep
|
|
686
|
+
// feeding data from a failed attempt into the retry's counters and into the
|
|
687
|
+
// caller's file — after the caller has already closed it.
|
|
688
|
+
let generation = 0
|
|
689
|
+
let live = true
|
|
690
|
+
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
691
|
+
let lastError: unknown
|
|
692
|
+
try {
|
|
693
|
+
for (const authMethod of candidates) {
|
|
694
|
+
// Reset per attempt: a retry re-sends every range from its own start, so
|
|
695
|
+
// carrying the count over would report more bytes than the file holds.
|
|
696
|
+
transferred = 0
|
|
697
|
+
const mine = ++generation
|
|
698
|
+
const report = (offset: number, data: Buffer): void => {
|
|
699
|
+
if (!live || mine !== generation) return
|
|
700
|
+
onData(offset, data)
|
|
701
|
+
transferred += data.length
|
|
702
|
+
onProgress?.(transferred, total)
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
const ranges: { start: number; length: number }[] = []
|
|
706
|
+
for (let start = 0; start < total; start += rangeSize) {
|
|
707
|
+
ranges.push({ start, length: Math.min(rangeSize, total - start) })
|
|
708
|
+
}
|
|
709
|
+
await Promise.all(
|
|
710
|
+
ranges.map(range =>
|
|
711
|
+
downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp),
|
|
712
|
+
),
|
|
713
|
+
)
|
|
714
|
+
return transferred
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof WinrmTimedOut) throw error
|
|
717
|
+
lastError = error
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
|
|
721
|
+
} finally {
|
|
722
|
+
// Once this function settles the caller may close its file, so every worker
|
|
723
|
+
// must stop emitting immediately — an orphaned worker writing afterwards
|
|
724
|
+
// would hit a closed handle.
|
|
725
|
+
live = false
|
|
726
|
+
}
|
|
727
|
+
}
|