dsh-winrm 0.2.0 → 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 +9 -5
- package/README.zh.md +9 -5
- package/lib/client.js +72 -72
- package/lib/index.js +978 -72
- 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/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/powershell.ts +298 -2
- package/src/smb.ts +57 -5
- package/src/tools.ts +2 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw WSMan `Send` for the streaming upload path.
|
|
3
|
+
*
|
|
4
|
+
* The bundled `winrm-client` can only put text on the WinRS stdin stream: its
|
|
5
|
+
* `doSendInput` base64-encodes the input *again* on top of whatever the caller
|
|
6
|
+
* already encoded, and it has no way to close the stream. Both are fatal for a
|
|
7
|
+
* bulk transfer — double base64 costs 1.78x the bytes on the wire, and a
|
|
8
|
+
* stdin stream that never ends cannot tell the remote receiver "that is all".
|
|
9
|
+
*
|
|
10
|
+
* This module therefore builds the `Send` envelope itself and posts it through
|
|
11
|
+
* the library's own `sendHttp`, which keeps Basic/NTLM/SPNEGO and the HTTPS
|
|
12
|
+
* options exactly as the rest of the transport uses them. The stream carries
|
|
13
|
+
* raw file bytes, so the wire cost is 1.0x plus the envelope.
|
|
14
|
+
*
|
|
15
|
+
* The library is reached through `createRequire` rather than a static import
|
|
16
|
+
* because `dist/src/utils/http.js` is an internal CommonJS path with no
|
|
17
|
+
* `exports` map behind it. `loadSendHttp` returns null when that path is gone
|
|
18
|
+
* (a future `winrm-client` layout change), which lets the caller fall back to
|
|
19
|
+
* the old chunked path instead of failing the transfer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import { createRequire } from 'node:module'
|
|
24
|
+
|
|
25
|
+
/** Connection parameters for one target (mirrors `WinRMParams` in client.ts). */
|
|
26
|
+
export interface WsmanTarget {
|
|
27
|
+
host: string
|
|
28
|
+
port: number
|
|
29
|
+
path: string
|
|
30
|
+
username: string
|
|
31
|
+
password: string
|
|
32
|
+
authMethod: 'basic' | 'ntlm'
|
|
33
|
+
useHttps?: boolean
|
|
34
|
+
rejectUnauthorized?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `sendHttp` as the bundled library declares it. */
|
|
38
|
+
type SendHttp = (
|
|
39
|
+
data: string,
|
|
40
|
+
host: string,
|
|
41
|
+
port: number,
|
|
42
|
+
path: string,
|
|
43
|
+
username: string,
|
|
44
|
+
password: string,
|
|
45
|
+
authMethod: 'basic' | 'ntlm',
|
|
46
|
+
timeout?: number,
|
|
47
|
+
useHttps?: boolean,
|
|
48
|
+
rejectUnauthorized?: boolean,
|
|
49
|
+
) => Promise<unknown>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Envelope ceiling declared on every Send, in bytes.
|
|
53
|
+
*
|
|
54
|
+
* This is what the *client* promises the server it will never exceed, and the
|
|
55
|
+
* server enforces its own `MaxEnvelopeSizekb` on top. 512000 is the value the
|
|
56
|
+
* target host reports, and it is far above the library's hardcoded 153600 —
|
|
57
|
+
* raising it is what makes 256 KiB chunks legal.
|
|
58
|
+
*/
|
|
59
|
+
export const WS_MAX_ENVELOPE_BYTES = 512_000
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bytes of XML wrapper around one base64 chunk.
|
|
63
|
+
*
|
|
64
|
+
* Measured against the envelope this module emits: fixed header, selector set,
|
|
65
|
+
* body tags and the message id. Used to size chunks against the ceiling before
|
|
66
|
+
* sending, so a normal transfer never needs a size rejection to converge.
|
|
67
|
+
*/
|
|
68
|
+
export const SEND_ENVELOPE_OVERHEAD_BYTES = 1_600
|
|
69
|
+
|
|
70
|
+
/** The largest raw slice whose base64 form still fits `WS_MAX_ENVELOPE_BYTES`. */
|
|
71
|
+
export const MAX_SEND_SLICE_BYTES = Math.floor(
|
|
72
|
+
((WS_MAX_ENVELOPE_BYTES - SEND_ENVELOPE_OVERHEAD_BYTES) / 4) * 3,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
let sendHttpCache: SendHttp | null | undefined
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the library's `sendHttp`, or null when the internal path is absent.
|
|
79
|
+
* @returns the send function, or null when it cannot be loaded.
|
|
80
|
+
*/
|
|
81
|
+
export function loadSendHttp(): SendHttp | null {
|
|
82
|
+
if (sendHttpCache !== undefined) return sendHttpCache
|
|
83
|
+
try {
|
|
84
|
+
const require = createRequire(import.meta.url)
|
|
85
|
+
const mod = require('winrm-client/dist/src/utils/http.js') as { sendHttp?: unknown }
|
|
86
|
+
sendHttpCache = typeof mod.sendHttp === 'function' ? (mod.sendHttp as SendHttp) : null
|
|
87
|
+
} catch {
|
|
88
|
+
sendHttpCache = null
|
|
89
|
+
}
|
|
90
|
+
return sendHttpCache
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build one WSMan `Send` envelope carrying `chunk` on the stdin stream.
|
|
95
|
+
*
|
|
96
|
+
* The header mirrors what the library emits, with three deliberate changes:
|
|
97
|
+
* `MaxEnvelopeSize` is raised to `limit`, the `Send` action replaces the
|
|
98
|
+
* command's, and the stream body is the caller's base64 verbatim — no second
|
|
99
|
+
* encode. `Name="stdin"` and `CommandId` are what bind the bytes to the
|
|
100
|
+
* running receiver's standard input.
|
|
101
|
+
* @param shellId - target shell from `Shell.doCreateShell`.
|
|
102
|
+
* @param commandId - target command from `Command.doExecuteCommand`.
|
|
103
|
+
* @param chunk - raw bytes to place on the remote stdin stream.
|
|
104
|
+
* @param limit - envelope ceiling to declare.
|
|
105
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
106
|
+
*/
|
|
107
|
+
export function buildSendEnvelope(shellId: string, commandId: string, chunk: Buffer, limit: number): string {
|
|
108
|
+
return (
|
|
109
|
+
'<?xml version="1.0" encoding="UTF-8"?>' +
|
|
110
|
+
'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"' +
|
|
111
|
+
' xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"' +
|
|
112
|
+
' xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"' +
|
|
113
|
+
' xmlns:p="http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd"' +
|
|
114
|
+
' xmlns:rsp="http://schemas.microsoft.com/wbem/wsman/1/windows/shell">' +
|
|
115
|
+
'<s:Header>' +
|
|
116
|
+
'<wsa:To>http://windows-host:5985/wsman</wsa:To>' +
|
|
117
|
+
'<wsman:ResourceURI mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI>' +
|
|
118
|
+
'<wsa:ReplyTo><wsa:Address mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo>' +
|
|
119
|
+
'<wsman:MaxEnvelopeSize mustUnderstand="true">' +
|
|
120
|
+
String(limit) +
|
|
121
|
+
'</wsman:MaxEnvelopeSize>' +
|
|
122
|
+
'<wsa:MessageID>uuid:' +
|
|
123
|
+
randomUUID() +
|
|
124
|
+
'</wsa:MessageID>' +
|
|
125
|
+
'<wsman:Locale mustUnderstand="false" xml:lang="en-US"/>' +
|
|
126
|
+
'<wsman:OperationTimeout>PT60S</wsman:OperationTimeout>' +
|
|
127
|
+
'<wsa:Action mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send</wsa:Action>' +
|
|
128
|
+
'<wsman:SelectorSet><wsman:Selector Name="ShellId">' +
|
|
129
|
+
shellId +
|
|
130
|
+
'</wsman:Selector></wsman:SelectorSet>' +
|
|
131
|
+
'</s:Header>' +
|
|
132
|
+
'<s:Body><rsp:Send><rsp:Stream CommandId="' +
|
|
133
|
+
commandId +
|
|
134
|
+
'" Name="stdin">' +
|
|
135
|
+
chunk.toString('base64') +
|
|
136
|
+
'</rsp:Stream></rsp:Send></s:Body>' +
|
|
137
|
+
'</s:Envelope>'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Push one raw slice onto the remote stdin stream.
|
|
143
|
+
*
|
|
144
|
+
* A rejection here is safe to retry with a smaller slice: the server validates
|
|
145
|
+
* the envelope size before dispatching it to the shell, so nothing reached the
|
|
146
|
+
* receiver's stdin when the call fails on size.
|
|
147
|
+
* @param target - connection parameters (auth already selected).
|
|
148
|
+
* @param shellId - target shell.
|
|
149
|
+
* @param commandId - target command.
|
|
150
|
+
* @param chunk - raw bytes to send.
|
|
151
|
+
* @param sendHttp - the resolved library function.
|
|
152
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
153
|
+
*/
|
|
154
|
+
export async function sendStdinChunk(
|
|
155
|
+
target: WsmanTarget,
|
|
156
|
+
shellId: string,
|
|
157
|
+
commandId: string,
|
|
158
|
+
chunk: Buffer,
|
|
159
|
+
sendHttp: SendHttp,
|
|
160
|
+
timeoutMs: number,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const envelope = buildSendEnvelope(shellId, commandId, chunk, WS_MAX_ENVELOPE_BYTES)
|
|
163
|
+
await sendHttp(
|
|
164
|
+
envelope,
|
|
165
|
+
target.host,
|
|
166
|
+
target.port,
|
|
167
|
+
target.path,
|
|
168
|
+
target.username,
|
|
169
|
+
target.password,
|
|
170
|
+
target.authMethod,
|
|
171
|
+
timeoutMs,
|
|
172
|
+
target.useHttps,
|
|
173
|
+
target.rejectUnauthorized,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a failed Send was refused for exceeding the envelope ceiling.
|
|
179
|
+
*
|
|
180
|
+
* WSMan reports this as a SOAP fault rather than a status code, and the exact
|
|
181
|
+
* wording differs between the HTTP and HTTPS stacks, so the test looks for any
|
|
182
|
+
* of the size-related phrasings plus the plain 413 status. It deliberately
|
|
183
|
+
* does not match a bare "size" in unrelated text.
|
|
184
|
+
* @param error - the rejection to classify.
|
|
185
|
+
* @returns true when retrying with a smaller slice is the right response.
|
|
186
|
+
*/
|
|
187
|
+
export function isEnvelopeTooLarge(error: unknown): boolean {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
189
|
+
return (
|
|
190
|
+
/\b413\b/.test(message) ||
|
|
191
|
+
/envelope/i.test(message) ||
|
|
192
|
+
/MaxEnvelopeSize/i.test(message) ||
|
|
193
|
+
/maximum.*(size|length)/i.test(message)
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build one WSMan `Receive` envelope asking for stdout and stderr.
|
|
199
|
+
*
|
|
200
|
+
* The library's own `buildReceiveOutputRequest` cannot be reused here because
|
|
201
|
+
* it hardcodes `MaxEnvelopeSize` at 153600 in its shared header. Measured
|
|
202
|
+
* against this target, that ceiling is what caps each response at 128 KiB —
|
|
203
|
+
* raising it to 512000 lets one `Receive` return 128 KiB per stream, which is
|
|
204
|
+
* the most the server will put in a single response regardless.
|
|
205
|
+
* @param shellId - target shell.
|
|
206
|
+
* @param commandId - target command whose streams are wanted.
|
|
207
|
+
* @param limit - envelope ceiling to declare.
|
|
208
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
209
|
+
*/
|
|
210
|
+
export function buildReceiveEnvelope(shellId: string, commandId: string, limit: number): string {
|
|
211
|
+
return (
|
|
212
|
+
'<?xml version="1.0" encoding="UTF-8"?>' +
|
|
213
|
+
'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"' +
|
|
214
|
+
' xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"' +
|
|
215
|
+
' xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"' +
|
|
216
|
+
' xmlns:rsp="http://schemas.microsoft.com/wbem/wsman/1/windows/shell">' +
|
|
217
|
+
'<s:Header>' +
|
|
218
|
+
'<wsa:To>http://windows-host:5985/wsman</wsa:To>' +
|
|
219
|
+
'<wsman:ResourceURI mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI>' +
|
|
220
|
+
'<wsa:ReplyTo><wsa:Address mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo>' +
|
|
221
|
+
'<wsman:MaxEnvelopeSize mustUnderstand="true">' +
|
|
222
|
+
String(limit) +
|
|
223
|
+
'</wsman:MaxEnvelopeSize>' +
|
|
224
|
+
'<wsa:MessageID>uuid:' +
|
|
225
|
+
randomUUID() +
|
|
226
|
+
'</wsa:MessageID>' +
|
|
227
|
+
'<wsman:Locale mustUnderstand="false" xml:lang="en-US"/>' +
|
|
228
|
+
'<wsman:OperationTimeout>PT60S</wsman:OperationTimeout>' +
|
|
229
|
+
'<wsa:Action mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive</wsa:Action>' +
|
|
230
|
+
'<wsman:SelectorSet><wsman:Selector Name="ShellId">' +
|
|
231
|
+
shellId +
|
|
232
|
+
'</wsman:Selector></wsman:SelectorSet>' +
|
|
233
|
+
'</s:Header>' +
|
|
234
|
+
'<s:Body><rsp:Receive><rsp:DesiredStream CommandId="' +
|
|
235
|
+
commandId +
|
|
236
|
+
'">stdout stderr</rsp:DesiredStream></rsp:Receive></s:Body>' +
|
|
237
|
+
'</s:Envelope>'
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One decoded `rsp:Stream` element from a Receive response. */
|
|
242
|
+
export interface ReceivedStream {
|
|
243
|
+
/** `Name` attribute: `stdout` or `stderr`. */
|
|
244
|
+
name: string
|
|
245
|
+
/** Decoded payload bytes; empty for a stream element that carried no text. */
|
|
246
|
+
data: Buffer
|
|
247
|
+
/** True when the `End` attribute marks this stream closed. */
|
|
248
|
+
end: boolean
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A decoded Receive response: its streams plus the command's exit code once known. */
|
|
252
|
+
export interface ReceiveResult {
|
|
253
|
+
streams: ReceivedStream[]
|
|
254
|
+
/** `rsp:CommandState.rsp:ExitCode`, or undefined while the command still runs. */
|
|
255
|
+
exitCode?: number
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Decode a base64 text node that the XML parser may have coerced to a number.
|
|
260
|
+
*
|
|
261
|
+
* fast-xml-parser runs with `parseTagValue` enabled, so an all-digit text node
|
|
262
|
+
* comes back as a JS number: `"0123"` parses to `123`, `"0000"` to `0`, and
|
|
263
|
+
* `"1e55"` to a float. A base64 payload is usually long enough to contain a
|
|
264
|
+
* non-digit and stay a string, but the final short chunk of a stream is exactly
|
|
265
|
+
* where a leading zero can appear, and silently reinterpreting it would corrupt
|
|
266
|
+
* the file. Converting through `String()` recovers the digits for every value
|
|
267
|
+
* that lost only leading zeros; a value that came back in exponent form cannot
|
|
268
|
+
* be recovered and is rejected rather than guessed at.
|
|
269
|
+
* @param text - the raw text node as the parser produced it.
|
|
270
|
+
* @returns the base64 text, or null when it cannot be reconstructed.
|
|
271
|
+
*/
|
|
272
|
+
function coerceBase64(text: unknown): string | null {
|
|
273
|
+
if (typeof text === 'string') return text.replace(/\s+/g, '')
|
|
274
|
+
if (typeof text === 'number' && Number.isFinite(text) && Number.isInteger(text) && text >= 0) {
|
|
275
|
+
return String(text)
|
|
276
|
+
}
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read the streams and exit code out of a Receive response.
|
|
282
|
+
*
|
|
283
|
+
* The library's `extractStreams` cannot be used for binary payloads: it decodes
|
|
284
|
+
* every stream through `extractText`, which falls back to `String(obj)` for an
|
|
285
|
+
* element that has attributes but no text node — turning the terminal
|
|
286
|
+
* `End="true"` marker into the literal string `'[object Object]'` — and the
|
|
287
|
+
* library's `doReceiveOutputNonBlocking` then `.trim()`s the decoded text,
|
|
288
|
+
* which would eat whitespace bytes at either end of a real payload. This
|
|
289
|
+
* reader keeps the distinction between "no text" (zero bytes) and "text" and
|
|
290
|
+
* never trims.
|
|
291
|
+
* @param response - the parsed XML object returned by `sendHttp`.
|
|
292
|
+
* @returns the decoded streams and exit code.
|
|
293
|
+
* @throws when a stream's text node cannot be decoded as base64.
|
|
294
|
+
*/
|
|
295
|
+
export function readReceiveResult(response: unknown): ReceiveResult {
|
|
296
|
+
const envelope = response as Record<string, unknown> | null
|
|
297
|
+
const body = (envelope?.['s:Envelope'] as Record<string, unknown> | undefined)?.['s:Body'] as
|
|
298
|
+
| Record<string, unknown>
|
|
299
|
+
| undefined
|
|
300
|
+
const receive = body?.['rsp:ReceiveResponse'] as Record<string, unknown> | undefined
|
|
301
|
+
const streams: ReceivedStream[] = []
|
|
302
|
+
if (receive !== undefined) {
|
|
303
|
+
const raw = receive['rsp:Stream']
|
|
304
|
+
const list = raw === undefined || raw === null ? [] : Array.isArray(raw) ? raw : [raw]
|
|
305
|
+
for (const element of list) {
|
|
306
|
+
const node = element as Record<string, unknown>
|
|
307
|
+
const text = node !== null && typeof node === 'object' && '_' in node ? node['_'] : ''
|
|
308
|
+
const attributes = (node?.['$'] ?? {}) as Record<string, unknown>
|
|
309
|
+
const name = String(attributes['@_Name'] ?? '')
|
|
310
|
+
const end = String(attributes['@_End'] ?? '') === 'true'
|
|
311
|
+
if (text === '' || text === null || text === undefined) {
|
|
312
|
+
streams.push({ name, data: Buffer.alloc(0), end })
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
const base64 = coerceBase64(text)
|
|
316
|
+
if (base64 === null) {
|
|
317
|
+
throw new Error('receive stream returned an undecodable text node: ' + String(text))
|
|
318
|
+
}
|
|
319
|
+
streams.push({ name, data: base64 === '' ? Buffer.alloc(0) : Buffer.from(base64, 'base64'), end })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const state = receive?.['rsp:CommandState'] as Record<string, unknown> | undefined
|
|
323
|
+
const rawExit = state?.['rsp:ExitCode']
|
|
324
|
+
const exitCode = rawExit === undefined || rawExit === null ? undefined : Number(rawExit)
|
|
325
|
+
return { streams, exitCode: exitCode !== undefined && Number.isFinite(exitCode) ? exitCode : undefined }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Fetch one Receive response for a running command.
|
|
330
|
+
* @param target - connection parameters (auth already selected).
|
|
331
|
+
* @param shellId - target shell.
|
|
332
|
+
* @param commandId - target command.
|
|
333
|
+
* @param sendHttp - the resolved library function.
|
|
334
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
335
|
+
* @returns the decoded streams and exit code.
|
|
336
|
+
*/
|
|
337
|
+
export async function receiveOutput(
|
|
338
|
+
target: WsmanTarget,
|
|
339
|
+
shellId: string,
|
|
340
|
+
commandId: string,
|
|
341
|
+
sendHttp: SendHttp,
|
|
342
|
+
timeoutMs: number,
|
|
343
|
+
): Promise<ReceiveResult> {
|
|
344
|
+
const response = await sendHttp(
|
|
345
|
+
buildReceiveEnvelope(shellId, commandId, WS_MAX_ENVELOPE_BYTES),
|
|
346
|
+
target.host,
|
|
347
|
+
target.port,
|
|
348
|
+
target.path,
|
|
349
|
+
target.username,
|
|
350
|
+
target.password,
|
|
351
|
+
target.authMethod,
|
|
352
|
+
timeoutMs,
|
|
353
|
+
target.useHttps,
|
|
354
|
+
target.rejectUnauthorized,
|
|
355
|
+
)
|
|
356
|
+
return readReceiveResult(response)
|
|
357
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The WinRM engine facade: host lookup, PowerShell exec (UTF-8 envelope),
|
|
3
|
-
* service & process management, directory listing,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* service & process management, directory listing, file transfer, cluster
|
|
4
|
+
* execution and the streaming console. The heavy lifting lives in engine/
|
|
5
|
+
* (client, console); this class composes them behind one WinRmEngine instance
|
|
6
|
+
* per plugin apply.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
|
|
10
10
|
import type { HostStore } from './store.ts'
|
|
11
|
-
import { connOf, downloadChunks, runScript, testConnection, uploadBuffer } from './engine/client.ts'
|
|
12
|
-
import { downloadSmb, uploadSmb, type TransferChannel } from './smb.ts'
|
|
11
|
+
import { connOf, downloadChunks, downloadStream, runScript, testConnection, uploadBuffer, uploadStream } from './engine/client.ts'
|
|
12
|
+
import { downloadSmb, smbReachable, smbUnavailable, uploadSmb, type TransferChannel } from './smb.ts'
|
|
13
13
|
import { ConsoleSession } from './engine/console.ts'
|
|
14
14
|
import { psKillProcess, psListDir, psListProcesses, psListServices, psServiceAction } from './powershell.ts'
|
|
15
15
|
|
|
@@ -244,18 +244,31 @@ export class WinRmEngine {
|
|
|
244
244
|
return this.gate.run(alias, async () => {
|
|
245
245
|
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
|
|
246
246
|
if (channel !== 'winrm') {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
247
|
+
// Probe 445 first: `net use` takes ~60 s to fail against a host whose
|
|
248
|
+
// SMB is down, and on `auto` that delay landed before every upload.
|
|
249
|
+
if (await smbReachable(entry.host)) {
|
|
250
|
+
try {
|
|
251
|
+
const outcome = await uploadSmb(entry, localPath, remotePath)
|
|
252
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
|
|
253
|
+
return { bytes: outcome.bytes, channel: outcome.channel }
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (channel === 'smb') throw error
|
|
256
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
|
|
257
|
+
}
|
|
258
|
+
} else if (channel === 'smb') {
|
|
259
|
+
throw smbUnavailable(entry.host)
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
|
-
|
|
262
|
+
// WinRM transport: the streaming path moves the file over a single
|
|
263
|
+
// stdin stream (one shell, one Send per 256 KiB). It returns null when
|
|
264
|
+
// the transport it borrows is unavailable, in which case the chunked
|
|
265
|
+
// path still works — just far more slowly.
|
|
266
|
+
const conn = connOf(entry)
|
|
267
|
+
const report = (transferred: number, total: number): void => {
|
|
257
268
|
onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
|
|
258
|
-
}
|
|
269
|
+
}
|
|
270
|
+
let bytes = await uploadStream(conn, remotePath, data, report)
|
|
271
|
+
if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data, report)
|
|
259
272
|
onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
|
|
260
273
|
return { bytes, channel: 'winrm' }
|
|
261
274
|
})
|
|
@@ -268,34 +281,85 @@ export class WinRmEngine {
|
|
|
268
281
|
return this.gate.run(alias, async () => {
|
|
269
282
|
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
|
|
270
283
|
if (channel !== 'winrm') {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
284
|
+
// Same 445 probe as upload: `net use` has no short timeout of its own.
|
|
285
|
+
if (await smbReachable(entry.host)) {
|
|
286
|
+
try {
|
|
287
|
+
const outcome = await downloadSmb(entry, remotePath, localPath)
|
|
288
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
|
|
289
|
+
return { bytes: outcome.bytes, channel: outcome.channel }
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (channel === 'smb') throw error
|
|
292
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
|
|
293
|
+
}
|
|
294
|
+
} else if (channel === 'smb') {
|
|
295
|
+
throw smbUnavailable(entry.host)
|
|
278
296
|
}
|
|
279
297
|
}
|
|
280
|
-
const {
|
|
281
|
-
|
|
298
|
+
const { open, rm } = await import('node:fs/promises')
|
|
299
|
+
// The destination is opened on the first write rather than up front, so a
|
|
300
|
+
// download that fails its size probe (a path that does not exist, a
|
|
301
|
+
// directory, an unreachable host) does not leave a truncated empty file
|
|
302
|
+
// behind as evidence that it succeeded.
|
|
303
|
+
let sink: Awaited<ReturnType<typeof open>> | null = null
|
|
304
|
+
let completed = false
|
|
282
305
|
let bytes = 0
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
(
|
|
297
|
-
)
|
|
298
|
-
|
|
306
|
+
// Positional writes: the ranges arrive out of order and each piece knows
|
|
307
|
+
// its own offset, so a sequential stream would need full reassembly in
|
|
308
|
+
// memory first. pwrite keeps memory bounded by one Receive response.
|
|
309
|
+
// Writes are chained rather than fired loose: the handle must not close
|
|
310
|
+
// while a write is still in flight, and chaining bounds the pending work
|
|
311
|
+
// to a single write no matter how large the file is. A rejected write
|
|
312
|
+
// propagates down the chain and surfaces once at the final await.
|
|
313
|
+
let queue: Promise<void> = Promise.resolve()
|
|
314
|
+
const put = (offset: number, data: Buffer): void => {
|
|
315
|
+
bytes += data.length
|
|
316
|
+
queue = queue.then(async () => {
|
|
317
|
+
if (sink === null) sink = await open(localPath, 'w', 0o600)
|
|
318
|
+
const handle = sink
|
|
319
|
+
await handle.write(data, 0, data.length, offset)
|
|
320
|
+
})
|
|
321
|
+
// A failed write must not be reported as an unhandled rejection while
|
|
322
|
+
// the transfer is still running: the rejection is re-raised by the
|
|
323
|
+
// final await below, which is where the caller can act on it.
|
|
324
|
+
void queue.catch(() => undefined)
|
|
325
|
+
}
|
|
326
|
+
const report = (transferred: number, total: number): void => {
|
|
327
|
+
onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const conn = connOf(entry)
|
|
331
|
+
const streamed = await downloadStream(conn, remotePath, put, report)
|
|
332
|
+
if (streamed === null) {
|
|
333
|
+
let offset = 0
|
|
334
|
+
await downloadChunks(
|
|
335
|
+
conn,
|
|
336
|
+
remotePath,
|
|
337
|
+
(b64) => {
|
|
338
|
+
const buffer = Buffer.from(b64, 'base64')
|
|
339
|
+
put(offset, buffer)
|
|
340
|
+
offset += buffer.length
|
|
341
|
+
},
|
|
342
|
+
report,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
await queue
|
|
346
|
+
// A zero-byte remote file produces no writes, so the sink is still
|
|
347
|
+
// unopened; create the file rather than finishing with nothing on disk.
|
|
348
|
+
if (sink === null) sink = await open(localPath, 'w', 0o600)
|
|
349
|
+
completed = true
|
|
350
|
+
} finally {
|
|
351
|
+
// `downloadStream` stops its workers from emitting before it settles,
|
|
352
|
+
// but a write already handed to the queue may still be in flight. Let
|
|
353
|
+
// it finish before the handle closes, or it lands on a closed file as
|
|
354
|
+
// an unhandled rejection that takes the whole process down.
|
|
355
|
+
await queue.catch(() => undefined)
|
|
356
|
+
if (sink !== null) await sink.close()
|
|
357
|
+
// A transfer that failed partway would otherwise leave a truncated file
|
|
358
|
+
// that looks exactly like a finished download. Removing it is safe even
|
|
359
|
+
// though it may have overwritten a previous file, because the 'w' open
|
|
360
|
+
// already truncated that file to nothing.
|
|
361
|
+
if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => undefined)
|
|
362
|
+
}
|
|
299
363
|
onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
|
|
300
364
|
return { bytes, channel: 'winrm' }
|
|
301
365
|
})
|