dsh-winrm 0.1.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/LICENSE +73 -0
- package/README.md +112 -0
- package/README.zh.md +119 -0
- package/RELEASING.md +75 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2076 -0
- package/lib/index.js +2458 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +59 -0
- package/lib/types/client/index.d.ts +38 -0
- package/lib/types/client/locales.d.ts +231 -0
- package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
- package/lib/types/client/panel/ServicesTab.d.ts +7 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
- package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
- package/lib/types/client/panel/helpers.d.ts +16 -0
- package/lib/types/engine/client.d.ts +49 -0
- package/lib/types/engine/console.d.ts +23 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/powershell.d.ts +73 -0
- package/lib/types/protocol.d.ts +176 -0
- package/lib/types/routes.d.ts +26 -0
- package/lib/types/smb.d.ts +31 -0
- package/lib/types/store.d.ts +44 -0
- package/lib/types/tools.d.ts +20 -0
- package/package.json +111 -0
- package/scripts/enable-winrm.ps1 +0 -0
- package/scripts/postbuild.mjs +40 -0
- package/scripts/validate-manifest.mjs +18 -0
- package/src/client/api.ts +345 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +82 -0
- package/src/client/locales.ts +243 -0
- package/src/client/panel/ConsoleTab.tsx +142 -0
- package/src/client/panel/HostFormDialog.tsx +142 -0
- package/src/client/panel/HostsTab.tsx +153 -0
- package/src/client/panel/ProcessesTab.tsx +119 -0
- package/src/client/panel/ServicesTab.tsx +123 -0
- package/src/client/panel/TransferTab.tsx +161 -0
- package/src/client/panel/WinrmPanel.tsx +85 -0
- package/src/client/panel/WinrmPanelIcon.tsx +27 -0
- package/src/client/panel/helpers.ts +33 -0
- package/src/client/panel/panel.module.css +1000 -0
- package/src/engine/client.ts +291 -0
- package/src/engine/console.ts +62 -0
- package/src/engine.ts +331 -0
- package/src/index.ts +142 -0
- package/src/invariant.ts +4 -0
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/powershell.ts +208 -0
- package/src/protocol.ts +178 -0
- package/src/routes.ts +490 -0
- package/src/smb.ts +129 -0
- package/src/store.ts +232 -0
- package/src/tools.ts +387 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WinRM transport bridge.
|
|
3
|
+
*
|
|
4
|
+
* Runs each PowerShell payload over a native Node.js WinRM client
|
|
5
|
+
* (winrm-client) — no Python/pywinrm subprocess. The script rides the
|
|
6
|
+
* UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
|
|
7
|
+
* any codepage output survives the WinRM transport losslessly; the envelope
|
|
8
|
+
* is base64-encoded with `-EncodedCommand`, keeping the command line a
|
|
9
|
+
* single ASCII-safe line that WinRS/cmd.exe cannot mangle.
|
|
10
|
+
*
|
|
11
|
+
* Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
|
|
12
|
+
* first, then Basic) with fallback, so a host that only enables one scheme
|
|
13
|
+
* still connects. Credentials are passed to the library in-process — they
|
|
14
|
+
* never appear in a child-process argument list.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Command, Shell } from 'winrm-client'
|
|
18
|
+
import type { WinHostEntry } from '../protocol.ts'
|
|
19
|
+
import { parseEnvelope, powershellCommandLine, psFileSize, psReadChunk, psWriteChunk, stripClixml, winrmPowerShellScript } from '../powershell.ts'
|
|
20
|
+
|
|
21
|
+
/** Connection parameters for one target (transport-agnostic projection of a host entry). */
|
|
22
|
+
export interface WinRMParams {
|
|
23
|
+
host: string
|
|
24
|
+
port: number
|
|
25
|
+
path: string
|
|
26
|
+
username: string
|
|
27
|
+
password: string
|
|
28
|
+
useHttps?: boolean
|
|
29
|
+
rejectUnauthorized?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** winrm-client auth schemes (mirrors its `AuthMethod`). */
|
|
33
|
+
type WinrmAuth = 'basic' | 'ntlm'
|
|
34
|
+
|
|
35
|
+
/** Extra wall-clock budget so connection setup does not eat the command's timeout window. */
|
|
36
|
+
const SETUP_GRACE_MS = 5_000
|
|
37
|
+
|
|
38
|
+
/** Upper bound on the best-effort shell-delete round trip after a timed-out call. */
|
|
39
|
+
const DELETE_TIMEOUT_MS = 10_000
|
|
40
|
+
|
|
41
|
+
/** Sentinel: the command exceeded its deadline (distinct from auth/transport errors). */
|
|
42
|
+
class WinrmTimedOut extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super('WinRM request timed out')
|
|
45
|
+
this.name = 'WinrmTimedOut'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Small cancellable delay used to bound the shell-delete cleanup. */
|
|
50
|
+
function sleep(ms: number): Promise<void> {
|
|
51
|
+
return new Promise(resolve => { setTimeout(resolve, ms) })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Race a promise against a deadline; the loser's later settlement is
|
|
56
|
+
* ignored (Promise.race attaches a handler to both), so a hung WinRM call
|
|
57
|
+
* cannot surface as an unhandled rejection.
|
|
58
|
+
* @param promise - the operation to bound.
|
|
59
|
+
* @param ms - milliseconds before rejecting with {@link WinrmTimedOut}.
|
|
60
|
+
* @returns the operation result, or rejects with WinrmTimedOut on deadline.
|
|
61
|
+
*/
|
|
62
|
+
async function raceDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
63
|
+
let timer: NodeJS.Timeout | undefined
|
|
64
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
65
|
+
timer = setTimeout(() => { reject(new WinrmTimedOut()) }, ms)
|
|
66
|
+
})
|
|
67
|
+
try {
|
|
68
|
+
return await Promise.race([promise, timeout])
|
|
69
|
+
} finally {
|
|
70
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build winrm-client's base params for one auth scheme. */
|
|
75
|
+
function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
|
|
76
|
+
return {
|
|
77
|
+
host: conn.host,
|
|
78
|
+
port: conn.port,
|
|
79
|
+
path: conn.path,
|
|
80
|
+
username: conn.username,
|
|
81
|
+
password: conn.password,
|
|
82
|
+
authMethod,
|
|
83
|
+
useHttps: conn.useHttps ?? false,
|
|
84
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One shell lifecycle: create → run the encoded script → receive until the
|
|
90
|
+
* command completes → delete. Bounded by an overall deadline; on timeout the
|
|
91
|
+
* delete is fired without blocking the caller.
|
|
92
|
+
* @param conn - connection parameters.
|
|
93
|
+
* @param envelope - the wrapped UTF-8 envelope script (already prepared).
|
|
94
|
+
* @param timeoutMs - command execution budget measured from just after Execute.
|
|
95
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
96
|
+
* @returns stdout/stderr captured from the WinRS streams.
|
|
97
|
+
* @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
|
|
98
|
+
*/
|
|
99
|
+
async function attemptOnce(
|
|
100
|
+
conn: WinRMParams,
|
|
101
|
+
envelope: string,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
authMethod: WinrmAuth,
|
|
104
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
105
|
+
const base = baseParams(conn, authMethod)
|
|
106
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
|
|
107
|
+
const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
|
|
108
|
+
|
|
109
|
+
const inner = (async (): Promise<{ stdout: string; stderr: string }> => {
|
|
110
|
+
const shellId = await Shell.doCreateShell(base)
|
|
111
|
+
state.shellId = shellId
|
|
112
|
+
// The deadline can fire while the shell is still being created, in which
|
|
113
|
+
// case this attempt has already settled and its `finally` ran without a
|
|
114
|
+
// shell id: abandon the request and remove the shell here so a slow
|
|
115
|
+
// handshake cannot leak it or run the command after the timeout.
|
|
116
|
+
if (state.settled) {
|
|
117
|
+
void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
118
|
+
throw new WinrmTimedOut()
|
|
119
|
+
}
|
|
120
|
+
const command = powershellCommandLine(envelope)
|
|
121
|
+
const commandId = await Command.doExecuteCommand({ ...base, shellId, command })
|
|
122
|
+
const receive = { ...base, shellId, commandId }
|
|
123
|
+
const commandDeadline = Date.now() + timeoutMs
|
|
124
|
+
let stdout = ''
|
|
125
|
+
let stderr = ''
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
|
|
128
|
+
const chunk = await Command.doReceiveOutputNonBlocking(receive)
|
|
129
|
+
stdout += chunk.output
|
|
130
|
+
stderr += chunk.stderr
|
|
131
|
+
if (chunk.isComplete) return { stdout, stderr }
|
|
132
|
+
}
|
|
133
|
+
})()
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
return await raceDeadline(inner, hardDeadline - Date.now())
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
|
|
139
|
+
state.timedOut = true
|
|
140
|
+
throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut()
|
|
141
|
+
}
|
|
142
|
+
throw error
|
|
143
|
+
} finally {
|
|
144
|
+
state.settled = true
|
|
145
|
+
const shellId = state.shellId
|
|
146
|
+
if (shellId !== undefined) {
|
|
147
|
+
const deletion = Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
|
|
148
|
+
if (state.timedOut) void deletion
|
|
149
|
+
else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)])
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run one envelope over WinRM, trying each auth scheme in order until one
|
|
156
|
+
* succeeds. A timeout aborts immediately (retrying auth would waste the
|
|
157
|
+
* remaining budget on a reachable-but-slow host).
|
|
158
|
+
* @param conn - connection parameters.
|
|
159
|
+
* @param envelope - the wrapped UTF-8 envelope script.
|
|
160
|
+
* @param timeoutMs - command execution budget.
|
|
161
|
+
* @returns captured stdout/stderr.
|
|
162
|
+
* @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
|
|
163
|
+
*/
|
|
164
|
+
async function runWinrm(
|
|
165
|
+
conn: WinRMParams,
|
|
166
|
+
envelope: string,
|
|
167
|
+
timeoutMs: number,
|
|
168
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
169
|
+
const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
|
|
170
|
+
let lastError: unknown
|
|
171
|
+
for (const authMethod of candidates) {
|
|
172
|
+
try {
|
|
173
|
+
return await attemptOnce(conn, envelope, timeoutMs, authMethod)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof WinrmTimedOut) throw error
|
|
176
|
+
lastError = error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runScript(
|
|
183
|
+
conn: WinRMParams,
|
|
184
|
+
script: string,
|
|
185
|
+
options: { timeoutMs?: number; onChunk?: (chunk: { output: string; stderr: string }) => void } = {},
|
|
186
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; durationMs: number }> {
|
|
187
|
+
const started = Date.now()
|
|
188
|
+
const timeoutMs = options.timeoutMs ?? 60_000
|
|
189
|
+
const envelope = winrmPowerShellScript(script)
|
|
190
|
+
|
|
191
|
+
let outcome: { stdout: string; stderr: string }
|
|
192
|
+
try {
|
|
193
|
+
outcome = await runWinrm(conn, envelope, timeoutMs)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof WinrmTimedOut) {
|
|
196
|
+
return { stdout: '', stderr: '', exitCode: null, timedOut: true, durationMs: Date.now() - started }
|
|
197
|
+
}
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const raw = outcome.stdout
|
|
202
|
+
const parsed = parseEnvelope(raw)
|
|
203
|
+
// The envelope is the wrapper's completion signal, and it always prints —
|
|
204
|
+
// even for output that is empty. Its absence therefore means the script
|
|
205
|
+
// never ran to completion (a parse error, or an aborted shell), which is a
|
|
206
|
+
// failure; the raw stream is still returned as evidence.
|
|
207
|
+
const exitCode = parsed !== null ? parsed.exitCode : 1
|
|
208
|
+
// A script that reached the envelope leaves only the host's CLIXML records
|
|
209
|
+
// on stderr; an aborted one keeps its stderr untouched for diagnosis.
|
|
210
|
+
const stderr = parsed !== null ? stripClixml(outcome.stderr) : outcome.stderr
|
|
211
|
+
options.onChunk?.({ output: raw, stderr })
|
|
212
|
+
return {
|
|
213
|
+
stdout: parsed !== null ? parsed.text : raw,
|
|
214
|
+
stderr,
|
|
215
|
+
exitCode,
|
|
216
|
+
timedOut: false,
|
|
217
|
+
durationMs: Date.now() - started,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function connOf(entry: WinHostEntry): WinRMParams {
|
|
222
|
+
return {
|
|
223
|
+
host: entry.host,
|
|
224
|
+
port: entry.port,
|
|
225
|
+
path: '/wsman',
|
|
226
|
+
username: entry.user,
|
|
227
|
+
password: entry.auth.password ?? '',
|
|
228
|
+
useHttps: entry.transport === 'https',
|
|
229
|
+
rejectUnauthorized: entry.rejectUnauthorized ?? true,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function testConnection(conn: WinRMParams): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
|
234
|
+
const started = Date.now()
|
|
235
|
+
try {
|
|
236
|
+
const result = await runScript(conn, 'Write-Output ok', { timeoutMs: 30_000 })
|
|
237
|
+
return { ok: result.exitCode === 0 && result.stdout.trim() !== '', latencyMs: Date.now() - started }
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const TRANSFER_CHUNK = 48 * 1024
|
|
244
|
+
|
|
245
|
+
export async function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs = 60_000): Promise<number> {
|
|
246
|
+
const result = await runScript(conn, psFileSize(remotePath), { timeoutMs })
|
|
247
|
+
const value = Number.parseInt(result.stdout.trim(), 10)
|
|
248
|
+
return Number.isFinite(value) ? value : 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function downloadChunks(
|
|
252
|
+
conn: WinRMParams,
|
|
253
|
+
remotePath: string,
|
|
254
|
+
onChunk: (b64: string, bytes: number) => void,
|
|
255
|
+
onProgress?: (transferred: number, total: number) => void,
|
|
256
|
+
timeoutMs = 60_000,
|
|
257
|
+
): Promise<number> {
|
|
258
|
+
const total = await remoteFileSize(conn, remotePath, timeoutMs)
|
|
259
|
+
if (total === 0) return 0
|
|
260
|
+
let offset = 0
|
|
261
|
+
for (;;) {
|
|
262
|
+
const result = await runScript(conn, psReadChunk(remotePath, offset, TRANSFER_CHUNK), { timeoutMs })
|
|
263
|
+
const b64 = result.stdout.trim()
|
|
264
|
+
const bytes = Buffer.from(b64, 'base64').length
|
|
265
|
+
if (bytes === 0) break
|
|
266
|
+
onChunk(b64, bytes)
|
|
267
|
+
offset += bytes
|
|
268
|
+
onProgress?.(offset, total)
|
|
269
|
+
if (offset >= total || bytes < TRANSFER_CHUNK) break
|
|
270
|
+
}
|
|
271
|
+
return offset
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function uploadBuffer(
|
|
275
|
+
conn: WinRMParams,
|
|
276
|
+
remotePath: string,
|
|
277
|
+
data: Buffer,
|
|
278
|
+
onProgress?: (transferred: number, total: number) => void,
|
|
279
|
+
timeoutMs = 60_000,
|
|
280
|
+
): Promise<number> {
|
|
281
|
+
let written = 0
|
|
282
|
+
let first = true
|
|
283
|
+
for (let offset = 0; offset < data.length; offset += TRANSFER_CHUNK) {
|
|
284
|
+
const chunk = data.subarray(offset, Math.min(offset + TRANSFER_CHUNK, data.length))
|
|
285
|
+
await runScript(conn, psWriteChunk(remotePath, chunk.toString('base64'), !first), { timeoutMs })
|
|
286
|
+
written += chunk.length
|
|
287
|
+
first = false
|
|
288
|
+
onProgress?.(written, data.length)
|
|
289
|
+
}
|
|
290
|
+
return written
|
|
291
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket PowerShell console backed by winrm-client one-shot commands.
|
|
3
|
+
*
|
|
4
|
+
* The native Node WinRM client executes each submitted command through the
|
|
5
|
+
* same UTF-8 envelope as winrm_exec; the WebSocket remains open while
|
|
6
|
+
* commands are serialized. This is intentionally a command console rather
|
|
7
|
+
* than a persistent remote process, so every line is authenticated and
|
|
8
|
+
* isolated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WinRMParams } from './client.ts'
|
|
12
|
+
import { runScript } from './client.ts'
|
|
13
|
+
|
|
14
|
+
export class ConsoleSession {
|
|
15
|
+
private conn: WinRMParams
|
|
16
|
+
private running = false
|
|
17
|
+
private exited = false
|
|
18
|
+
private queue: Promise<void> = Promise.resolve()
|
|
19
|
+
onData: ((data: string) => void) | undefined
|
|
20
|
+
onExit: ((code: number | null, error?: string) => void) | undefined
|
|
21
|
+
|
|
22
|
+
constructor(conn: WinRMParams) {
|
|
23
|
+
this.conn = conn
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start(): Promise<void> {
|
|
27
|
+
this.running = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
send(data: string): void {
|
|
31
|
+
const command = data.replace(/\r?\n/g, '').trim()
|
|
32
|
+
if (!this.running || command === '') return
|
|
33
|
+
if (command.toLowerCase() === 'exit') {
|
|
34
|
+
this.close()
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
this.queue = this.queue.then(async () => {
|
|
38
|
+
if (!this.running) return
|
|
39
|
+
try {
|
|
40
|
+
const result = await runScript(this.conn, command, { timeoutMs: 120_000 })
|
|
41
|
+
if (result.stdout !== '') this.onData?.(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'))
|
|
42
|
+
if (result.stderr !== '') this.onData?.(result.stderr + (result.stderr.endsWith('\n') ? '' : '\n'))
|
|
43
|
+
if (result.timedOut) this.onData?.('[timed out]\n')
|
|
44
|
+
} catch (error) {
|
|
45
|
+
this.onData?.('[error] ' + (error instanceof Error ? error.message : String(error)) + '\n')
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
close(): void {
|
|
51
|
+
if (!this.running) return
|
|
52
|
+
this.running = false
|
|
53
|
+
this.exited = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private finish(code: number | null, error?: string): void {
|
|
57
|
+
if (this.exited) return
|
|
58
|
+
this.exited = true
|
|
59
|
+
this.running = false
|
|
60
|
+
this.onExit?.(code, error)
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WinRM engine facade: host lookup, PowerShell exec (UTF-8 envelope),
|
|
3
|
+
* service & process management, directory listing, base64-chunked file
|
|
4
|
+
* transfer, cluster execution and the streaming console. The heavy lifting
|
|
5
|
+
* lives in engine/ (client, console); this class composes them behind one
|
|
6
|
+
* WinRmEngine instance per plugin apply.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
|
|
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'
|
|
13
|
+
import { ConsoleSession } from './engine/console.ts'
|
|
14
|
+
import { psKillProcess, psListDir, psListProcesses, psListServices, psServiceAction } from './powershell.ts'
|
|
15
|
+
|
|
16
|
+
/** JSON-safe parse of the envelope output into a typed value. */
|
|
17
|
+
function parseJson<T>(text: string): T {
|
|
18
|
+
const trimmed = text.trim()
|
|
19
|
+
if (trimmed === '' || trimmed === 'null') return [] as unknown as T
|
|
20
|
+
return JSON.parse(trimmed) as T
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
|
|
24
|
+
class AliasGate {
|
|
25
|
+
private queues = new Map<string, Promise<void>>()
|
|
26
|
+
|
|
27
|
+
async run<T>(alias: string, fn: () => Promise<T>): Promise<T> {
|
|
28
|
+
const prev = this.queues.get(alias) ?? Promise.resolve()
|
|
29
|
+
const next = prev.then(() => fn(), () => fn())
|
|
30
|
+
// Keep the chain alive even when fn rejects (the caller observes the rejection).
|
|
31
|
+
this.queues.set(alias, next.then(() => undefined, () => undefined))
|
|
32
|
+
try {
|
|
33
|
+
return await next
|
|
34
|
+
} finally {
|
|
35
|
+
if (this.queues.get(alias) === next.then(() => undefined, () => undefined)) {
|
|
36
|
+
// (The identity check above is intentionally approximate; the queue
|
|
37
|
+
// entry is replaced per run, so stale entries are naturally dropped.)
|
|
38
|
+
}
|
|
39
|
+
void prev
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The engine. One instance per plugin apply; dispose() clears nothing pooled (WinRM is per-call HTTP). */
|
|
45
|
+
export class WinRmEngine {
|
|
46
|
+
readonly store: HostStore
|
|
47
|
+
private gate = new AliasGate()
|
|
48
|
+
|
|
49
|
+
constructor(store: HostStore) {
|
|
50
|
+
this.store = store
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- config
|
|
54
|
+
|
|
55
|
+
/** Secret-free host list (filtered by the optional query). */
|
|
56
|
+
list(query?: string): WinHostSummary[] {
|
|
57
|
+
const needle = query?.trim().toLowerCase()
|
|
58
|
+
return this.store.list()
|
|
59
|
+
.filter(entry => needle === undefined || needle === ''
|
|
60
|
+
|| entry.alias.toLowerCase().includes(needle)
|
|
61
|
+
|| (entry.description ?? '').toLowerCase().includes(needle)
|
|
62
|
+
|| entry.host.toLowerCase().includes(needle)
|
|
63
|
+
|| entry.tags.some(tag => tag.toLowerCase().includes(needle)))
|
|
64
|
+
.map(entry => this.store.summarize(entry))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One host summary by alias. */
|
|
68
|
+
find(alias: string): WinHostSummary | undefined {
|
|
69
|
+
const entry = this.store.find(alias)
|
|
70
|
+
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------ exec
|
|
74
|
+
|
|
75
|
+
/** Run one PowerShell command on `alias`. */
|
|
76
|
+
async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
|
|
77
|
+
const entry = this.store.find(alias)
|
|
78
|
+
if (entry === undefined) {
|
|
79
|
+
return { success: false, exitCode: null, timedOut: false, stdout: '', stderr: '', durationMs: 0, error: `alias '${alias}' not found` }
|
|
80
|
+
}
|
|
81
|
+
return this.gate.run(alias, async () => {
|
|
82
|
+
try {
|
|
83
|
+
const result = await runScript(connOf(entry), command, { timeoutMs: timeoutMs ?? 60_000 })
|
|
84
|
+
return {
|
|
85
|
+
success: result.exitCode === 0,
|
|
86
|
+
exitCode: result.exitCode,
|
|
87
|
+
timedOut: result.timedOut,
|
|
88
|
+
stdout: result.stdout,
|
|
89
|
+
stderr: result.stderr,
|
|
90
|
+
durationMs: result.durationMs,
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { success: false, exitCode: null, timedOut: false, stdout: '', stderr: '', durationMs: 0, error: error instanceof Error ? error.message : String(error) }
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --------------------------------------------------------------- cluster
|
|
99
|
+
|
|
100
|
+
/** Run one command against many hosts concurrently. */
|
|
101
|
+
async cluster(options: {
|
|
102
|
+
command: string
|
|
103
|
+
aliases?: string[]
|
|
104
|
+
environment?: string
|
|
105
|
+
tags?: string[]
|
|
106
|
+
timeoutMs?: number
|
|
107
|
+
maxWorkers?: number
|
|
108
|
+
}): Promise<ClusterResult[]> {
|
|
109
|
+
const hosts = this.store.list()
|
|
110
|
+
let matched = hosts
|
|
111
|
+
if (options.aliases !== undefined && options.aliases.length > 0) {
|
|
112
|
+
const set = new Set(options.aliases)
|
|
113
|
+
matched = matched.filter(entry => set.has(entry.alias))
|
|
114
|
+
}
|
|
115
|
+
if (options.environment !== undefined && options.environment !== '') {
|
|
116
|
+
matched = matched.filter(entry => entry.environment === options.environment)
|
|
117
|
+
}
|
|
118
|
+
if (options.tags !== undefined && options.tags.length > 0) {
|
|
119
|
+
matched = matched.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
|
|
120
|
+
}
|
|
121
|
+
const maxWorkers = Math.max(1, Math.min(options.maxWorkers ?? 8, 32))
|
|
122
|
+
const results: ClusterResult[] = []
|
|
123
|
+
let cursor = 0
|
|
124
|
+
const worker = async (): Promise<void> => {
|
|
125
|
+
for (;;) {
|
|
126
|
+
const index = cursor++
|
|
127
|
+
const entry = matched[index]
|
|
128
|
+
if (entry === undefined) return
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
const outcome = await this.exec(entry.alias, options.command, options.timeoutMs)
|
|
131
|
+
results.push({
|
|
132
|
+
alias: entry.alias,
|
|
133
|
+
ok: outcome.success,
|
|
134
|
+
exitCode: outcome.exitCode,
|
|
135
|
+
timedOut: outcome.timedOut,
|
|
136
|
+
stdout: outcome.stdout,
|
|
137
|
+
stderr: outcome.stderr,
|
|
138
|
+
durationMs: outcome.durationMs,
|
|
139
|
+
...(outcome.error !== undefined ? { error: outcome.error } : {}),
|
|
140
|
+
})
|
|
141
|
+
void started
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(Array.from({ length: Math.min(maxWorkers, matched.length) }, () => worker()))
|
|
145
|
+
return results
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -------------------------------------------------------------- services
|
|
149
|
+
|
|
150
|
+
/** Full service table. */
|
|
151
|
+
async listServices(alias: string): Promise<ServiceInfo[]> {
|
|
152
|
+
const entry = this.store.find(alias)
|
|
153
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
154
|
+
return this.gate.run(alias, async () => {
|
|
155
|
+
const result = await runScript(connOf(entry), psListServices(), { timeoutMs: 60_000 })
|
|
156
|
+
if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to list services')
|
|
157
|
+
const items = parseJson<Array<{ name?: string; displayName?: string; status?: string; startMode?: string; startName?: string }>>(result.stdout)
|
|
158
|
+
return items.map(item => ({
|
|
159
|
+
name: item.name ?? '',
|
|
160
|
+
displayName: item.displayName ?? '',
|
|
161
|
+
status: item.status ?? '',
|
|
162
|
+
startMode: item.startMode ?? '',
|
|
163
|
+
startName: item.startName ?? '',
|
|
164
|
+
}))
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One service action, returning the refreshed service row. */
|
|
169
|
+
async serviceAction(alias: string, name: string, action: 'start' | 'stop' | 'restart' | 'set-auto' | 'set-manual' | 'set-disabled'): Promise<ServiceInfo> {
|
|
170
|
+
const entry = this.store.find(alias)
|
|
171
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
172
|
+
return this.gate.run(alias, async () => {
|
|
173
|
+
const result = await runScript(connOf(entry), psServiceAction(name, action), { timeoutMs: 60_000 })
|
|
174
|
+
if (result.exitCode !== 0) throw new Error(result.stdout || `service action '${action}' failed`)
|
|
175
|
+
const item = parseJson<Array<{ name?: string; displayName?: string; status?: string; startMode?: string; startName?: string }>>(result.stdout)[0] ?? {}
|
|
176
|
+
return {
|
|
177
|
+
name: item.name ?? name,
|
|
178
|
+
displayName: item.displayName ?? '',
|
|
179
|
+
status: item.status ?? '',
|
|
180
|
+
startMode: item.startMode ?? '',
|
|
181
|
+
startName: item.startName ?? '',
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ------------------------------------------------------------- processes
|
|
187
|
+
|
|
188
|
+
/** Full process table. */
|
|
189
|
+
async listProcesses(alias: string): Promise<ProcessInfo[]> {
|
|
190
|
+
const entry = this.store.find(alias)
|
|
191
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
192
|
+
return this.gate.run(alias, async () => {
|
|
193
|
+
const result = await runScript(connOf(entry), psListProcesses(), { timeoutMs: 60_000 })
|
|
194
|
+
if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to list processes')
|
|
195
|
+
const items = parseJson<Array<{ id?: number; name?: string; cpu?: number | null; memMB?: number; startTime?: string | null; path?: string | null }>>(result.stdout)
|
|
196
|
+
return items.map(item => ({
|
|
197
|
+
id: item.id ?? 0,
|
|
198
|
+
name: item.name ?? '',
|
|
199
|
+
...(item.cpu !== undefined && item.cpu !== null ? { cpu: item.cpu } : {}),
|
|
200
|
+
...(item.memMB !== undefined && item.memMB !== null ? { memMB: item.memMB } : {}),
|
|
201
|
+
...(item.startTime !== undefined && item.startTime !== null ? { startTime: item.startTime } : {}),
|
|
202
|
+
...(item.path !== undefined && item.path !== null ? { path: item.path } : {}),
|
|
203
|
+
}))
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Kill one process by id. */
|
|
208
|
+
async killProcess(alias: string, id: number): Promise<void> {
|
|
209
|
+
const entry = this.store.find(alias)
|
|
210
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
211
|
+
return this.gate.run(alias, async () => {
|
|
212
|
+
const result = await runScript(connOf(entry), psKillProcess(id), { timeoutMs: 30_000 })
|
|
213
|
+
if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to kill process')
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -------------------------------------------------------------------- ls
|
|
218
|
+
|
|
219
|
+
/** List a remote directory. */
|
|
220
|
+
async ls(alias: string, dir: string): Promise<RemoteDirEntry[]> {
|
|
221
|
+
const entry = this.store.find(alias)
|
|
222
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
223
|
+
return this.gate.run(alias, async () => {
|
|
224
|
+
const result = await runScript(connOf(entry), psListDir(dir), { timeoutMs: 60_000 })
|
|
225
|
+
if (result.exitCode !== 0) throw new Error(result.stdout || `failed to list '${dir}'`)
|
|
226
|
+
const items = parseJson<Array<{ name?: string; type?: string; size?: number; mtimeMs?: number }>>(result.stdout)
|
|
227
|
+
return items.map(item => ({
|
|
228
|
+
name: item.name ?? '',
|
|
229
|
+
type: (item.type === 'dir' || item.type === 'file' ? item.type : 'other') as RemoteDirEntry['type'],
|
|
230
|
+
size: item.size ?? 0,
|
|
231
|
+
mtimeMs: item.mtimeMs ?? 0,
|
|
232
|
+
}))
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// -------------------------------------------------------------- transfer
|
|
237
|
+
|
|
238
|
+
/** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
|
|
239
|
+
async upload(alias: string, localPath: string, remotePath: string, onProgress?: (progress: TransferProgress) => void, channel: TransferChannel = 'auto'): Promise<{ bytes: number; channel?: TransferChannel }> {
|
|
240
|
+
const entry = this.store.find(alias)
|
|
241
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
242
|
+
const { readFileSync } = await import('node:fs')
|
|
243
|
+
const data = readFileSync(localPath)
|
|
244
|
+
return this.gate.run(alias, async () => {
|
|
245
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
|
|
246
|
+
if (channel !== 'winrm') {
|
|
247
|
+
try {
|
|
248
|
+
const outcome = await uploadSmb(entry, localPath, remotePath)
|
|
249
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
|
|
250
|
+
return { bytes: outcome.bytes, channel: outcome.channel }
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (channel === 'smb') throw error
|
|
253
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
|
|
257
|
+
onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
|
|
258
|
+
})
|
|
259
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
|
|
260
|
+
return { bytes, channel: 'winrm' }
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Download one remote file to a local path. Defaults to SMB with WinRM fallback. */
|
|
265
|
+
async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, channel: TransferChannel = 'auto'): Promise<{ bytes: number; channel?: TransferChannel }> {
|
|
266
|
+
const entry = this.store.find(alias)
|
|
267
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
268
|
+
return this.gate.run(alias, async () => {
|
|
269
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
|
|
270
|
+
if (channel !== 'winrm') {
|
|
271
|
+
try {
|
|
272
|
+
const outcome = await downloadSmb(entry, remotePath, localPath)
|
|
273
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
|
|
274
|
+
return { bytes: outcome.bytes, channel: outcome.channel }
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (channel === 'smb') throw error
|
|
277
|
+
onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const { createWriteStream } = await import('node:fs')
|
|
281
|
+
const sink = createWriteStream(localPath, { mode: 0o600 })
|
|
282
|
+
let bytes = 0
|
|
283
|
+
await new Promise<void>((resolve, reject) => {
|
|
284
|
+
sink.on('error', reject)
|
|
285
|
+
void downloadChunks(
|
|
286
|
+
connOf(entry),
|
|
287
|
+
remotePath,
|
|
288
|
+
(b64) => {
|
|
289
|
+
const buffer = Buffer.from(b64, 'base64')
|
|
290
|
+
bytes += buffer.length
|
|
291
|
+
sink.write(buffer)
|
|
292
|
+
},
|
|
293
|
+
(transferred, total) => onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 }),
|
|
294
|
+
).then(
|
|
295
|
+
() => sink.end(resolve),
|
|
296
|
+
(error) => { sink.destroy(); reject(error) },
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
|
|
300
|
+
return { bytes, channel: 'winrm' }
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --------------------------------------------------------------- console
|
|
305
|
+
|
|
306
|
+
/** Open a streaming PowerShell console session (standalone connection). */
|
|
307
|
+
async openConsole(alias: string): Promise<ConsoleSession> {
|
|
308
|
+
const entry = this.store.find(alias)
|
|
309
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
310
|
+
const session = new ConsoleSession(connOf(entry))
|
|
311
|
+
await session.start()
|
|
312
|
+
return session
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ------------------------------------------------------------------ misc
|
|
316
|
+
|
|
317
|
+
/** Probe connectivity. */
|
|
318
|
+
async test(alias: string): Promise<TestResult> {
|
|
319
|
+
const entry = this.store.find(alias)
|
|
320
|
+
if (entry === undefined) return { ok: false, error: `alias '${alias}' not found` }
|
|
321
|
+
return this.gate.run(alias, async () => {
|
|
322
|
+
const result = await testConnection(connOf(entry))
|
|
323
|
+
return result
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** No pooled resources to close (WinRM is per-call HTTP). */
|
|
328
|
+
dispose(): void {
|
|
329
|
+
// Nothing to tear down: every operation owns its shell lifecycle.
|
|
330
|
+
}
|
|
331
|
+
}
|