dsh-winrm 0.2.0 → 0.3.1
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 +9 -5
- package/README.zh.md +9 -5
- package/lib/client.js +71 -71
- package/lib/index.js +981 -75
- package/lib/types/engine/client.d.ts +48 -1
- package/lib/types/engine/wsman.d.ts +153 -0
- package/lib/types/engine.d.ts +4 -4
- package/lib/types/index.d.ts +6 -5
- package/lib/types/powershell.d.ts +110 -1
- package/lib/types/smb.d.ts +24 -5
- package/package.json +2 -2
- package/src/engine/client.ts +439 -5
- package/src/engine/wsman.ts +357 -0
- package/src/engine.ts +104 -40
- package/src/index.ts +6 -5
- package/src/powershell.ts +298 -2
- package/src/smb.ts +57 -5
- package/src/tools.ts +4 -4
package/lib/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { createGunzip, gzipSync } from "node:zlib";
|
|
2
4
|
import { Command, Shell } from "winrm-client";
|
|
5
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
6
|
import { spawn } from "node:child_process";
|
|
4
7
|
import { copyFile, readFile, stat, unlink } from "node:fs/promises";
|
|
5
|
-
import {
|
|
8
|
+
import { createConnection } from "node:net";
|
|
6
9
|
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
7
10
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
11
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -143,6 +146,55 @@ function powershellCommandLine() {
|
|
|
143
146
|
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(OUTER_SCRIPT);
|
|
144
147
|
}
|
|
145
148
|
/**
|
|
149
|
+
* Bootstrap for the streaming upload path: read `<byteCount>\n<script>` from
|
|
150
|
+
* stdin, then run that script **in this process** so it can keep draining the
|
|
151
|
+
* same stdin stream.
|
|
152
|
+
*
|
|
153
|
+
* This exists because {@link psReceiveStream} outgrew the command line. WinRS
|
|
154
|
+
* hands the command line to cmd.exe, whose limit is about 8191 characters, and
|
|
155
|
+
* the receiver is 3424 characters of PowerShell — 9206 once base64-of-UTF-16
|
|
156
|
+
* expands it, over the cap. Shipping the receiver on stdin instead costs one
|
|
157
|
+
* extra `Send` and keeps the command line a constant 1154 characters no matter
|
|
158
|
+
* how the receiver grows.
|
|
159
|
+
*
|
|
160
|
+
* Running the receiver in-process (rather than in a child, as
|
|
161
|
+
* {@link OUTER_SCRIPT} does) is what makes the handoff work: the child would
|
|
162
|
+
* need its own stdin, and the payload has to keep flowing into whatever reads
|
|
163
|
+
* the file. A fresh `[Console]::OpenStandardInput()` inside the receiver picks
|
|
164
|
+
* up exactly where this bootstrap stopped reading, because `Read` on the raw
|
|
165
|
+
* stream never buffers ahead of the count it was asked for.
|
|
166
|
+
*/
|
|
167
|
+
const STREAM_BOOTSTRAP = [
|
|
168
|
+
"$ErrorActionPreference='Continue'",
|
|
169
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
170
|
+
"$i=[Console]::OpenStandardInput()",
|
|
171
|
+
"$d=''",
|
|
172
|
+
"while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}",
|
|
173
|
+
"$n=[int]$d",
|
|
174
|
+
"$u=New-Object byte[] $n",
|
|
175
|
+
"$r=0",
|
|
176
|
+
"while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}",
|
|
177
|
+
"$s=[Text.Encoding]::UTF8.GetString($u,0,$r)",
|
|
178
|
+
"$c=0",
|
|
179
|
+
"try{",
|
|
180
|
+
" &([scriptblock]::Create($s))",
|
|
181
|
+
"}catch{",
|
|
182
|
+
" [Console]::Error.WriteLine([string]($_|Out-String))",
|
|
183
|
+
" $c=1",
|
|
184
|
+
"}",
|
|
185
|
+
"exit $c",
|
|
186
|
+
""
|
|
187
|
+
].join("\r\n");
|
|
188
|
+
/**
|
|
189
|
+
* The constant powershell.exe command line for a streaming upload.
|
|
190
|
+
*
|
|
191
|
+
* The receiver travels on stdin right behind it, so this stays well clear of
|
|
192
|
+
* cmd.exe's cap and does not change when the receiver does.
|
|
193
|
+
*/
|
|
194
|
+
function streamCommandLine() {
|
|
195
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(STREAM_BOOTSTRAP);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
146
198
|
* The stdin payload for one command: the UTF-8 **byte** count, a newline, then
|
|
147
199
|
* the raw script.
|
|
148
200
|
*
|
|
@@ -226,11 +278,16 @@ function psListDir(dir) {
|
|
|
226
278
|
""
|
|
227
279
|
].join("\r\n");
|
|
228
280
|
}
|
|
229
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* Plain: remote file size in bytes. Emits nothing at all when the path is
|
|
283
|
+
* missing or is a directory, so the caller can tell "absent" from "empty" —
|
|
284
|
+
* both used to collapse to the string "0", which made a download of a
|
|
285
|
+
* nonexistent file look like a successful transfer of a zero-byte one.
|
|
286
|
+
*/
|
|
230
287
|
function psFileSize(p) {
|
|
231
288
|
return [
|
|
232
289
|
"$__f = Get-Item -LiteralPath " + sq(p) + " -ErrorAction SilentlyContinue",
|
|
233
|
-
"if ($null -
|
|
290
|
+
"if ($null -ne $__f -and -not $__f.PSIsContainer) { [string]$__f.Length }",
|
|
234
291
|
""
|
|
235
292
|
].join("\r\n");
|
|
236
293
|
}
|
|
@@ -265,6 +322,227 @@ function psWriteChunk(p, b64, append) {
|
|
|
265
322
|
""
|
|
266
323
|
].join("\r\n");
|
|
267
324
|
}
|
|
325
|
+
/** Magic stamped into the frame header that precedes every stream chunk. */
|
|
326
|
+
const STREAM_FRAME_MAGIC = "DSHZ";
|
|
327
|
+
/** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
|
|
328
|
+
const STREAM_BUFFER_BYTES = 262144;
|
|
329
|
+
/**
|
|
330
|
+
* Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
|
|
331
|
+
*
|
|
332
|
+
* The frame is what makes a compressed stream legal on a pipe. stdin cannot
|
|
333
|
+
* seek, so the receiver has to be handed the exact byte count of the next gzip
|
|
334
|
+
* member before it lets `GZipStream` touch it; otherwise the inflater would
|
|
335
|
+
* read into the member that follows and the stream would desynchronize. The
|
|
336
|
+
* mode byte travels with the payload so the client can switch between gzip and
|
|
337
|
+
* raw per chunk.
|
|
338
|
+
* @param payload - gzip member bytes or raw file bytes.
|
|
339
|
+
* @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
|
|
340
|
+
* @returns the framed chunk to place on the stdin stream.
|
|
341
|
+
*/
|
|
342
|
+
function buildStreamFrame(payload, mode) {
|
|
343
|
+
const frame = Buffer.alloc(10 + payload.length);
|
|
344
|
+
frame.write(STREAM_FRAME_MAGIC, 0, "ascii");
|
|
345
|
+
frame.writeUInt32LE(payload.length, 4);
|
|
346
|
+
frame[8] = mode;
|
|
347
|
+
frame[9] = 0;
|
|
348
|
+
payload.copy(frame, 10);
|
|
349
|
+
return frame;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Streaming receiver: drain framed stdin chunks into the file named by the
|
|
353
|
+
* header frame, until the declared byte count lands.
|
|
354
|
+
*
|
|
355
|
+
* This is the fast upload path. Instead of shipping base64 text inside a
|
|
356
|
+
* PowerShell script — which costs one full shell create/command/delete cycle
|
|
357
|
+
* per 48 KiB — the remote shell starts this script once and the client then
|
|
358
|
+
* pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
|
|
359
|
+
* left is a single `Send`, so a whole file costs one shell and one command no
|
|
360
|
+
* matter how large it is.
|
|
361
|
+
*
|
|
362
|
+
* The script deliberately takes **no arguments**. The destination path and
|
|
363
|
+
* total byte count arrive as the first frame on stdin, which keeps the command
|
|
364
|
+
* line a constant size: WinRS hands the command line to cmd.exe, whose limit
|
|
365
|
+
* is about 8191 characters, and a long remote path interpolated into the
|
|
366
|
+
* script would eat into that budget (the script is base64-of-UTF-16 on the
|
|
367
|
+
* wire, so every character costs 2.67). Reading them from the stream removes
|
|
368
|
+
* the limit entirely and mirrors how `powershellCommandLine` stays constant.
|
|
369
|
+
*
|
|
370
|
+
* Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
|
|
371
|
+
* 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
|
|
372
|
+
* explicit frame is what makes a *pipe* usable for a compressed stream: stdin
|
|
373
|
+
* cannot seek, so the receiver must be told how long the next gzip member is
|
|
374
|
+
* before it hands the bytes to `GZipStream`, or the inflater would read into
|
|
375
|
+
* the member that follows. The mode byte lets the client pick per chunk — gzip
|
|
376
|
+
* when it actually shrinks the data, raw bytes when it would not — so
|
|
377
|
+
* incompressible files never pay compression overhead.
|
|
378
|
+
*
|
|
379
|
+
* The script prints the three-line envelope on stdout with the byte count as
|
|
380
|
+
* its payload, so the caller can prove the transfer landed complete.
|
|
381
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
382
|
+
*/
|
|
383
|
+
function psReceiveStream() {
|
|
384
|
+
return [
|
|
385
|
+
"$ErrorActionPreference = 'Stop'",
|
|
386
|
+
"$__a = ''",
|
|
387
|
+
"$__e = ''",
|
|
388
|
+
"$__c = 0",
|
|
389
|
+
"try {",
|
|
390
|
+
" $__i = [Console]::OpenStandardInput()",
|
|
391
|
+
" $__hdr = New-Object byte[] " + String(10),
|
|
392
|
+
" $__got = 0",
|
|
393
|
+
" while ($__got -lt $__hdr.Length) {",
|
|
394
|
+
" $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)",
|
|
395
|
+
" if ($__k -le 0) { break }",
|
|
396
|
+
" $__got += $__k",
|
|
397
|
+
" }",
|
|
398
|
+
" if ($__got -lt $__hdr.Length) { throw " + sq("upload stream ended before its header") + " }",
|
|
399
|
+
" if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw " + sq("upload stream lost frame alignment") + " }",
|
|
400
|
+
" if ($__hdr[8] -ne " + String(2) + ") { throw " + sq("upload stream started without a header frame") + " }",
|
|
401
|
+
" $__size = 0",
|
|
402
|
+
" for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }",
|
|
403
|
+
" $__meta = New-Object byte[] $__size",
|
|
404
|
+
" $__got = 0",
|
|
405
|
+
" while ($__got -lt $__size) {",
|
|
406
|
+
" $__k = $__i.Read($__meta, $__got, $__size - $__got)",
|
|
407
|
+
" if ($__k -le 0) { break }",
|
|
408
|
+
" $__got += $__k",
|
|
409
|
+
" }",
|
|
410
|
+
" if ($__got -lt $__size) { throw " + sq("upload stream truncated its header frame") + " }",
|
|
411
|
+
" $__text = [System.Text.Encoding]::UTF8.GetString($__meta)",
|
|
412
|
+
" $__cut = $__text.IndexOf([char]10)",
|
|
413
|
+
" if ($__cut -lt 0) { throw " + sq("upload header frame is malformed") + " }",
|
|
414
|
+
" $__n = [long]$__text.Substring(0, $__cut)",
|
|
415
|
+
" $__p = $__text.Substring($__cut + 1)",
|
|
416
|
+
" $__d = [System.IO.Path]::GetDirectoryName($__p)",
|
|
417
|
+
" if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }",
|
|
418
|
+
" $__fs = New-Object System.IO.FileStream($__p, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)",
|
|
419
|
+
" $__w = 0",
|
|
420
|
+
" try {",
|
|
421
|
+
" $__buf = New-Object byte[] " + String(STREAM_BUFFER_BYTES),
|
|
422
|
+
" while ($__w -lt $__n) {",
|
|
423
|
+
" $__got = 0",
|
|
424
|
+
" while ($__got -lt $__hdr.Length) {",
|
|
425
|
+
" $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)",
|
|
426
|
+
" if ($__k -le 0) { break }",
|
|
427
|
+
" $__got += $__k",
|
|
428
|
+
" }",
|
|
429
|
+
" if ($__got -lt $__hdr.Length) { break }",
|
|
430
|
+
" if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw " + sq("upload stream lost frame alignment") + " }",
|
|
431
|
+
" $__size = 0",
|
|
432
|
+
" for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }",
|
|
433
|
+
" $__mode = $__hdr[8]",
|
|
434
|
+
" $__blob = New-Object byte[] $__size",
|
|
435
|
+
" $__got = 0",
|
|
436
|
+
" while ($__got -lt $__size) {",
|
|
437
|
+
" $__k = $__i.Read($__blob, $__got, $__size - $__got)",
|
|
438
|
+
" if ($__k -le 0) { break }",
|
|
439
|
+
" $__got += $__k",
|
|
440
|
+
" }",
|
|
441
|
+
" if ($__got -lt $__size) { break }",
|
|
442
|
+
" if ($__mode -eq " + String(1) + ") {",
|
|
443
|
+
" $__ms = New-Object System.IO.MemoryStream(, $__blob)",
|
|
444
|
+
" $__gz = New-Object System.IO.Compression.GZipStream($__ms, [System.IO.Compression.CompressionMode]::Decompress)",
|
|
445
|
+
" try {",
|
|
446
|
+
" while ($true) {",
|
|
447
|
+
" $__k = $__gz.Read($__buf, 0, $__buf.Length)",
|
|
448
|
+
" if ($__k -le 0) { break }",
|
|
449
|
+
" $__fs.Write($__buf, 0, $__k)",
|
|
450
|
+
" $__w += $__k",
|
|
451
|
+
" }",
|
|
452
|
+
" } finally { $__gz.Dispose(); $__ms.Dispose() }",
|
|
453
|
+
" } else {",
|
|
454
|
+
" $__fs.Write($__blob, 0, $__blob.Length)",
|
|
455
|
+
" $__w += $__blob.Length",
|
|
456
|
+
" }",
|
|
457
|
+
" }",
|
|
458
|
+
" } finally { $__fs.Dispose() }",
|
|
459
|
+
" $__a = [string]$__w",
|
|
460
|
+
"} catch {",
|
|
461
|
+
" $__a = [string]$__a + [string]($_.Exception.Message)",
|
|
462
|
+
" $__c = 1",
|
|
463
|
+
"}",
|
|
464
|
+
sq(ENVELOPE_MARKER) + " + [string]$__c",
|
|
465
|
+
"'O:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__a))",
|
|
466
|
+
"'E:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__e))",
|
|
467
|
+
""
|
|
468
|
+
].join("\r\n");
|
|
469
|
+
}
|
|
470
|
+
/** Remote-side read buffer used by {@link psSendStream} while draining the file to stdout. */
|
|
471
|
+
const SEND_BUFFER_BYTES = 262144;
|
|
472
|
+
/**
|
|
473
|
+
* Streaming sender: write one byte range of the file to standard output,
|
|
474
|
+
* gzip-compressed, for the download path.
|
|
475
|
+
*
|
|
476
|
+
* This is the mirror of {@link psReceiveStream}. The old download path asked
|
|
477
|
+
* for 48 KiB at a time, and every chunk cost a full shell create / command /
|
|
478
|
+
* delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
|
|
479
|
+
* the shell is created once and the client then simply receives; the only
|
|
480
|
+
* per-chunk cost left is one `Receive`, and the target streams the file as fast
|
|
481
|
+
* as the transport will drain it.
|
|
482
|
+
*
|
|
483
|
+
* Two details make this work:
|
|
484
|
+
*
|
|
485
|
+
* - **Only stderr may carry text.** Anything written to the PowerShell success
|
|
486
|
+
* stream would be merged into the payload and corrupt the file, so the byte
|
|
487
|
+
* count and any error go to `[Console]::Error` and the exit code, not stdout.
|
|
488
|
+
* - **The range is positional.** `Position` plus a byte count lets several
|
|
489
|
+
* shells serve different parts of one file concurrently; each writes to its
|
|
490
|
+
* own offset on the client, so completion order does not matter.
|
|
491
|
+
*
|
|
492
|
+
* Output is gzip-compressed unconditionally. Measured against this target, a
|
|
493
|
+
* compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
|
|
494
|
+
* incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
|
|
495
|
+
* compression pays for itself or costs nothing.
|
|
496
|
+
* @param remotePath - file to read on the target.
|
|
497
|
+
* @param start - first byte offset of this worker's range.
|
|
498
|
+
* @param length - number of bytes this worker must send.
|
|
499
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
500
|
+
*/
|
|
501
|
+
function psSendStream(remotePath, start, length) {
|
|
502
|
+
return [
|
|
503
|
+
"$ErrorActionPreference = 'Stop'",
|
|
504
|
+
"$__o = [Console]::OpenStandardOutput()",
|
|
505
|
+
"$__n = 0",
|
|
506
|
+
"try {",
|
|
507
|
+
" $__fs = [System.IO.File]::OpenRead(" + sq(remotePath) + ")",
|
|
508
|
+
" try {",
|
|
509
|
+
" $__fs.Position = [long]" + String(start),
|
|
510
|
+
" $__left = [long]" + String(length),
|
|
511
|
+
" $__gz = New-Object System.IO.Compression.GZipStream($__o, [System.IO.Compression.CompressionMode]::Compress, $true)",
|
|
512
|
+
" try {",
|
|
513
|
+
" $__buf = New-Object byte[] " + String(SEND_BUFFER_BYTES),
|
|
514
|
+
" while ($__left -gt 0) {",
|
|
515
|
+
" $__want = [int][Math]::Min([long]$__buf.Length, $__left)",
|
|
516
|
+
" $__k = $__fs.Read($__buf, 0, $__want)",
|
|
517
|
+
" if ($__k -le 0) { break }",
|
|
518
|
+
" $__gz.Write($__buf, 0, $__k)",
|
|
519
|
+
" $__n += $__k",
|
|
520
|
+
" $__left -= $__k",
|
|
521
|
+
" }",
|
|
522
|
+
" $__gz.Flush()",
|
|
523
|
+
" } finally { $__gz.Dispose() }",
|
|
524
|
+
" $__o.Flush()",
|
|
525
|
+
" } finally { $__fs.Dispose() }",
|
|
526
|
+
" [Console]::Error.WriteLine('__DSH_DL__ OK ' + [string]$__n)",
|
|
527
|
+
"} catch {",
|
|
528
|
+
" [Console]::Error.WriteLine('__DSH_DL__ ERR ' + [string]($_.Exception.Message))",
|
|
529
|
+
" throw",
|
|
530
|
+
"}",
|
|
531
|
+
""
|
|
532
|
+
].join("\r\n");
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Wrap an arbitrary script in the constant powershell.exe invocation.
|
|
536
|
+
*
|
|
537
|
+
* Only safe for scripts that fit cmd.exe's ~8191 character command line (the
|
|
538
|
+
* script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
|
|
539
|
+
* {@link psReceiveStream} outgrew that and travels on stdin instead.
|
|
540
|
+
* @param script - the PowerShell script to run.
|
|
541
|
+
* @returns the command line to hand to WinRS.
|
|
542
|
+
*/
|
|
543
|
+
function scriptCommandLine(script) {
|
|
544
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(script);
|
|
545
|
+
}
|
|
268
546
|
/** Decode one base64 envelope field, tolerating whitespace the transport added. */
|
|
269
547
|
function decodeField(base64) {
|
|
270
548
|
return Buffer.from(base64.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
@@ -316,6 +594,197 @@ function stripClixml(stderr) {
|
|
|
316
594
|
return stderr.replace(CLIXML_HEADER, "").replace(CLIXML_RECORDS, "").trim();
|
|
317
595
|
}
|
|
318
596
|
//#endregion
|
|
597
|
+
//#region src/engine/wsman.ts
|
|
598
|
+
/**
|
|
599
|
+
* Raw WSMan `Send` for the streaming upload path.
|
|
600
|
+
*
|
|
601
|
+
* The bundled `winrm-client` can only put text on the WinRS stdin stream: its
|
|
602
|
+
* `doSendInput` base64-encodes the input *again* on top of whatever the caller
|
|
603
|
+
* already encoded, and it has no way to close the stream. Both are fatal for a
|
|
604
|
+
* bulk transfer — double base64 costs 1.78x the bytes on the wire, and a
|
|
605
|
+
* stdin stream that never ends cannot tell the remote receiver "that is all".
|
|
606
|
+
*
|
|
607
|
+
* This module therefore builds the `Send` envelope itself and posts it through
|
|
608
|
+
* the library's own `sendHttp`, which keeps Basic/NTLM/SPNEGO and the HTTPS
|
|
609
|
+
* options exactly as the rest of the transport uses them. The stream carries
|
|
610
|
+
* raw file bytes, so the wire cost is 1.0x plus the envelope.
|
|
611
|
+
*
|
|
612
|
+
* The library is reached through `createRequire` rather than a static import
|
|
613
|
+
* because `dist/src/utils/http.js` is an internal CommonJS path with no
|
|
614
|
+
* `exports` map behind it. `loadSendHttp` returns null when that path is gone
|
|
615
|
+
* (a future `winrm-client` layout change), which lets the caller fall back to
|
|
616
|
+
* the old chunked path instead of failing the transfer.
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* Envelope ceiling declared on every Send, in bytes.
|
|
620
|
+
*
|
|
621
|
+
* This is what the *client* promises the server it will never exceed, and the
|
|
622
|
+
* server enforces its own `MaxEnvelopeSizekb` on top. 512000 is the value the
|
|
623
|
+
* target host reports, and it is far above the library's hardcoded 153600 —
|
|
624
|
+
* raising it is what makes 256 KiB chunks legal.
|
|
625
|
+
*/
|
|
626
|
+
const WS_MAX_ENVELOPE_BYTES = 512e3;
|
|
627
|
+
let sendHttpCache;
|
|
628
|
+
/**
|
|
629
|
+
* Resolve the library's `sendHttp`, or null when the internal path is absent.
|
|
630
|
+
* @returns the send function, or null when it cannot be loaded.
|
|
631
|
+
*/
|
|
632
|
+
function loadSendHttp() {
|
|
633
|
+
if (sendHttpCache !== void 0) return sendHttpCache;
|
|
634
|
+
try {
|
|
635
|
+
const mod = createRequire(import.meta.url)("winrm-client/dist/src/utils/http.js");
|
|
636
|
+
sendHttpCache = typeof mod.sendHttp === "function" ? mod.sendHttp : null;
|
|
637
|
+
} catch {
|
|
638
|
+
sendHttpCache = null;
|
|
639
|
+
}
|
|
640
|
+
return sendHttpCache;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Build one WSMan `Send` envelope carrying `chunk` on the stdin stream.
|
|
644
|
+
*
|
|
645
|
+
* The header mirrors what the library emits, with three deliberate changes:
|
|
646
|
+
* `MaxEnvelopeSize` is raised to `limit`, the `Send` action replaces the
|
|
647
|
+
* command's, and the stream body is the caller's base64 verbatim — no second
|
|
648
|
+
* encode. `Name="stdin"` and `CommandId` are what bind the bytes to the
|
|
649
|
+
* running receiver's standard input.
|
|
650
|
+
* @param shellId - target shell from `Shell.doCreateShell`.
|
|
651
|
+
* @param commandId - target command from `Command.doExecuteCommand`.
|
|
652
|
+
* @param chunk - raw bytes to place on the remote stdin stream.
|
|
653
|
+
* @param limit - envelope ceiling to declare.
|
|
654
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
655
|
+
*/
|
|
656
|
+
function buildSendEnvelope(shellId, commandId, chunk, limit) {
|
|
657
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\" xmlns:wsa=\"http://schemas.xmlsoap.org/ws/2004/08/addressing\" xmlns:wsman=\"http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd\" xmlns:p=\"http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd\" xmlns:rsp=\"http://schemas.microsoft.com/wbem/wsman/1/windows/shell\"><s:Header><wsa:To>http://windows-host:5985/wsman</wsa:To><wsman:ResourceURI mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI><wsa:ReplyTo><wsa:Address mustUnderstand=\"true\">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo><wsman:MaxEnvelopeSize mustUnderstand=\"true\">" + String(limit) + "</wsman:MaxEnvelopeSize><wsa:MessageID>uuid:" + randomUUID() + "</wsa:MessageID><wsman:Locale mustUnderstand=\"false\" xml:lang=\"en-US\"/><wsman:OperationTimeout>PT60S</wsman:OperationTimeout><wsa:Action mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send</wsa:Action><wsman:SelectorSet><wsman:Selector Name=\"ShellId\">" + shellId + "</wsman:Selector></wsman:SelectorSet></s:Header><s:Body><rsp:Send><rsp:Stream CommandId=\"" + commandId + "\" Name=\"stdin\">" + chunk.toString("base64") + "</rsp:Stream></rsp:Send></s:Body></s:Envelope>";
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Push one raw slice onto the remote stdin stream.
|
|
661
|
+
*
|
|
662
|
+
* A rejection here is safe to retry with a smaller slice: the server validates
|
|
663
|
+
* the envelope size before dispatching it to the shell, so nothing reached the
|
|
664
|
+
* receiver's stdin when the call fails on size.
|
|
665
|
+
* @param target - connection parameters (auth already selected).
|
|
666
|
+
* @param shellId - target shell.
|
|
667
|
+
* @param commandId - target command.
|
|
668
|
+
* @param chunk - raw bytes to send.
|
|
669
|
+
* @param sendHttp - the resolved library function.
|
|
670
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
671
|
+
*/
|
|
672
|
+
async function sendStdinChunk(target, shellId, commandId, chunk, sendHttp, timeoutMs) {
|
|
673
|
+
await sendHttp(buildSendEnvelope(shellId, commandId, chunk, WS_MAX_ENVELOPE_BYTES), target.host, target.port, target.path, target.username, target.password, target.authMethod, timeoutMs, target.useHttps, target.rejectUnauthorized);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Whether a failed Send was refused for exceeding the envelope ceiling.
|
|
677
|
+
*
|
|
678
|
+
* WSMan reports this as a SOAP fault rather than a status code, and the exact
|
|
679
|
+
* wording differs between the HTTP and HTTPS stacks, so the test looks for any
|
|
680
|
+
* of the size-related phrasings plus the plain 413 status. It deliberately
|
|
681
|
+
* does not match a bare "size" in unrelated text.
|
|
682
|
+
* @param error - the rejection to classify.
|
|
683
|
+
* @returns true when retrying with a smaller slice is the right response.
|
|
684
|
+
*/
|
|
685
|
+
function isEnvelopeTooLarge(error) {
|
|
686
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
687
|
+
return /\b413\b/.test(message) || /envelope/i.test(message) || /MaxEnvelopeSize/i.test(message) || /maximum.*(size|length)/i.test(message);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Build one WSMan `Receive` envelope asking for stdout and stderr.
|
|
691
|
+
*
|
|
692
|
+
* The library's own `buildReceiveOutputRequest` cannot be reused here because
|
|
693
|
+
* it hardcodes `MaxEnvelopeSize` at 153600 in its shared header. Measured
|
|
694
|
+
* against this target, that ceiling is what caps each response at 128 KiB —
|
|
695
|
+
* raising it to 512000 lets one `Receive` return 128 KiB per stream, which is
|
|
696
|
+
* the most the server will put in a single response regardless.
|
|
697
|
+
* @param shellId - target shell.
|
|
698
|
+
* @param commandId - target command whose streams are wanted.
|
|
699
|
+
* @param limit - envelope ceiling to declare.
|
|
700
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
701
|
+
*/
|
|
702
|
+
function buildReceiveEnvelope(shellId, commandId, limit) {
|
|
703
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\" xmlns:wsa=\"http://schemas.xmlsoap.org/ws/2004/08/addressing\" xmlns:wsman=\"http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd\" xmlns:rsp=\"http://schemas.microsoft.com/wbem/wsman/1/windows/shell\"><s:Header><wsa:To>http://windows-host:5985/wsman</wsa:To><wsman:ResourceURI mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI><wsa:ReplyTo><wsa:Address mustUnderstand=\"true\">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo><wsman:MaxEnvelopeSize mustUnderstand=\"true\">" + String(limit) + "</wsman:MaxEnvelopeSize><wsa:MessageID>uuid:" + randomUUID() + "</wsa:MessageID><wsman:Locale mustUnderstand=\"false\" xml:lang=\"en-US\"/><wsman:OperationTimeout>PT60S</wsman:OperationTimeout><wsa:Action mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive</wsa:Action><wsman:SelectorSet><wsman:Selector Name=\"ShellId\">" + shellId + "</wsman:Selector></wsman:SelectorSet></s:Header><s:Body><rsp:Receive><rsp:DesiredStream CommandId=\"" + commandId + "\">stdout stderr</rsp:DesiredStream></rsp:Receive></s:Body></s:Envelope>";
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Decode a base64 text node that the XML parser may have coerced to a number.
|
|
707
|
+
*
|
|
708
|
+
* fast-xml-parser runs with `parseTagValue` enabled, so an all-digit text node
|
|
709
|
+
* comes back as a JS number: `"0123"` parses to `123`, `"0000"` to `0`, and
|
|
710
|
+
* `"1e55"` to a float. A base64 payload is usually long enough to contain a
|
|
711
|
+
* non-digit and stay a string, but the final short chunk of a stream is exactly
|
|
712
|
+
* where a leading zero can appear, and silently reinterpreting it would corrupt
|
|
713
|
+
* the file. Converting through `String()` recovers the digits for every value
|
|
714
|
+
* that lost only leading zeros; a value that came back in exponent form cannot
|
|
715
|
+
* be recovered and is rejected rather than guessed at.
|
|
716
|
+
* @param text - the raw text node as the parser produced it.
|
|
717
|
+
* @returns the base64 text, or null when it cannot be reconstructed.
|
|
718
|
+
*/
|
|
719
|
+
function coerceBase64(text) {
|
|
720
|
+
if (typeof text === "string") return text.replace(/\s+/g, "");
|
|
721
|
+
if (typeof text === "number" && Number.isFinite(text) && Number.isInteger(text) && text >= 0) return String(text);
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Read the streams and exit code out of a Receive response.
|
|
726
|
+
*
|
|
727
|
+
* The library's `extractStreams` cannot be used for binary payloads: it decodes
|
|
728
|
+
* every stream through `extractText`, which falls back to `String(obj)` for an
|
|
729
|
+
* element that has attributes but no text node — turning the terminal
|
|
730
|
+
* `End="true"` marker into the literal string `'[object Object]'` — and the
|
|
731
|
+
* library's `doReceiveOutputNonBlocking` then `.trim()`s the decoded text,
|
|
732
|
+
* which would eat whitespace bytes at either end of a real payload. This
|
|
733
|
+
* reader keeps the distinction between "no text" (zero bytes) and "text" and
|
|
734
|
+
* never trims.
|
|
735
|
+
* @param response - the parsed XML object returned by `sendHttp`.
|
|
736
|
+
* @returns the decoded streams and exit code.
|
|
737
|
+
* @throws when a stream's text node cannot be decoded as base64.
|
|
738
|
+
*/
|
|
739
|
+
function readReceiveResult(response) {
|
|
740
|
+
const receive = ((response?.["s:Envelope"])?.["s:Body"])?.["rsp:ReceiveResponse"];
|
|
741
|
+
const streams = [];
|
|
742
|
+
if (receive !== void 0) {
|
|
743
|
+
const raw = receive["rsp:Stream"];
|
|
744
|
+
const list = raw === void 0 || raw === null ? [] : Array.isArray(raw) ? raw : [raw];
|
|
745
|
+
for (const element of list) {
|
|
746
|
+
const node = element;
|
|
747
|
+
const text = node !== null && typeof node === "object" && "_" in node ? node["_"] : "";
|
|
748
|
+
const attributes = node?.["$"] ?? {};
|
|
749
|
+
const name = String(attributes["@_Name"] ?? "");
|
|
750
|
+
const end = String(attributes["@_End"] ?? "") === "true";
|
|
751
|
+
if (text === "" || text === null || text === void 0) {
|
|
752
|
+
streams.push({
|
|
753
|
+
name,
|
|
754
|
+
data: Buffer.alloc(0),
|
|
755
|
+
end
|
|
756
|
+
});
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const base64 = coerceBase64(text);
|
|
760
|
+
if (base64 === null) throw new Error("receive stream returned an undecodable text node: " + String(text));
|
|
761
|
+
streams.push({
|
|
762
|
+
name,
|
|
763
|
+
data: base64 === "" ? Buffer.alloc(0) : Buffer.from(base64, "base64"),
|
|
764
|
+
end
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const rawExit = (receive?.["rsp:CommandState"])?.["rsp:ExitCode"];
|
|
769
|
+
const exitCode = rawExit === void 0 || rawExit === null ? void 0 : Number(rawExit);
|
|
770
|
+
return {
|
|
771
|
+
streams,
|
|
772
|
+
exitCode: exitCode !== void 0 && Number.isFinite(exitCode) ? exitCode : void 0
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Fetch one Receive response for a running command.
|
|
777
|
+
* @param target - connection parameters (auth already selected).
|
|
778
|
+
* @param shellId - target shell.
|
|
779
|
+
* @param commandId - target command.
|
|
780
|
+
* @param sendHttp - the resolved library function.
|
|
781
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
782
|
+
* @returns the decoded streams and exit code.
|
|
783
|
+
*/
|
|
784
|
+
async function receiveOutput(target, shellId, commandId, sendHttp, timeoutMs) {
|
|
785
|
+
return readReceiveResult(await sendHttp(buildReceiveEnvelope(shellId, commandId, WS_MAX_ENVELOPE_BYTES), target.host, target.port, target.path, target.username, target.password, target.authMethod, timeoutMs, target.useHttps, target.rejectUnauthorized));
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
319
788
|
//#region src/engine/client.ts
|
|
320
789
|
/**
|
|
321
790
|
* WinRM transport bridge.
|
|
@@ -545,13 +1014,21 @@ async function testConnection(conn) {
|
|
|
545
1014
|
}
|
|
546
1015
|
}
|
|
547
1016
|
const TRANSFER_CHUNK = 48 * 1024;
|
|
1017
|
+
/**
|
|
1018
|
+
* Size of a remote file, or null when the path does not exist or is a
|
|
1019
|
+
* directory. The distinction matters: a caller that treats "absent" as "empty"
|
|
1020
|
+
* writes an empty local file and reports success for a download that copied
|
|
1021
|
+
* nothing.
|
|
1022
|
+
*/
|
|
548
1023
|
async function remoteFileSize(conn, remotePath, timeoutMs = 6e4) {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
1024
|
+
const text = (await runScript(conn, psFileSize(remotePath), { timeoutMs })).stdout.trim();
|
|
1025
|
+
if (text === "") return null;
|
|
1026
|
+
const value = Number.parseInt(text, 10);
|
|
1027
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
552
1028
|
}
|
|
553
1029
|
async function downloadChunks(conn, remotePath, onChunk, onProgress, timeoutMs = 6e4) {
|
|
554
1030
|
const total = await remoteFileSize(conn, remotePath, timeoutMs);
|
|
1031
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`);
|
|
555
1032
|
if (total === 0) return 0;
|
|
556
1033
|
let offset = 0;
|
|
557
1034
|
for (;;) {
|
|
@@ -577,16 +1054,355 @@ async function uploadBuffer(conn, remotePath, data, onProgress, timeoutMs = 6e4)
|
|
|
577
1054
|
}
|
|
578
1055
|
return written;
|
|
579
1056
|
}
|
|
1057
|
+
/** Raw slice size for the streaming path: 256 KiB, comfortably inside `MAX_SEND_SLICE_BYTES`. */
|
|
1058
|
+
const STREAM_CHUNK_BYTES = 262144;
|
|
1059
|
+
/**
|
|
1060
|
+
* Compress a slice only when it actually shrinks.
|
|
1061
|
+
*
|
|
1062
|
+
* Gzip on already-compressed input (zips, images, installers) produces output
|
|
1063
|
+
* marginally *larger* than its input, and the remote side then pays to inflate
|
|
1064
|
+
* it for nothing — measured at ~3.8x the wall time of sending the same bytes
|
|
1065
|
+
* raw. Local compression is cheap (~3.4 ms per 256 KiB even when it fails to
|
|
1066
|
+
* compress), so probing every slice costs almost nothing and the ratio is the
|
|
1067
|
+
* only thing that decides.
|
|
1068
|
+
*/
|
|
1069
|
+
const STREAM_GZIP_MIN_RATIO = .85;
|
|
1070
|
+
/** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
|
|
1071
|
+
const STREAM_SEND_TIMEOUT_MS = 12e4;
|
|
1072
|
+
/**
|
|
1073
|
+
* One streaming transfer over a single shell: create → run the receiver →
|
|
1074
|
+
* push the receiver script, the header frame and every data frame down stdin →
|
|
1075
|
+
* receive the envelope → delete the shell.
|
|
1076
|
+
*
|
|
1077
|
+
* The receiver script is the first thing on stdin because it does not fit the
|
|
1078
|
+
* command line (see `streamCommandLine`); the bootstrap in the command line
|
|
1079
|
+
* reads it and runs it in-process so it can keep reading the same stream.
|
|
1080
|
+
* @param conn - connection parameters.
|
|
1081
|
+
* @param remotePath - destination path on the target.
|
|
1082
|
+
* @param data - the file contents to send.
|
|
1083
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
1084
|
+
* @param timeoutMs - whole-transfer budget.
|
|
1085
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
1086
|
+
* @param sendHttp - the resolved library send function.
|
|
1087
|
+
* @returns the number of file bytes the receiver acknowledged writing.
|
|
1088
|
+
* @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
|
|
1089
|
+
*/
|
|
1090
|
+
async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp) {
|
|
1091
|
+
const base = baseParams(conn, authMethod);
|
|
1092
|
+
const target = {
|
|
1093
|
+
host: conn.host,
|
|
1094
|
+
port: conn.port,
|
|
1095
|
+
path: conn.path,
|
|
1096
|
+
username: conn.username,
|
|
1097
|
+
password: conn.password,
|
|
1098
|
+
authMethod,
|
|
1099
|
+
useHttps: conn.useHttps ?? false,
|
|
1100
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1101
|
+
};
|
|
1102
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
1103
|
+
const state = {
|
|
1104
|
+
shellId: void 0,
|
|
1105
|
+
timedOut: false,
|
|
1106
|
+
settled: false
|
|
1107
|
+
};
|
|
1108
|
+
const total = data.length;
|
|
1109
|
+
const inner = async () => {
|
|
1110
|
+
const shellId = await Shell.doCreateShell(base);
|
|
1111
|
+
state.shellId = shellId;
|
|
1112
|
+
if (state.settled) {
|
|
1113
|
+
Shell.doDeleteShell({
|
|
1114
|
+
...base,
|
|
1115
|
+
shellId
|
|
1116
|
+
}).catch(() => void 0);
|
|
1117
|
+
throw new WinrmTimedOut();
|
|
1118
|
+
}
|
|
1119
|
+
const commandId = await Command.doExecuteCommand({
|
|
1120
|
+
...base,
|
|
1121
|
+
shellId,
|
|
1122
|
+
command: streamCommandLine()
|
|
1123
|
+
});
|
|
1124
|
+
await sendStdinChunk(target, shellId, commandId, Buffer.from(scriptPayload(psReceiveStream()), "utf8"), sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1125
|
+
const header = Buffer.from(String(total) + "\n" + remotePath, "utf8");
|
|
1126
|
+
await sendStdinChunk(target, shellId, commandId, buildStreamFrame(header, 2), sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1127
|
+
let offset = 0;
|
|
1128
|
+
let slice = STREAM_CHUNK_BYTES;
|
|
1129
|
+
let ceiling = STREAM_CHUNK_BYTES;
|
|
1130
|
+
while (offset < total) {
|
|
1131
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
|
|
1132
|
+
const take = Math.min(slice, total - offset);
|
|
1133
|
+
const chunk = data.subarray(offset, offset + take);
|
|
1134
|
+
const gz = gzipSync(chunk, { level: 6 });
|
|
1135
|
+
const compress = gz.length < chunk.length * STREAM_GZIP_MIN_RATIO;
|
|
1136
|
+
const frame = buildStreamFrame(compress ? gz : chunk, compress ? 1 : 0);
|
|
1137
|
+
try {
|
|
1138
|
+
await sendStdinChunk(target, shellId, commandId, frame, sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (isEnvelopeTooLarge(error) && slice > 16384) {
|
|
1141
|
+
slice = Math.max(16384, Math.floor(slice / 2));
|
|
1142
|
+
ceiling = slice;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
throw error;
|
|
1146
|
+
}
|
|
1147
|
+
offset += take;
|
|
1148
|
+
onProgress?.(offset, total);
|
|
1149
|
+
if (slice < ceiling) slice = Math.min(ceiling, slice * 2);
|
|
1150
|
+
}
|
|
1151
|
+
const receive = {
|
|
1152
|
+
...base,
|
|
1153
|
+
shellId,
|
|
1154
|
+
commandId
|
|
1155
|
+
};
|
|
1156
|
+
const commandDeadline = Date.now() + timeoutMs;
|
|
1157
|
+
let stdout = "";
|
|
1158
|
+
let stderr = "";
|
|
1159
|
+
for (;;) {
|
|
1160
|
+
if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut();
|
|
1161
|
+
const chunk = await Command.doReceiveOutputNonBlocking(receive);
|
|
1162
|
+
stdout += chunk.output;
|
|
1163
|
+
stderr += chunk.stderr;
|
|
1164
|
+
if (chunk.isComplete) break;
|
|
1165
|
+
}
|
|
1166
|
+
const parsed = parseEnvelope(stdout);
|
|
1167
|
+
if (parsed === null) throw new Error("streaming upload did not report completion: " + (stripClixml(stderr) || stdout).trim());
|
|
1168
|
+
if (parsed.exitCode !== 0) throw new Error("streaming upload failed on the target: " + parsed.stdout.trim());
|
|
1169
|
+
const written = Number.parseInt(parsed.stdout.trim(), 10);
|
|
1170
|
+
if (!Number.isFinite(written) || written !== total) throw new Error(`streaming upload wrote ${parsed.stdout.trim()} of ${total} bytes`);
|
|
1171
|
+
return written;
|
|
1172
|
+
};
|
|
1173
|
+
try {
|
|
1174
|
+
return await raceDeadline(inner(), hardDeadline - Date.now());
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
|
|
1177
|
+
state.timedOut = true;
|
|
1178
|
+
throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut();
|
|
1179
|
+
}
|
|
1180
|
+
throw error;
|
|
1181
|
+
} finally {
|
|
1182
|
+
state.settled = true;
|
|
1183
|
+
const shellId = state.shellId;
|
|
1184
|
+
if (shellId !== void 0) {
|
|
1185
|
+
const deletion = Shell.doDeleteShell({
|
|
1186
|
+
...base,
|
|
1187
|
+
shellId
|
|
1188
|
+
}).catch(() => void 0);
|
|
1189
|
+
if (state.timedOut);
|
|
1190
|
+
else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)]);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Upload a buffer over a single WinRS stdin stream.
|
|
1196
|
+
*
|
|
1197
|
+
* This replaces the per-chunk shell cycle: one shell, one command and one
|
|
1198
|
+
* `Send` per 256 KiB, against a receiver that drains the stream straight into
|
|
1199
|
+
* the file. Measured at ~10 s for 2 MiB where the old path took 310 s.
|
|
1200
|
+
*
|
|
1201
|
+
* Returns null — rather than throwing — when the transport this path depends
|
|
1202
|
+
* on is unavailable (a future `winrm-client` layout that drops the internal
|
|
1203
|
+
* module it borrows), so the caller can fall back to {@link uploadBuffer}
|
|
1204
|
+
* instead of failing the transfer.
|
|
1205
|
+
* @param conn - connection parameters.
|
|
1206
|
+
* @param remotePath - destination path on the target.
|
|
1207
|
+
* @param data - the file contents to send.
|
|
1208
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
1209
|
+
* @param timeoutMs - whole-transfer budget.
|
|
1210
|
+
* @returns bytes written, or null when the streaming path is unavailable.
|
|
1211
|
+
*/
|
|
1212
|
+
async function uploadStream(conn, remotePath, data, onProgress, timeoutMs = 6e5) {
|
|
1213
|
+
const sendHttp = loadSendHttp();
|
|
1214
|
+
if (sendHttp === null) return null;
|
|
1215
|
+
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
1216
|
+
let lastError;
|
|
1217
|
+
for (const authMethod of candidates) try {
|
|
1218
|
+
return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
if (error instanceof WinrmTimedOut) throw error;
|
|
1221
|
+
lastError = error;
|
|
1222
|
+
}
|
|
1223
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("all WinRM authentication methods failed");
|
|
1224
|
+
}
|
|
1225
|
+
/** Smallest byte range worth giving a worker; below this the shell setup dominates. */
|
|
1226
|
+
const DOWNLOAD_MIN_RANGE_BYTES = 524288;
|
|
1227
|
+
/** Most shells to open for one download. Measured 3.9x on 16 MiB at four; more adds target load. */
|
|
1228
|
+
const DOWNLOAD_MAX_WORKERS = 4;
|
|
1229
|
+
/** Per-Receive HTTP timeout. The server long-polls, so this only bounds a stalled transport. */
|
|
1230
|
+
const DOWNLOAD_RECEIVE_TIMEOUT_MS = 12e4;
|
|
1231
|
+
/**
|
|
1232
|
+
* One worker's slice of a download: own shell, own command, own byte range.
|
|
1233
|
+
* @param conn - connection parameters.
|
|
1234
|
+
* @param remotePath - file being read.
|
|
1235
|
+
* @param start - first byte offset of this worker's range.
|
|
1236
|
+
* @param length - bytes this worker must deliver.
|
|
1237
|
+
* @param onData - receives each decompressed piece together with its file offset.
|
|
1238
|
+
* @param timeoutMs - budget for the whole range.
|
|
1239
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
1240
|
+
* @param sendHttp - the resolved library HTTP function.
|
|
1241
|
+
* @returns the number of file bytes this worker delivered.
|
|
1242
|
+
*/
|
|
1243
|
+
async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeoutMs, authMethod, sendHttp) {
|
|
1244
|
+
const base = baseParams(conn, authMethod);
|
|
1245
|
+
const target = {
|
|
1246
|
+
host: conn.host,
|
|
1247
|
+
port: conn.port,
|
|
1248
|
+
path: conn.path,
|
|
1249
|
+
username: conn.username,
|
|
1250
|
+
password: conn.password,
|
|
1251
|
+
authMethod,
|
|
1252
|
+
useHttps: conn.useHttps ?? false,
|
|
1253
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1254
|
+
};
|
|
1255
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
1256
|
+
const state = {
|
|
1257
|
+
shellId: void 0,
|
|
1258
|
+
timedOut: false,
|
|
1259
|
+
settled: false
|
|
1260
|
+
};
|
|
1261
|
+
const inner = async () => {
|
|
1262
|
+
const shellId = await Shell.doCreateShell(base);
|
|
1263
|
+
state.shellId = shellId;
|
|
1264
|
+
if (state.settled) {
|
|
1265
|
+
Shell.doDeleteShell({
|
|
1266
|
+
...base,
|
|
1267
|
+
shellId
|
|
1268
|
+
}).catch(() => void 0);
|
|
1269
|
+
throw new WinrmTimedOut();
|
|
1270
|
+
}
|
|
1271
|
+
const commandId = await Command.doExecuteCommand({
|
|
1272
|
+
...base,
|
|
1273
|
+
shellId,
|
|
1274
|
+
command: scriptCommandLine(psSendStream(remotePath, start, length))
|
|
1275
|
+
});
|
|
1276
|
+
const inflater = createGunzip();
|
|
1277
|
+
let inflateError;
|
|
1278
|
+
inflater.on("error", (error) => {
|
|
1279
|
+
inflateError = error;
|
|
1280
|
+
});
|
|
1281
|
+
let written = 0;
|
|
1282
|
+
let stderr = "";
|
|
1283
|
+
inflater.on("data", (piece) => {
|
|
1284
|
+
onData(start + written, piece);
|
|
1285
|
+
written += piece.length;
|
|
1286
|
+
});
|
|
1287
|
+
let stdoutEnded = false;
|
|
1288
|
+
let exitCode;
|
|
1289
|
+
while (!stdoutEnded) {
|
|
1290
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
|
|
1291
|
+
const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS);
|
|
1292
|
+
let sawData = false;
|
|
1293
|
+
for (const stream of result.streams) {
|
|
1294
|
+
if (stream.data.length === 0) {
|
|
1295
|
+
if (stream.name === "stdout" && stream.end) stdoutEnded = true;
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
sawData = true;
|
|
1299
|
+
if (stream.name === "stdout") {
|
|
1300
|
+
inflater.write(stream.data);
|
|
1301
|
+
if (stream.end) stdoutEnded = true;
|
|
1302
|
+
} else stderr += stream.data.toString("utf8");
|
|
1303
|
+
}
|
|
1304
|
+
if (inflateError !== void 0) throw inflateError;
|
|
1305
|
+
if (result.exitCode !== void 0) exitCode = result.exitCode;
|
|
1306
|
+
if (!sawData && exitCode !== void 0) break;
|
|
1307
|
+
}
|
|
1308
|
+
await new Promise((resolve, reject) => {
|
|
1309
|
+
inflater.on("error", reject);
|
|
1310
|
+
inflater.on("end", resolve);
|
|
1311
|
+
inflater.end();
|
|
1312
|
+
});
|
|
1313
|
+
if (inflateError !== void 0) throw inflateError;
|
|
1314
|
+
if (exitCode !== void 0 && exitCode !== 0) throw new Error("streaming download failed on the target: " + stderr.trim());
|
|
1315
|
+
if (written !== length) throw new Error(`streaming download delivered ${written} of ${length} bytes`);
|
|
1316
|
+
return written;
|
|
1317
|
+
};
|
|
1318
|
+
try {
|
|
1319
|
+
return await raceDeadline(inner(), hardDeadline - Date.now());
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
state.timedOut = error instanceof WinrmTimedOut;
|
|
1322
|
+
throw error;
|
|
1323
|
+
} finally {
|
|
1324
|
+
state.settled = true;
|
|
1325
|
+
const shellId = state.shellId;
|
|
1326
|
+
if (shellId !== void 0) try {
|
|
1327
|
+
await raceDeadline(Shell.doDeleteShell({
|
|
1328
|
+
...base,
|
|
1329
|
+
shellId
|
|
1330
|
+
}), DELETE_TIMEOUT_MS);
|
|
1331
|
+
} catch {}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Stream a remote file to a consumer, in parallel byte ranges.
|
|
1336
|
+
*
|
|
1337
|
+
* The old path asked for 48 KiB per shell lifecycle, so a 2 MiB file cost 43
|
|
1338
|
+
* round-trip cycles and 129 s. Measured against this target, one `Receive`
|
|
1339
|
+
* returns at most 128 KiB no matter how large an envelope is declared, which
|
|
1340
|
+
* makes a download latency-bound rather than bandwidth-bound; the fix is
|
|
1341
|
+
* therefore to run several ranges at once. Each worker opens its own shell and
|
|
1342
|
+
* streams its range gzip-compressed, and because ranges are absolute, the
|
|
1343
|
+
* caller can place each piece by offset and completion order does not matter.
|
|
1344
|
+
*
|
|
1345
|
+
* The remote side compresses unconditionally: a compressible 16 MiB file
|
|
1346
|
+
* arrives in one round trip (2.0 s), and an incompressible file was measured no
|
|
1347
|
+
* slower than raw (9457 ms vs 9582 ms).
|
|
1348
|
+
* @param conn - connection parameters.
|
|
1349
|
+
* @param remotePath - file to read.
|
|
1350
|
+
* @param onData - receives each decompressed piece with its absolute file offset.
|
|
1351
|
+
* @param onProgress - receives cumulative bytes delivered and the total size.
|
|
1352
|
+
* @param timeoutMs - budget for the whole download.
|
|
1353
|
+
* @returns the number of bytes delivered, or null when streaming is unavailable.
|
|
1354
|
+
*/
|
|
1355
|
+
async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs = 6e5) {
|
|
1356
|
+
const sendHttp = loadSendHttp();
|
|
1357
|
+
if (sendHttp === null) return null;
|
|
1358
|
+
const total = await remoteFileSize(conn, remotePath);
|
|
1359
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`);
|
|
1360
|
+
if (total === 0) return 0;
|
|
1361
|
+
const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)));
|
|
1362
|
+
const rangeSize = Math.ceil(total / workers);
|
|
1363
|
+
let transferred = 0;
|
|
1364
|
+
let generation = 0;
|
|
1365
|
+
let live = true;
|
|
1366
|
+
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
1367
|
+
let lastError;
|
|
1368
|
+
try {
|
|
1369
|
+
for (const authMethod of candidates) {
|
|
1370
|
+
transferred = 0;
|
|
1371
|
+
const mine = ++generation;
|
|
1372
|
+
const report = (offset, data) => {
|
|
1373
|
+
if (!live || mine !== generation) return;
|
|
1374
|
+
onData(offset, data);
|
|
1375
|
+
transferred += data.length;
|
|
1376
|
+
onProgress?.(transferred, total);
|
|
1377
|
+
};
|
|
1378
|
+
try {
|
|
1379
|
+
const ranges = [];
|
|
1380
|
+
for (let start = 0; start < total; start += rangeSize) ranges.push({
|
|
1381
|
+
start,
|
|
1382
|
+
length: Math.min(rangeSize, total - start)
|
|
1383
|
+
});
|
|
1384
|
+
await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp)));
|
|
1385
|
+
return transferred;
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
if (error instanceof WinrmTimedOut) throw error;
|
|
1388
|
+
lastError = error;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("all WinRM authentication methods failed");
|
|
1392
|
+
} finally {
|
|
1393
|
+
live = false;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
580
1396
|
//#endregion
|
|
581
1397
|
//#region src/smb.ts
|
|
582
1398
|
/**
|
|
583
1399
|
* SMB transfer bridge.
|
|
584
1400
|
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
1401
|
+
* SMB is the fast channel when the admin share is reachable, so `auto` tries it
|
|
1402
|
+
* first and falls back to the WinRM streaming path. This module mounts the
|
|
1403
|
+
* remote host's admin share with `net use` (password passed via stdin, never
|
|
1404
|
+
* argv), copies whole files with node:fs, then verifies the SHA-256 on both
|
|
1405
|
+
* ends.
|
|
590
1406
|
*/
|
|
591
1407
|
/**
|
|
592
1408
|
* Convert a Windows drive path to its UNC admin-share form.
|
|
@@ -669,6 +1485,67 @@ async function remoteSha256(host, remotePath) {
|
|
|
669
1485
|
if (match === null) throw new Error("SMB remote hash verification failed: " + stdout);
|
|
670
1486
|
return match[1].toUpperCase();
|
|
671
1487
|
}
|
|
1488
|
+
/** How long a reachability verdict is trusted before probing again. */
|
|
1489
|
+
const SMB_PROBE_TTL_MS = 6e4;
|
|
1490
|
+
/** Budget for the TCP reachability probe; far below `net use`'s own timeout. */
|
|
1491
|
+
const SMB_PROBE_TIMEOUT_MS = 3e3;
|
|
1492
|
+
const smbProbeCache = /* @__PURE__ */ new Map();
|
|
1493
|
+
/**
|
|
1494
|
+
* Whether the target's SMB port (445) accepts a connection.
|
|
1495
|
+
*
|
|
1496
|
+
* `net use` is the only way to mount the admin share, but it has no useful
|
|
1497
|
+
* timeout: against a host whose 445 is filtered or whose SMB service is down it
|
|
1498
|
+
* spends a full minute before failing. That minute was being paid on *every*
|
|
1499
|
+
* `auto` upload before the WinRM fallback could even start — measured at 61 s
|
|
1500
|
+
* and 58 s on the test host, against a 12 s streaming transfer. A bare TCP
|
|
1501
|
+
* connect answers the same question in milliseconds.
|
|
1502
|
+
*
|
|
1503
|
+
* A negative verdict is cached briefly so a burst of transfers does not re-probe
|
|
1504
|
+
* for each one; a positive verdict is cached too, but the `net use` that follows
|
|
1505
|
+
* still reports its own failure if the share turns out to be unmountable.
|
|
1506
|
+
* @param host - target hostname or address.
|
|
1507
|
+
* @returns true when port 445 accepted a connection.
|
|
1508
|
+
*/
|
|
1509
|
+
async function smbReachable(host) {
|
|
1510
|
+
const cached = smbProbeCache.get(host);
|
|
1511
|
+
if (cached !== void 0 && Date.now() - cached.at < SMB_PROBE_TTL_MS) return cached.reachable;
|
|
1512
|
+
const reachable = await new Promise((resolve) => {
|
|
1513
|
+
const socket = createConnection({
|
|
1514
|
+
host,
|
|
1515
|
+
port: 445
|
|
1516
|
+
});
|
|
1517
|
+
let settled = false;
|
|
1518
|
+
const finish = (value) => {
|
|
1519
|
+
if (settled) return;
|
|
1520
|
+
settled = true;
|
|
1521
|
+
socket.destroy();
|
|
1522
|
+
resolve(value);
|
|
1523
|
+
};
|
|
1524
|
+
const timer = setTimeout(() => finish(false), SMB_PROBE_TIMEOUT_MS);
|
|
1525
|
+
socket.once("connect", () => {
|
|
1526
|
+
clearTimeout(timer);
|
|
1527
|
+
finish(true);
|
|
1528
|
+
});
|
|
1529
|
+
socket.once("error", () => {
|
|
1530
|
+
clearTimeout(timer);
|
|
1531
|
+
finish(false);
|
|
1532
|
+
});
|
|
1533
|
+
socket.once("timeout", () => {
|
|
1534
|
+
clearTimeout(timer);
|
|
1535
|
+
finish(false);
|
|
1536
|
+
});
|
|
1537
|
+
socket.setTimeout(SMB_PROBE_TIMEOUT_MS);
|
|
1538
|
+
});
|
|
1539
|
+
smbProbeCache.set(host, {
|
|
1540
|
+
reachable,
|
|
1541
|
+
at: Date.now()
|
|
1542
|
+
});
|
|
1543
|
+
return reachable;
|
|
1544
|
+
}
|
|
1545
|
+
/** Error thrown when SMB was requested or probed and the target is not listening. */
|
|
1546
|
+
function smbUnavailable(host) {
|
|
1547
|
+
return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running)");
|
|
1548
|
+
}
|
|
672
1549
|
/**
|
|
673
1550
|
* Copy one local file to a remote Windows host over SMB, verifying the
|
|
674
1551
|
* remote SHA-256 matches the local source.
|
|
@@ -981,30 +1858,34 @@ var WinRmEngine = class {
|
|
|
981
1858
|
total: data.length,
|
|
982
1859
|
percent: 0
|
|
983
1860
|
});
|
|
984
|
-
if (channel !== "winrm")
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1861
|
+
if (channel !== "winrm") {
|
|
1862
|
+
if (await smbReachable(entry.host)) try {
|
|
1863
|
+
const outcome = await uploadSmb(entry, localPath, remotePath);
|
|
1864
|
+
onProgress?.({
|
|
1865
|
+
phase: "done",
|
|
1866
|
+
file: remotePath,
|
|
1867
|
+
transferred: outcome.bytes,
|
|
1868
|
+
total: outcome.bytes,
|
|
1869
|
+
percent: 100
|
|
1870
|
+
});
|
|
1871
|
+
return {
|
|
1872
|
+
bytes: outcome.bytes,
|
|
1873
|
+
channel: outcome.channel
|
|
1874
|
+
};
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
if (channel === "smb") throw error;
|
|
1877
|
+
onProgress?.({
|
|
1878
|
+
phase: "connecting",
|
|
1879
|
+
file: remotePath,
|
|
1880
|
+
transferred: 0,
|
|
1881
|
+
total: data.length,
|
|
1882
|
+
percent: 0
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
else if (channel === "smb") throw smbUnavailable(entry.host);
|
|
1006
1886
|
}
|
|
1007
|
-
const
|
|
1887
|
+
const conn = connOf(entry);
|
|
1888
|
+
const report = (transferred, total) => {
|
|
1008
1889
|
onProgress?.({
|
|
1009
1890
|
phase: "transferring",
|
|
1010
1891
|
file: remotePath,
|
|
@@ -1012,7 +1893,9 @@ var WinRmEngine = class {
|
|
|
1012
1893
|
total,
|
|
1013
1894
|
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
|
|
1014
1895
|
});
|
|
1015
|
-
}
|
|
1896
|
+
};
|
|
1897
|
+
let bytes = await uploadStream(conn, remotePath, data, report);
|
|
1898
|
+
if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data, report);
|
|
1016
1899
|
onProgress?.({
|
|
1017
1900
|
phase: "done",
|
|
1018
1901
|
file: remotePath,
|
|
@@ -1038,49 +1921,72 @@ var WinRmEngine = class {
|
|
|
1038
1921
|
total: 0,
|
|
1039
1922
|
percent: 0
|
|
1040
1923
|
});
|
|
1041
|
-
if (channel !== "winrm")
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1924
|
+
if (channel !== "winrm") {
|
|
1925
|
+
if (await smbReachable(entry.host)) try {
|
|
1926
|
+
const outcome = await downloadSmb(entry, remotePath, localPath);
|
|
1927
|
+
onProgress?.({
|
|
1928
|
+
phase: "done",
|
|
1929
|
+
file: remotePath,
|
|
1930
|
+
transferred: outcome.bytes,
|
|
1931
|
+
total: outcome.bytes,
|
|
1932
|
+
percent: 100
|
|
1933
|
+
});
|
|
1934
|
+
return {
|
|
1935
|
+
bytes: outcome.bytes,
|
|
1936
|
+
channel: outcome.channel
|
|
1937
|
+
};
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
if (channel === "smb") throw error;
|
|
1940
|
+
onProgress?.({
|
|
1941
|
+
phase: "connecting",
|
|
1942
|
+
file: remotePath,
|
|
1943
|
+
transferred: 0,
|
|
1944
|
+
total: 0,
|
|
1945
|
+
percent: 0
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
else if (channel === "smb") throw smbUnavailable(entry.host);
|
|
1063
1949
|
}
|
|
1064
|
-
const {
|
|
1065
|
-
|
|
1950
|
+
const { open, rm } = await import("node:fs/promises");
|
|
1951
|
+
let sink = null;
|
|
1952
|
+
let completed = false;
|
|
1066
1953
|
let bytes = 0;
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
sink.write(
|
|
1073
|
-
}
|
|
1954
|
+
let queue = Promise.resolve();
|
|
1955
|
+
const put = (offset, data) => {
|
|
1956
|
+
bytes += data.length;
|
|
1957
|
+
queue = queue.then(async () => {
|
|
1958
|
+
if (sink === null) sink = await open(localPath, "w", 384);
|
|
1959
|
+
await sink.write(data, 0, data.length, offset);
|
|
1960
|
+
});
|
|
1961
|
+
queue.catch(() => void 0);
|
|
1962
|
+
};
|
|
1963
|
+
const report = (transferred, total) => {
|
|
1964
|
+
onProgress?.({
|
|
1074
1965
|
phase: "transferring",
|
|
1075
1966
|
file: remotePath,
|
|
1076
1967
|
transferred,
|
|
1077
1968
|
total,
|
|
1078
1969
|
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
|
|
1079
|
-
})).then(() => sink.end(resolve), (error) => {
|
|
1080
|
-
sink.destroy();
|
|
1081
|
-
reject(error);
|
|
1082
1970
|
});
|
|
1083
|
-
}
|
|
1971
|
+
};
|
|
1972
|
+
try {
|
|
1973
|
+
const conn = connOf(entry);
|
|
1974
|
+
if (await downloadStream(conn, remotePath, put, report) === null) {
|
|
1975
|
+
let offset = 0;
|
|
1976
|
+
await downloadChunks(conn, remotePath, (b64) => {
|
|
1977
|
+
const buffer = Buffer.from(b64, "base64");
|
|
1978
|
+
put(offset, buffer);
|
|
1979
|
+
offset += buffer.length;
|
|
1980
|
+
}, report);
|
|
1981
|
+
}
|
|
1982
|
+
await queue;
|
|
1983
|
+
if (sink === null) sink = await open(localPath, "w", 384);
|
|
1984
|
+
completed = true;
|
|
1985
|
+
} finally {
|
|
1986
|
+
await queue.catch(() => void 0);
|
|
1987
|
+
if (sink !== null) await sink.close();
|
|
1988
|
+
if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => void 0);
|
|
1989
|
+
}
|
|
1084
1990
|
onProgress?.({
|
|
1085
1991
|
phase: "done",
|
|
1086
1992
|
file: remotePath,
|
|
@@ -2265,7 +3171,7 @@ function winrmProcessTool(engine) {
|
|
|
2265
3171
|
function winrmUploadTool(engine) {
|
|
2266
3172
|
return defineTool({
|
|
2267
3173
|
name: "winrm_upload",
|
|
2268
|
-
description: "Upload a local file to a configured Windows host
|
|
3174
|
+
description: "Upload a local file to a configured Windows host. The bytes go over the SMB admin share when it is reachable, otherwise they are streamed over WinRM in one shell with per-chunk gzip (the 48 KiB base64 chunk path is kept only as a fallback). The local path is on THIS machine (the dsh host). Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.",
|
|
2269
3175
|
parameters: {
|
|
2270
3176
|
alias: {
|
|
2271
3177
|
type: "string",
|
|
@@ -2289,7 +3195,7 @@ function winrmUploadTool(engine) {
|
|
|
2289
3195
|
"smb",
|
|
2290
3196
|
"winrm"
|
|
2291
3197
|
],
|
|
2292
|
-
description: "Transfer channel: smb (admin share), winrm (
|
|
3198
|
+
description: "Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback)."
|
|
2293
3199
|
}
|
|
2294
3200
|
},
|
|
2295
3201
|
output: {
|
|
@@ -2333,7 +3239,7 @@ function winrmUploadTool(engine) {
|
|
|
2333
3239
|
function winrmDownloadTool(engine) {
|
|
2334
3240
|
return defineTool({
|
|
2335
3241
|
name: "winrm_download",
|
|
2336
|
-
description: "Download a remote FILE from a configured Windows host to a local path on this machine
|
|
3242
|
+
description: "Download a remote FILE from a configured Windows host to a local path on this machine. The bytes come over the SMB admin share when it is reachable, otherwise they are streamed over WinRM as parallel byte ranges (the 48 KiB base64 chunk path is kept only as a fallback). Directory download is not supported — download files individually. Triggers: download file from Windows server, fetch remote log/artifact.",
|
|
2337
3243
|
parameters: {
|
|
2338
3244
|
alias: {
|
|
2339
3245
|
type: "string",
|
|
@@ -2357,7 +3263,7 @@ function winrmDownloadTool(engine) {
|
|
|
2357
3263
|
"smb",
|
|
2358
3264
|
"winrm"
|
|
2359
3265
|
],
|
|
2360
|
-
description: "Transfer channel: smb (admin share), winrm (
|
|
3266
|
+
description: "Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback)."
|
|
2361
3267
|
}
|
|
2362
3268
|
},
|
|
2363
3269
|
output: {
|
|
@@ -2529,7 +3435,7 @@ const DEFAULT_ENABLED = true;
|
|
|
2529
3435
|
/** Order of the announcement section within the tool-guidance band. */
|
|
2530
3436
|
const SECTION_ORDER = 151;
|
|
2531
3437
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
2532
|
-
const WINRM_GUIDANCE = "本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(
|
|
3438
|
+
const WINRM_GUIDANCE = "本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(SMB 管理共享可达时优先,否则走 WinRM 流式传输,无 SMB 依赖,大文件经 SHA256 校验)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
|
|
2533
3439
|
/**
|
|
2534
3440
|
* Mount the WinRM engine, routes, tools, and announcement.
|
|
2535
3441
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|