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/src/powershell.ts
CHANGED
|
@@ -152,6 +152,57 @@ export function powershellCommandLine(): string {
|
|
|
152
152
|
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(OUTER_SCRIPT)
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Bootstrap for the streaming upload path: read `<byteCount>\n<script>` from
|
|
157
|
+
* stdin, then run that script **in this process** so it can keep draining the
|
|
158
|
+
* same stdin stream.
|
|
159
|
+
*
|
|
160
|
+
* This exists because {@link psReceiveStream} outgrew the command line. WinRS
|
|
161
|
+
* hands the command line to cmd.exe, whose limit is about 8191 characters, and
|
|
162
|
+
* the receiver is 3424 characters of PowerShell — 9206 once base64-of-UTF-16
|
|
163
|
+
* expands it, over the cap. Shipping the receiver on stdin instead costs one
|
|
164
|
+
* extra `Send` and keeps the command line a constant 1154 characters no matter
|
|
165
|
+
* how the receiver grows.
|
|
166
|
+
*
|
|
167
|
+
* Running the receiver in-process (rather than in a child, as
|
|
168
|
+
* {@link OUTER_SCRIPT} does) is what makes the handoff work: the child would
|
|
169
|
+
* need its own stdin, and the payload has to keep flowing into whatever reads
|
|
170
|
+
* the file. A fresh `[Console]::OpenStandardInput()` inside the receiver picks
|
|
171
|
+
* up exactly where this bootstrap stopped reading, because `Read` on the raw
|
|
172
|
+
* stream never buffers ahead of the count it was asked for.
|
|
173
|
+
*/
|
|
174
|
+
const STREAM_BOOTSTRAP = [
|
|
175
|
+
"$ErrorActionPreference='Continue'",
|
|
176
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8',
|
|
177
|
+
'$i=[Console]::OpenStandardInput()',
|
|
178
|
+
"$d=''",
|
|
179
|
+
'while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}',
|
|
180
|
+
'$n=[int]$d',
|
|
181
|
+
'$u=New-Object byte[] $n',
|
|
182
|
+
'$r=0',
|
|
183
|
+
'while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}',
|
|
184
|
+
'$s=[Text.Encoding]::UTF8.GetString($u,0,$r)',
|
|
185
|
+
'$c=0',
|
|
186
|
+
'try{',
|
|
187
|
+
' &([scriptblock]::Create($s))',
|
|
188
|
+
'}catch{',
|
|
189
|
+
' [Console]::Error.WriteLine([string]($_|Out-String))',
|
|
190
|
+
' $c=1',
|
|
191
|
+
'}',
|
|
192
|
+
'exit $c',
|
|
193
|
+
'',
|
|
194
|
+
].join('\r\n')
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The constant powershell.exe command line for a streaming upload.
|
|
198
|
+
*
|
|
199
|
+
* The receiver travels on stdin right behind it, so this stays well clear of
|
|
200
|
+
* cmd.exe's cap and does not change when the receiver does.
|
|
201
|
+
*/
|
|
202
|
+
export function streamCommandLine(): string {
|
|
203
|
+
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(STREAM_BOOTSTRAP)
|
|
204
|
+
}
|
|
205
|
+
|
|
155
206
|
/**
|
|
156
207
|
* The stdin payload for one command: the UTF-8 **byte** count, a newline, then
|
|
157
208
|
* the raw script.
|
|
@@ -245,11 +296,16 @@ export function psListDir(dir: string): string {
|
|
|
245
296
|
].join('\r\n')
|
|
246
297
|
}
|
|
247
298
|
|
|
248
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* Plain: remote file size in bytes. Emits nothing at all when the path is
|
|
301
|
+
* missing or is a directory, so the caller can tell "absent" from "empty" —
|
|
302
|
+
* both used to collapse to the string "0", which made a download of a
|
|
303
|
+
* nonexistent file look like a successful transfer of a zero-byte one.
|
|
304
|
+
*/
|
|
249
305
|
export function psFileSize(p: string): string {
|
|
250
306
|
return [
|
|
251
307
|
'$__f = Get-Item -LiteralPath ' + sq(p) + ' -ErrorAction SilentlyContinue',
|
|
252
|
-
'if ($null -
|
|
308
|
+
'if ($null -ne $__f -and -not $__f.PSIsContainer) { [string]$__f.Length }',
|
|
253
309
|
'',
|
|
254
310
|
].join('\r\n')
|
|
255
311
|
}
|
|
@@ -287,6 +343,246 @@ export function psWriteChunk(p: string, b64: string, append: boolean): string {
|
|
|
287
343
|
].join('\r\n')
|
|
288
344
|
}
|
|
289
345
|
|
|
346
|
+
/** Magic stamped into the frame header that precedes every stream chunk. */
|
|
347
|
+
export const STREAM_FRAME_MAGIC = 'DSHZ'
|
|
348
|
+
|
|
349
|
+
/** Frame header size: 4 magic bytes + 4 little-endian length bytes + 1 mode byte + 1 reserved. */
|
|
350
|
+
export const STREAM_FRAME_HEADER_BYTES = 10
|
|
351
|
+
|
|
352
|
+
/** Frame mode: the payload after the header is a gzip member. */
|
|
353
|
+
export const STREAM_FRAME_GZIP = 1
|
|
354
|
+
|
|
355
|
+
/** Frame mode: the payload after the header is raw file bytes. */
|
|
356
|
+
export const STREAM_FRAME_RAW = 0
|
|
357
|
+
|
|
358
|
+
/** Frame mode: the payload after the header is the transfer's metadata. */
|
|
359
|
+
export const STREAM_FRAME_HEADER = 2
|
|
360
|
+
|
|
361
|
+
/** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
|
|
362
|
+
const STREAM_BUFFER_BYTES = 262_144
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
|
|
366
|
+
*
|
|
367
|
+
* The frame is what makes a compressed stream legal on a pipe. stdin cannot
|
|
368
|
+
* seek, so the receiver has to be handed the exact byte count of the next gzip
|
|
369
|
+
* member before it lets `GZipStream` touch it; otherwise the inflater would
|
|
370
|
+
* read into the member that follows and the stream would desynchronize. The
|
|
371
|
+
* mode byte travels with the payload so the client can switch between gzip and
|
|
372
|
+
* raw per chunk.
|
|
373
|
+
* @param payload - gzip member bytes or raw file bytes.
|
|
374
|
+
* @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
|
|
375
|
+
* @returns the framed chunk to place on the stdin stream.
|
|
376
|
+
*/
|
|
377
|
+
export function buildStreamFrame(payload: Buffer, mode: number): Buffer {
|
|
378
|
+
const frame = Buffer.alloc(STREAM_FRAME_HEADER_BYTES + payload.length)
|
|
379
|
+
frame.write(STREAM_FRAME_MAGIC, 0, 'ascii')
|
|
380
|
+
frame.writeUInt32LE(payload.length, 4)
|
|
381
|
+
frame[8] = mode
|
|
382
|
+
frame[9] = 0
|
|
383
|
+
payload.copy(frame, STREAM_FRAME_HEADER_BYTES)
|
|
384
|
+
return frame
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Streaming receiver: drain framed stdin chunks into the file named by the
|
|
389
|
+
* header frame, until the declared byte count lands.
|
|
390
|
+
*
|
|
391
|
+
* This is the fast upload path. Instead of shipping base64 text inside a
|
|
392
|
+
* PowerShell script — which costs one full shell create/command/delete cycle
|
|
393
|
+
* per 48 KiB — the remote shell starts this script once and the client then
|
|
394
|
+
* pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
|
|
395
|
+
* left is a single `Send`, so a whole file costs one shell and one command no
|
|
396
|
+
* matter how large it is.
|
|
397
|
+
*
|
|
398
|
+
* The script deliberately takes **no arguments**. The destination path and
|
|
399
|
+
* total byte count arrive as the first frame on stdin, which keeps the command
|
|
400
|
+
* line a constant size: WinRS hands the command line to cmd.exe, whose limit
|
|
401
|
+
* is about 8191 characters, and a long remote path interpolated into the
|
|
402
|
+
* script would eat into that budget (the script is base64-of-UTF-16 on the
|
|
403
|
+
* wire, so every character costs 2.67). Reading them from the stream removes
|
|
404
|
+
* the limit entirely and mirrors how `powershellCommandLine` stays constant.
|
|
405
|
+
*
|
|
406
|
+
* Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
|
|
407
|
+
* 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
|
|
408
|
+
* explicit frame is what makes a *pipe* usable for a compressed stream: stdin
|
|
409
|
+
* cannot seek, so the receiver must be told how long the next gzip member is
|
|
410
|
+
* before it hands the bytes to `GZipStream`, or the inflater would read into
|
|
411
|
+
* the member that follows. The mode byte lets the client pick per chunk — gzip
|
|
412
|
+
* when it actually shrinks the data, raw bytes when it would not — so
|
|
413
|
+
* incompressible files never pay compression overhead.
|
|
414
|
+
*
|
|
415
|
+
* The script prints the three-line envelope on stdout with the byte count as
|
|
416
|
+
* its payload, so the caller can prove the transfer landed complete.
|
|
417
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
418
|
+
*/
|
|
419
|
+
export function psReceiveStream(): string {
|
|
420
|
+
return [
|
|
421
|
+
"$ErrorActionPreference = 'Stop'",
|
|
422
|
+
"$__a = ''",
|
|
423
|
+
"$__e = ''",
|
|
424
|
+
'$__c = 0',
|
|
425
|
+
'try {',
|
|
426
|
+
' $__i = [Console]::OpenStandardInput()',
|
|
427
|
+
' $__hdr = New-Object byte[] ' + String(STREAM_FRAME_HEADER_BYTES),
|
|
428
|
+
' $__got = 0',
|
|
429
|
+
' while ($__got -lt $__hdr.Length) {',
|
|
430
|
+
' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
|
|
431
|
+
' if ($__k -le 0) { break }',
|
|
432
|
+
' $__got += $__k',
|
|
433
|
+
' }',
|
|
434
|
+
' if ($__got -lt $__hdr.Length) { throw ' + sq('upload stream ended before its header') + ' }',
|
|
435
|
+
' 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') + ' }',
|
|
436
|
+
' if ($__hdr[8] -ne ' + String(STREAM_FRAME_HEADER) + ') { throw ' + sq('upload stream started without a header frame') + ' }',
|
|
437
|
+
' $__size = 0',
|
|
438
|
+
' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
|
|
439
|
+
' $__meta = New-Object byte[] $__size',
|
|
440
|
+
' $__got = 0',
|
|
441
|
+
' while ($__got -lt $__size) {',
|
|
442
|
+
' $__k = $__i.Read($__meta, $__got, $__size - $__got)',
|
|
443
|
+
' if ($__k -le 0) { break }',
|
|
444
|
+
' $__got += $__k',
|
|
445
|
+
' }',
|
|
446
|
+
' if ($__got -lt $__size) { throw ' + sq('upload stream truncated its header frame') + ' }',
|
|
447
|
+
' $__text = [System.Text.Encoding]::UTF8.GetString($__meta)',
|
|
448
|
+
' $__cut = $__text.IndexOf([char]10)',
|
|
449
|
+
' if ($__cut -lt 0) { throw ' + sq('upload header frame is malformed') + ' }',
|
|
450
|
+
' $__n = [long]$__text.Substring(0, $__cut)',
|
|
451
|
+
' $__p = $__text.Substring($__cut + 1)',
|
|
452
|
+
' $__d = [System.IO.Path]::GetDirectoryName($__p)',
|
|
453
|
+
' if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }',
|
|
454
|
+
' $__fs = New-Object System.IO.FileStream($__p, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)',
|
|
455
|
+
' $__w = 0',
|
|
456
|
+
' try {',
|
|
457
|
+
' $__buf = New-Object byte[] ' + String(STREAM_BUFFER_BYTES),
|
|
458
|
+
' while ($__w -lt $__n) {',
|
|
459
|
+
' $__got = 0',
|
|
460
|
+
' while ($__got -lt $__hdr.Length) {',
|
|
461
|
+
' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
|
|
462
|
+
' if ($__k -le 0) { break }',
|
|
463
|
+
' $__got += $__k',
|
|
464
|
+
' }',
|
|
465
|
+
' if ($__got -lt $__hdr.Length) { break }',
|
|
466
|
+
' 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') + ' }',
|
|
467
|
+
' $__size = 0',
|
|
468
|
+
' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
|
|
469
|
+
' $__mode = $__hdr[8]',
|
|
470
|
+
' $__blob = New-Object byte[] $__size',
|
|
471
|
+
' $__got = 0',
|
|
472
|
+
' while ($__got -lt $__size) {',
|
|
473
|
+
' $__k = $__i.Read($__blob, $__got, $__size - $__got)',
|
|
474
|
+
' if ($__k -le 0) { break }',
|
|
475
|
+
' $__got += $__k',
|
|
476
|
+
' }',
|
|
477
|
+
' if ($__got -lt $__size) { break }',
|
|
478
|
+
' if ($__mode -eq ' + String(STREAM_FRAME_GZIP) + ') {',
|
|
479
|
+
' $__ms = New-Object System.IO.MemoryStream(, $__blob)',
|
|
480
|
+
' $__gz = New-Object System.IO.Compression.GZipStream($__ms, [System.IO.Compression.CompressionMode]::Decompress)',
|
|
481
|
+
' try {',
|
|
482
|
+
' while ($true) {',
|
|
483
|
+
' $__k = $__gz.Read($__buf, 0, $__buf.Length)',
|
|
484
|
+
' if ($__k -le 0) { break }',
|
|
485
|
+
' $__fs.Write($__buf, 0, $__k)',
|
|
486
|
+
' $__w += $__k',
|
|
487
|
+
' }',
|
|
488
|
+
' } finally { $__gz.Dispose(); $__ms.Dispose() }',
|
|
489
|
+
' } else {',
|
|
490
|
+
' $__fs.Write($__blob, 0, $__blob.Length)',
|
|
491
|
+
' $__w += $__blob.Length',
|
|
492
|
+
' }',
|
|
493
|
+
' }',
|
|
494
|
+
' } finally { $__fs.Dispose() }',
|
|
495
|
+
' $__a = [string]$__w',
|
|
496
|
+
'} catch {',
|
|
497
|
+
' $__a = [string]$__a + [string]($_.Exception.Message)',
|
|
498
|
+
' $__c = 1',
|
|
499
|
+
'}',
|
|
500
|
+
sq(ENVELOPE_MARKER) + ' + [string]$__c',
|
|
501
|
+
"'O:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__a))",
|
|
502
|
+
"'E:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__e))",
|
|
503
|
+
'',
|
|
504
|
+
].join('\r\n')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Remote-side read buffer used by {@link psSendStream} while draining the file to stdout. */
|
|
508
|
+
const SEND_BUFFER_BYTES = 262_144
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Streaming sender: write one byte range of the file to standard output,
|
|
512
|
+
* gzip-compressed, for the download path.
|
|
513
|
+
*
|
|
514
|
+
* This is the mirror of {@link psReceiveStream}. The old download path asked
|
|
515
|
+
* for 48 KiB at a time, and every chunk cost a full shell create / command /
|
|
516
|
+
* delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
|
|
517
|
+
* the shell is created once and the client then simply receives; the only
|
|
518
|
+
* per-chunk cost left is one `Receive`, and the target streams the file as fast
|
|
519
|
+
* as the transport will drain it.
|
|
520
|
+
*
|
|
521
|
+
* Two details make this work:
|
|
522
|
+
*
|
|
523
|
+
* - **Only stderr may carry text.** Anything written to the PowerShell success
|
|
524
|
+
* stream would be merged into the payload and corrupt the file, so the byte
|
|
525
|
+
* count and any error go to `[Console]::Error` and the exit code, not stdout.
|
|
526
|
+
* - **The range is positional.** `Position` plus a byte count lets several
|
|
527
|
+
* shells serve different parts of one file concurrently; each writes to its
|
|
528
|
+
* own offset on the client, so completion order does not matter.
|
|
529
|
+
*
|
|
530
|
+
* Output is gzip-compressed unconditionally. Measured against this target, a
|
|
531
|
+
* compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
|
|
532
|
+
* incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
|
|
533
|
+
* compression pays for itself or costs nothing.
|
|
534
|
+
* @param remotePath - file to read on the target.
|
|
535
|
+
* @param start - first byte offset of this worker's range.
|
|
536
|
+
* @param length - number of bytes this worker must send.
|
|
537
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
538
|
+
*/
|
|
539
|
+
export function psSendStream(remotePath: string, start: number, length: number): string {
|
|
540
|
+
return [
|
|
541
|
+
"$ErrorActionPreference = 'Stop'",
|
|
542
|
+
'$__o = [Console]::OpenStandardOutput()',
|
|
543
|
+
'$__n = 0',
|
|
544
|
+
'try {',
|
|
545
|
+
' $__fs = [System.IO.File]::OpenRead(' + sq(remotePath) + ')',
|
|
546
|
+
' try {',
|
|
547
|
+
' $__fs.Position = [long]' + String(start),
|
|
548
|
+
' $__left = [long]' + String(length),
|
|
549
|
+
' $__gz = New-Object System.IO.Compression.GZipStream($__o, [System.IO.Compression.CompressionMode]::Compress, $true)',
|
|
550
|
+
' try {',
|
|
551
|
+
' $__buf = New-Object byte[] ' + String(SEND_BUFFER_BYTES),
|
|
552
|
+
' while ($__left -gt 0) {',
|
|
553
|
+
' $__want = [int][Math]::Min([long]$__buf.Length, $__left)',
|
|
554
|
+
' $__k = $__fs.Read($__buf, 0, $__want)',
|
|
555
|
+
' if ($__k -le 0) { break }',
|
|
556
|
+
' $__gz.Write($__buf, 0, $__k)',
|
|
557
|
+
' $__n += $__k',
|
|
558
|
+
' $__left -= $__k',
|
|
559
|
+
' }',
|
|
560
|
+
' $__gz.Flush()',
|
|
561
|
+
' } finally { $__gz.Dispose() }',
|
|
562
|
+
' $__o.Flush()',
|
|
563
|
+
' } finally { $__fs.Dispose() }',
|
|
564
|
+
" [Console]::Error.WriteLine('__DSH_DL__ OK ' + [string]$__n)",
|
|
565
|
+
'} catch {',
|
|
566
|
+
" [Console]::Error.WriteLine('__DSH_DL__ ERR ' + [string]($_.Exception.Message))",
|
|
567
|
+
' throw',
|
|
568
|
+
'}',
|
|
569
|
+
'',
|
|
570
|
+
].join('\r\n')
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Wrap an arbitrary script in the constant powershell.exe invocation.
|
|
575
|
+
*
|
|
576
|
+
* Only safe for scripts that fit cmd.exe's ~8191 character command line (the
|
|
577
|
+
* script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
|
|
578
|
+
* {@link psReceiveStream} outgrew that and travels on stdin instead.
|
|
579
|
+
* @param script - the PowerShell script to run.
|
|
580
|
+
* @returns the command line to hand to WinRS.
|
|
581
|
+
*/
|
|
582
|
+
export function scriptCommandLine(script: string): string {
|
|
583
|
+
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
|
|
584
|
+
}
|
|
585
|
+
|
|
290
586
|
/** Decode one base64 envelope field, tolerating whitespace the transport added. */
|
|
291
587
|
function decodeField(base64: string): string {
|
|
292
588
|
return Buffer.from(base64.replace(/\s+/g, ''), 'base64').toString('utf8')
|
package/src/smb.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SMB transfer bridge.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* SMB is the fast channel when the admin share is reachable, so `auto` tries it
|
|
5
|
+
* first and falls back to the WinRM streaming path. This module mounts the
|
|
6
|
+
* remote host's admin share with `net use` (password passed via stdin, never
|
|
7
|
+
* argv), copies whole files with node:fs, then verifies the SHA-256 on both
|
|
8
|
+
* ends.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { spawn } from 'node:child_process'
|
|
12
12
|
import { copyFile, stat } from 'node:fs/promises'
|
|
13
13
|
import { createHash } from 'node:crypto'
|
|
14
14
|
import { readFile } from 'node:fs/promises'
|
|
15
|
+
import { createConnection } from 'node:net'
|
|
15
16
|
import type { WinHostEntry } from './protocol.ts'
|
|
16
17
|
|
|
17
18
|
/** Per-transfer channel selector. */
|
|
@@ -85,6 +86,57 @@ async function remoteSha256(host: string, remotePath: string): Promise<string> {
|
|
|
85
86
|
return match[1].toUpperCase()
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** How long a reachability verdict is trusted before probing again. */
|
|
90
|
+
const SMB_PROBE_TTL_MS = 60_000
|
|
91
|
+
|
|
92
|
+
/** Budget for the TCP reachability probe; far below `net use`'s own timeout. */
|
|
93
|
+
const SMB_PROBE_TIMEOUT_MS = 3_000
|
|
94
|
+
|
|
95
|
+
const smbProbeCache = new Map<string, { reachable: boolean; at: number }>()
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether the target's SMB port (445) accepts a connection.
|
|
99
|
+
*
|
|
100
|
+
* `net use` is the only way to mount the admin share, but it has no useful
|
|
101
|
+
* timeout: against a host whose 445 is filtered or whose SMB service is down it
|
|
102
|
+
* spends a full minute before failing. That minute was being paid on *every*
|
|
103
|
+
* `auto` upload before the WinRM fallback could even start — measured at 61 s
|
|
104
|
+
* and 58 s on the test host, against a 12 s streaming transfer. A bare TCP
|
|
105
|
+
* connect answers the same question in milliseconds.
|
|
106
|
+
*
|
|
107
|
+
* A negative verdict is cached briefly so a burst of transfers does not re-probe
|
|
108
|
+
* for each one; a positive verdict is cached too, but the `net use` that follows
|
|
109
|
+
* still reports its own failure if the share turns out to be unmountable.
|
|
110
|
+
* @param host - target hostname or address.
|
|
111
|
+
* @returns true when port 445 accepted a connection.
|
|
112
|
+
*/
|
|
113
|
+
export async function smbReachable(host: string): Promise<boolean> {
|
|
114
|
+
const cached = smbProbeCache.get(host)
|
|
115
|
+
if (cached !== undefined && Date.now() - cached.at < SMB_PROBE_TTL_MS) return cached.reachable
|
|
116
|
+
const reachable = await new Promise<boolean>((resolve) => {
|
|
117
|
+
const socket = createConnection({ host, port: 445 })
|
|
118
|
+
let settled = false
|
|
119
|
+
const finish = (value: boolean): void => {
|
|
120
|
+
if (settled) return
|
|
121
|
+
settled = true
|
|
122
|
+
socket.destroy()
|
|
123
|
+
resolve(value)
|
|
124
|
+
}
|
|
125
|
+
const timer = setTimeout(() => finish(false), SMB_PROBE_TIMEOUT_MS)
|
|
126
|
+
socket.once('connect', () => { clearTimeout(timer); finish(true) })
|
|
127
|
+
socket.once('error', () => { clearTimeout(timer); finish(false) })
|
|
128
|
+
socket.once('timeout', () => { clearTimeout(timer); finish(false) })
|
|
129
|
+
socket.setTimeout(SMB_PROBE_TIMEOUT_MS)
|
|
130
|
+
})
|
|
131
|
+
smbProbeCache.set(host, { reachable, at: Date.now() })
|
|
132
|
+
return reachable
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Error thrown when SMB was requested or probed and the target is not listening. */
|
|
136
|
+
export function smbUnavailable(host: string): Error {
|
|
137
|
+
return new Error('SMB is not reachable on ' + host + ':445 (the admin share needs TCP 445 open and the Server service running)')
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
/**
|
|
89
141
|
* Copy one local file to a remote Windows host over SMB, verifying the
|
|
90
142
|
* remote SHA-256 matches the local source.
|
package/src/tools.ts
CHANGED
|
@@ -266,13 +266,13 @@ export function winrmProcessTool(engine: WinRmEngine) {
|
|
|
266
266
|
export function winrmUploadTool(engine: WinRmEngine) {
|
|
267
267
|
return defineTool({
|
|
268
268
|
name: 'winrm_upload',
|
|
269
|
-
description: 'Upload a local file to a configured Windows host
|
|
269
|
+
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). ' +
|
|
270
270
|
'Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.',
|
|
271
271
|
parameters: {
|
|
272
272
|
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
273
273
|
localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
|
|
274
274
|
remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created).' },
|
|
275
|
-
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (
|
|
275
|
+
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback).' },
|
|
276
276
|
},
|
|
277
277
|
output: {
|
|
278
278
|
schema: {
|
|
@@ -305,13 +305,13 @@ export function winrmUploadTool(engine: WinRmEngine) {
|
|
|
305
305
|
export function winrmDownloadTool(engine: WinRmEngine) {
|
|
306
306
|
return defineTool({
|
|
307
307
|
name: 'winrm_download',
|
|
308
|
-
description: 'Download a remote FILE from a configured Windows host to a local path on this machine
|
|
308
|
+
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. ' +
|
|
309
309
|
'Triggers: download file from Windows server, fetch remote log/artifact.',
|
|
310
310
|
parameters: {
|
|
311
311
|
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
312
312
|
remotePath: { type: 'string', required: true, description: 'Remote file path, e.g. C:\\temp\\file.txt.' },
|
|
313
313
|
localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
|
|
314
|
-
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (
|
|
314
|
+
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback).' },
|
|
315
315
|
},
|
|
316
316
|
output: {
|
|
317
317
|
schema: {
|