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.
Files changed (64) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +112 -0
  3. package/README.zh.md +119 -0
  4. package/RELEASING.md +75 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +2076 -0
  7. package/lib/index.js +2458 -0
  8. package/lib/invariant.js +6 -0
  9. package/lib/types/client/api.d.ts +59 -0
  10. package/lib/types/client/index.d.ts +38 -0
  11. package/lib/types/client/locales.d.ts +231 -0
  12. package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
  13. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  14. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  15. package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
  16. package/lib/types/client/panel/ServicesTab.d.ts +7 -0
  17. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  18. package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
  19. package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
  20. package/lib/types/client/panel/helpers.d.ts +16 -0
  21. package/lib/types/engine/client.d.ts +49 -0
  22. package/lib/types/engine/console.d.ts +23 -0
  23. package/lib/types/engine.d.ts +58 -0
  24. package/lib/types/index.d.ts +44 -0
  25. package/lib/types/invariant.d.ts +3 -0
  26. package/lib/types/loopback.d.ts +24 -0
  27. package/lib/types/mount-once.d.ts +24 -0
  28. package/lib/types/powershell.d.ts +73 -0
  29. package/lib/types/protocol.d.ts +176 -0
  30. package/lib/types/routes.d.ts +26 -0
  31. package/lib/types/smb.d.ts +31 -0
  32. package/lib/types/store.d.ts +44 -0
  33. package/lib/types/tools.d.ts +20 -0
  34. package/package.json +111 -0
  35. package/scripts/enable-winrm.ps1 +0 -0
  36. package/scripts/postbuild.mjs +40 -0
  37. package/scripts/validate-manifest.mjs +18 -0
  38. package/src/client/api.ts +345 -0
  39. package/src/client/css-modules.d.ts +5 -0
  40. package/src/client/index.ts +82 -0
  41. package/src/client/locales.ts +243 -0
  42. package/src/client/panel/ConsoleTab.tsx +142 -0
  43. package/src/client/panel/HostFormDialog.tsx +142 -0
  44. package/src/client/panel/HostsTab.tsx +153 -0
  45. package/src/client/panel/ProcessesTab.tsx +119 -0
  46. package/src/client/panel/ServicesTab.tsx +123 -0
  47. package/src/client/panel/TransferTab.tsx +161 -0
  48. package/src/client/panel/WinrmPanel.tsx +85 -0
  49. package/src/client/panel/WinrmPanelIcon.tsx +27 -0
  50. package/src/client/panel/helpers.ts +33 -0
  51. package/src/client/panel/panel.module.css +1000 -0
  52. package/src/engine/client.ts +291 -0
  53. package/src/engine/console.ts +62 -0
  54. package/src/engine.ts +331 -0
  55. package/src/index.ts +142 -0
  56. package/src/invariant.ts +4 -0
  57. package/src/loopback.ts +63 -0
  58. package/src/mount-once.ts +48 -0
  59. package/src/powershell.ts +208 -0
  60. package/src/protocol.ts +178 -0
  61. package/src/routes.ts +490 -0
  62. package/src/smb.ts +129 -0
  63. package/src/store.ts +232 -0
  64. package/src/tools.ts +387 -0
package/src/routes.ts ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * The /api/dsh-winrm route family: host CRUD, exec, cluster, services,
3
+ * processes, remote listing, NDJSON upload progress, binary download, and
4
+ * the WebSocket PowerShell console upgrade. Every route carries a
5
+ * loopback-only trust fence (plus browser same-origin markers) — these
6
+ * endpoints execute commands on remote machines, so LAN-exposed dsh web
7
+ * deployments must not serve them.
8
+ */
9
+
10
+ import { closeSync, createReadStream, createWriteStream, mkdirSync, openSync } from 'node:fs'
11
+ import { unlink } from 'node:fs/promises'
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { basename, join } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { WebSocket, WebSocketServer } from 'ws'
17
+ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
+ import type { WinRmEngine } from './engine.ts'
19
+ import { isLoopbackRequest } from './loopback.ts'
20
+ import { WINRM_API, type ConsoleClientFrame, type ConsoleServerFrame, type WinHostPayload } from './protocol.ts'
21
+ import type { HostStore } from './store.ts'
22
+
23
+ /** Cap on JSON request bodies. */
24
+ const MAX_JSON_BODY_BYTES = 64 * 1024
25
+
26
+ /** Cap on declared upload bodies (staged to disk before chunking out). */
27
+ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
28
+
29
+ /** One noServer WebSocket server for console upgrades. */
30
+ const consoleWss = new WebSocketServer({ noServer: true })
31
+
32
+ /** One JSON response. */
33
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
34
+ const payload = JSON.stringify(body)
35
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
36
+ res.end(payload)
37
+ }
38
+
39
+ /** Read a JSON request body (undefined when too large or unparseable). */
40
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
41
+ const chunks: Buffer[] = []
42
+ let size = 0
43
+ for await (const chunk of req) {
44
+ const buffer = chunk as Buffer
45
+ size += buffer.length
46
+ if (size > MAX_JSON_BODY_BYTES) return undefined
47
+ chunks.push(buffer)
48
+ }
49
+ try {
50
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
51
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /** URL query helper (first value, decoded). */
58
+ function queryParam(url: URL, name: string): string | undefined {
59
+ const value = url.searchParams.get(name)
60
+ return value === null ? undefined : value
61
+ }
62
+
63
+ /** Route family dependencies. */
64
+ export interface WinrmRoutesDeps {
65
+ store: HostStore
66
+ engine: WinRmEngine
67
+ stagingDir?: string
68
+ maxUploadBytes?: number
69
+ }
70
+
71
+ /**
72
+ * Build every /api/dsh-winrm route plus the console upgrade.
73
+ * @param deps - store, engine, staging dir.
74
+ */
75
+ export function makeRoutes(deps: WinrmRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
76
+ const { store, engine } = deps
77
+ const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-winrm-uploads')
78
+ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
79
+ mkdirSync(staging, { recursive: true, mode: 0o700 })
80
+
81
+ /** Guard helper: fence + method check. */
82
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
83
+ if (!isLoopbackRequest(req)) {
84
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
85
+ return false
86
+ }
87
+ if (req.method !== method) {
88
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
89
+ return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ const routes: WebRoute[] = [
95
+ // ------------------------------------------------------------ hosts
96
+ {
97
+ kind: 'exact',
98
+ path: WINRM_API.hosts,
99
+ handler: async (req, res) => {
100
+ const method = req.method ?? 'GET'
101
+ if (!isLoopbackRequest(req)) {
102
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
103
+ return
104
+ }
105
+ const url = new URL(req.url ?? '/', 'http://localhost')
106
+ if (method === 'GET') {
107
+ writeJson(res, 200, { hosts: engine.list(queryParam(url, 'query')) })
108
+ return
109
+ }
110
+ if (method === 'POST') {
111
+ const body = await readJsonBody(req)
112
+ if (body === undefined) {
113
+ writeJson(res, 400, { error: 'invalid JSON body' })
114
+ return
115
+ }
116
+ try {
117
+ const entry = store.create(body as unknown as WinHostPayload)
118
+ writeJson(res, 201, { host: store.summarize(entry) })
119
+ } catch (error) {
120
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
121
+ }
122
+ return
123
+ }
124
+ if (method !== 'PATCH' && method !== 'DELETE') {
125
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
126
+ return
127
+ }
128
+ const alias = queryParam(url, 'alias')
129
+ if (alias === undefined || alias === '') {
130
+ writeJson(res, 400, { error: 'alias query parameter is required' })
131
+ return
132
+ }
133
+ if (method === 'PATCH') {
134
+ const body = await readJsonBody(req)
135
+ if (body === undefined) {
136
+ writeJson(res, 400, { error: 'invalid JSON body' })
137
+ return
138
+ }
139
+ try {
140
+ const entry = store.update(alias, body as unknown as Partial<WinHostPayload>)
141
+ writeJson(res, 200, { host: store.summarize(entry) })
142
+ } catch (error) {
143
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
144
+ }
145
+ return
146
+ }
147
+ if (method === 'DELETE') {
148
+ try {
149
+ store.delete(alias)
150
+ writeJson(res, 200, { ok: true })
151
+ } catch (error) {
152
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
153
+ }
154
+ return
155
+ }
156
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
157
+ },
158
+ },
159
+ // ------------------------------------------------------------ ops
160
+ {
161
+ kind: 'exact',
162
+ path: WINRM_API.test,
163
+ handler: async (req, res) => {
164
+ if (!guard(req, res, 'POST')) return
165
+ const body = await readJsonBody(req)
166
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
167
+ if (alias === '') {
168
+ writeJson(res, 400, { error: 'alias is required' })
169
+ return
170
+ }
171
+ try {
172
+ writeJson(res, 200, { result: await engine.test(alias) })
173
+ } catch (error) {
174
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
175
+ }
176
+ },
177
+ },
178
+ {
179
+ kind: 'exact',
180
+ path: WINRM_API.exec,
181
+ handler: async (req, res) => {
182
+ if (!guard(req, res, 'POST')) return
183
+ const body = await readJsonBody(req)
184
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
185
+ const command = typeof body?.command === 'string' ? body.command : ''
186
+ if (alias === '' || command === '') {
187
+ writeJson(res, 400, { error: 'alias and command are required' })
188
+ return
189
+ }
190
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
191
+ try {
192
+ writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
193
+ } catch (error) {
194
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
195
+ }
196
+ },
197
+ },
198
+ {
199
+ kind: 'exact',
200
+ path: WINRM_API.cluster,
201
+ handler: async (req, res) => {
202
+ if (!guard(req, res, 'POST')) return
203
+ const body = await readJsonBody(req)
204
+ const command = typeof body?.command === 'string' ? body.command : ''
205
+ if (command === '') {
206
+ writeJson(res, 400, { error: 'command is required' })
207
+ return
208
+ }
209
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
210
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
211
+ const environment = typeof body?.environment === 'string' ? body.environment : undefined
212
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
213
+ const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
214
+ try {
215
+ writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
216
+ } catch (error) {
217
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
218
+ }
219
+ },
220
+ },
221
+ // ---------------------------------------------------------- services
222
+ {
223
+ kind: 'exact',
224
+ path: WINRM_API.services,
225
+ handler: async (req, res) => {
226
+ if (!guard(req, res, 'POST')) return
227
+ const body = await readJsonBody(req)
228
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
229
+ const name = typeof body?.name === 'string' ? body.name : ''
230
+ const action = typeof body?.action === 'string' ? body.action : 'list'
231
+ if (alias === '') {
232
+ writeJson(res, 400, { error: 'alias is required' })
233
+ return
234
+ }
235
+ try {
236
+ if (action === 'list' || name === '') {
237
+ writeJson(res, 200, { services: await engine.listServices(alias) })
238
+ } else {
239
+ const service = await engine.serviceAction(alias, name, action as 'start' | 'stop' | 'restart' | 'set-auto' | 'set-manual' | 'set-disabled')
240
+ writeJson(res, 200, { service })
241
+ }
242
+ } catch (error) {
243
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
244
+ }
245
+ },
246
+ },
247
+ // --------------------------------------------------------- processes
248
+ {
249
+ kind: 'exact',
250
+ path: WINRM_API.processes,
251
+ handler: async (req, res) => {
252
+ if (!guard(req, res, 'POST')) return
253
+ const body = await readJsonBody(req)
254
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
255
+ const action = typeof body?.action === 'string' ? body.action : 'list'
256
+ const id = typeof body?.id === 'number' ? body.id : undefined
257
+ if (alias === '') {
258
+ writeJson(res, 400, { error: 'alias is required' })
259
+ return
260
+ }
261
+ try {
262
+ if (action === 'kill' && id !== undefined) {
263
+ await engine.killProcess(alias, id)
264
+ writeJson(res, 200, { killed: id })
265
+ } else {
266
+ writeJson(res, 200, { processes: await engine.listProcesses(alias) })
267
+ }
268
+ } catch (error) {
269
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
270
+ }
271
+ },
272
+ },
273
+ // ----------------------------------------------------------------- ls
274
+ {
275
+ kind: 'exact',
276
+ path: WINRM_API.ls,
277
+ handler: async (req, res) => {
278
+ if (!guard(req, res, 'GET')) return
279
+ const url = new URL(req.url ?? '/', 'http://localhost')
280
+ const alias = queryParam(url, 'alias')
281
+ const dir = queryParam(url, 'path') ?? 'C:\\'
282
+ if (alias === undefined || alias === '') {
283
+ writeJson(res, 400, { error: 'alias query parameter is required' })
284
+ return
285
+ }
286
+ try {
287
+ writeJson(res, 200, { entries: await engine.ls(alias, dir) })
288
+ } catch (error) {
289
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
290
+ }
291
+ },
292
+ },
293
+ // --------------------------------------------------------- upload
294
+ {
295
+ kind: 'exact',
296
+ path: WINRM_API.upload,
297
+ handler: async (req, res) => {
298
+ if (!guard(req, res, 'POST')) return
299
+ const url = new URL(req.url ?? '/', 'http://localhost')
300
+ const alias = queryParam(url, 'alias')
301
+ const remotePath = queryParam(url, 'remotePath')
302
+ if (alias === undefined || remotePath === undefined) {
303
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
304
+ return
305
+ }
306
+ const channel = queryParam(url, 'channel') ?? 'auto'
307
+ const declared = Number(req.headers['content-length'])
308
+ if (Number.isFinite(declared) && declared > maxUploadBytes) {
309
+ writeJson(res, 413, { error: 'upload body too large' })
310
+ return
311
+ }
312
+ res.writeHead(200, {
313
+ 'content-type': 'application/x-ndjson; charset=utf-8',
314
+ 'cache-control': 'no-cache',
315
+ 'referrer-policy': 'no-referrer',
316
+ })
317
+ const emit = (line: unknown): void => {
318
+ try { res.write(JSON.stringify(line) + '\n') } catch { /* client gone */ }
319
+ }
320
+ const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
321
+ const sink = createWriteStream(tmp, { mode: 0o600 })
322
+ let settled = false
323
+ const fail = (error: unknown): void => {
324
+ if (settled) return
325
+ settled = true
326
+ emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
327
+ const cleanup = (): void => {
328
+ void unlink(tmp).catch(() => undefined).finally(() => {
329
+ try { res.end() } catch { /* closed */ }
330
+ })
331
+ }
332
+ if (sink.destroyed) {
333
+ cleanup()
334
+ } else {
335
+ sink.once('close', cleanup)
336
+ try { sink.destroy() } catch { cleanup() }
337
+ }
338
+ }
339
+ const done = (): void => {
340
+ if (settled) return
341
+ settled = true
342
+ try { res.end() } catch { /* closed */ }
343
+ }
344
+ sink.on('error', (error) => fail(error))
345
+ req.on('error', (error) => fail(error))
346
+ req.on('aborted', () => fail('upload aborted by the client'))
347
+ res.on('error', () => fail('response stream closed'))
348
+ res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
349
+ let received = 0
350
+ let capped = false
351
+ req.on('data', (chunk: Buffer) => {
352
+ received += chunk.byteLength
353
+ if (received > maxUploadBytes && !capped) {
354
+ capped = true
355
+ fail('upload body too large')
356
+ res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
357
+ req.resume()
358
+ }
359
+ })
360
+ req.pipe(sink)
361
+ sink.on('finish', async () => {
362
+ if (settled) return
363
+ emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 } })
364
+ try {
365
+ const outcome = await engine.upload(alias, tmp, remotePath, progress => emit({ type: 'progress', progress }), channel as 'auto' | 'smb' | 'winrm')
366
+ emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
367
+ } catch (error) {
368
+ emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
369
+ } finally {
370
+ await unlink(tmp).catch(() => undefined)
371
+ done()
372
+ }
373
+ })
374
+ },
375
+ },
376
+ // ------------------------------------------------------- download
377
+ {
378
+ kind: 'exact',
379
+ path: WINRM_API.download,
380
+ handler: async (req, res) => {
381
+ if (!guard(req, res, 'GET')) return
382
+ const url = new URL(req.url ?? '/', 'http://localhost')
383
+ const alias = queryParam(url, 'alias')
384
+ const remotePath = queryParam(url, 'remotePath')
385
+ if (alias === undefined || remotePath === undefined) {
386
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
387
+ return
388
+ }
389
+ const channel = queryParam(url, 'channel') ?? 'auto'
390
+ const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
391
+ try {
392
+ closeSync(openSync(tmp, 'w', 0o600))
393
+ const outcome = await engine.download(alias, remotePath, tmp, undefined, channel as 'auto' | 'smb' | 'winrm')
394
+ res.writeHead(200, {
395
+ 'content-type': 'application/octet-stream',
396
+ 'content-length': String(outcome.bytes),
397
+ 'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
398
+ 'referrer-policy': 'no-referrer',
399
+ })
400
+ await new Promise<void>((resolve, reject) => {
401
+ const source = createReadStream(tmp)
402
+ source.on('error', reject)
403
+ res.on('error', reject)
404
+ source.pipe(res)
405
+ source.on('end', resolve)
406
+ })
407
+ } catch (error) {
408
+ if (!res.headersSent) {
409
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
410
+ } else {
411
+ res.destroy()
412
+ }
413
+ } finally {
414
+ await unlink(tmp).catch(() => undefined)
415
+ }
416
+ },
417
+ },
418
+ ]
419
+
420
+ // ---------------------------------------------- console (upgrade)
421
+ const upgrade: WebUpgradeRoute = {
422
+ path: WINRM_API.console,
423
+ handler: (req, socket, head) => {
424
+ if (!isLoopbackRequest(req)) {
425
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
426
+ socket.destroy()
427
+ return
428
+ }
429
+ const url = new URL(req.url ?? '/', 'http://localhost')
430
+ const alias = queryParam(url, 'alias')
431
+ if (alias === undefined) {
432
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
433
+ socket.destroy()
434
+ return
435
+ }
436
+ consoleWss.handleUpgrade(req, socket, head, (ws) => {
437
+ let session: Awaited<ReturnType<WinRmEngine['openConsole']>> | undefined
438
+ let closed = false
439
+ const sendFrame = (frame: ConsoleServerFrame): void => {
440
+ if (closed || ws.readyState !== WebSocket.OPEN) return
441
+ ws.send(JSON.stringify(frame))
442
+ }
443
+ const closeSession = (): void => {
444
+ const opened = session
445
+ session = undefined
446
+ if (opened !== undefined) opened.close()
447
+ }
448
+ engine.openConsole(alias).then((opened) => {
449
+ if (ws.readyState !== WebSocket.OPEN) {
450
+ opened.close()
451
+ return
452
+ }
453
+ session = opened
454
+ sendFrame({ type: 'ready', alias })
455
+ opened.onData = (data) => sendFrame({ type: 'output', data })
456
+ opened.onExit = (code, error) => {
457
+ sendFrame({ type: 'exit', code, error })
458
+ closed = true
459
+ try { ws.close(1000) } catch { /* already closed */ }
460
+ }
461
+ }).catch((error) => {
462
+ sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
463
+ closed = true
464
+ try { ws.close(1000) } catch { /* already closed */ }
465
+ })
466
+ ws.on('message', (data) => {
467
+ let frame: ConsoleClientFrame
468
+ try {
469
+ frame = JSON.parse(String(data)) as ConsoleClientFrame
470
+ } catch {
471
+ return
472
+ }
473
+ if (frame.type === 'input') {
474
+ session?.send(frame.data)
475
+ }
476
+ })
477
+ ws.on('close', () => {
478
+ closed = true
479
+ closeSession()
480
+ })
481
+ ws.on('error', () => {
482
+ closed = true
483
+ closeSession()
484
+ })
485
+ })
486
+ },
487
+ }
488
+
489
+ return { routes, upgrade }
490
+ }
package/src/smb.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * SMB transfer bridge.
3
+ *
4
+ * The WinRM base64 chunk bridge is reliable for small files but breaks for
5
+ * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
6
+ * files ride the remote admin share. This module mounts the remote host's
7
+ * admin share with `net use` (password passed via stdin, never argv), copies
8
+ * whole files with node:fs, then verifies the SHA-256 on both ends.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process'
12
+ import { copyFile, stat } from 'node:fs/promises'
13
+ import { createHash } from 'node:crypto'
14
+ import { readFile } from 'node:fs/promises'
15
+ import type { WinHostEntry } from './protocol.ts'
16
+
17
+ /** Per-transfer channel selector. */
18
+ export type TransferChannel = 'auto' | 'smb' | 'winrm'
19
+
20
+ export interface SmbTransferResult {
21
+ bytes: number
22
+ channel: 'smb'
23
+ }
24
+
25
+ /**
26
+ * Convert a Windows drive path to its UNC admin-share form.
27
+ * D:\\dir\\file -> \\\\host\\D$\\dir\\file
28
+ */
29
+ export function adminSharePath(host: string, remotePath: string): string {
30
+ const absolute = remotePath.replace(/[\\/]+$/, '')
31
+ const match = /^([A-Za-z]):[\\/](.*)$/.exec(absolute)
32
+ if (match === null) {
33
+ throw new Error('SMB requires an absolute remote path like D:\\dir\\file')
34
+ }
35
+ const drive = match[1]
36
+ const tail = match[2]
37
+ return '\\\\' + host + '\\' + drive + '$' + (tail === '' ? '' : '\\' + tail)
38
+ }
39
+
40
+ /** Run one cmd.exe process; returns output and exit code. */
41
+ function runCmd(args: string[], input?: string, timeoutMs = 60_000): Promise<{ code: number; stdout: string; stderr: string }> {
42
+ return new Promise((resolve) => {
43
+ const child = spawn('cmd.exe', ['/c', ...args], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
44
+ const stdout: Buffer[] = []
45
+ const stderr: Buffer[] = []
46
+ let settled = false
47
+ const timer = setTimeout(() => {
48
+ if (settled) return
49
+ try { child.kill() } catch { /* already gone */ }
50
+ settled = true
51
+ resolve({ code: 1, stdout: '', stderr: 'SMB net use timed out' })
52
+ }, timeoutMs)
53
+ child.stdout.on('data', (chunk) => stdout.push(Buffer.from(chunk)))
54
+ child.stderr.on('data', (chunk) => stderr.push(Buffer.from(chunk)))
55
+ child.on('error', (error) => {
56
+ if (settled) return
57
+ clearTimeout(timer)
58
+ settled = true
59
+ resolve({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error) })
60
+ })
61
+ child.on('close', (code) => {
62
+ if (settled) return
63
+ clearTimeout(timer)
64
+ settled = true
65
+ resolve({ code: code ?? 1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') })
66
+ })
67
+ if (input !== undefined) child.stdin.end(input)
68
+ else child.stdin.end()
69
+ })
70
+ }
71
+
72
+ /** SHA-256 of a local file. */
73
+ async function sha256File(localPath: string): Promise<string> {
74
+ return createHash('sha256').update(await readFile(localPath)).digest('hex').toUpperCase()
75
+ }
76
+
77
+ /** SHA-256 of a remote file through the UNC path. */
78
+ async function remoteSha256(host: string, remotePath: string): Promise<string> {
79
+ const { execFile } = await import('node:child_process')
80
+ const { promisify } = await import('node:util')
81
+ const execFileAsync = promisify(execFile)
82
+ const { stdout } = await execFileAsync('certutil.exe', ['-hashfile', adminSharePath(host, remotePath), 'SHA256'])
83
+ const match = /([0-9A-Fa-f]{64})/.exec(stdout)
84
+ if (match === null) throw new Error('SMB remote hash verification failed: ' + stdout)
85
+ return match[1].toUpperCase()
86
+ }
87
+
88
+ /**
89
+ * Copy one local file to a remote Windows host over SMB, verifying the
90
+ * remote SHA-256 matches the local source.
91
+ */
92
+ export async function uploadSmb(entry: WinHostEntry, localPath: string, remotePath: string, timeoutMs = 300_000): Promise<SmbTransferResult> {
93
+ const unc = adminSharePath(entry.host, remotePath)
94
+ const mount = await runCmd(['net', 'use', '\\\\' + entry.host + '\\IPC$', entry.auth.password ?? '', '/user:' + entry.user], undefined, timeoutMs)
95
+ if (mount.code !== 0) throw new Error('SMB net use failed: ' + (mount.stderr || mount.stdout))
96
+ try {
97
+ const { mkdir } = await import('node:fs/promises')
98
+ const { dirname } = await import('node:path')
99
+ await mkdir(dirname(unc), { recursive: true })
100
+ await copyFile(localPath, unc)
101
+ const local = await stat(localPath)
102
+ const localHash = await sha256File(localPath)
103
+ const remoteHash = await remoteSha256(entry.host, remotePath)
104
+ if (remoteHash !== localHash) throw new Error('SMB upload hash mismatch: local ' + localHash + ', remote ' + remoteHash)
105
+ return { bytes: local.size, channel: 'smb' }
106
+ } finally {
107
+ await runCmd(['net', 'use', '\\\\' + entry.host + '\\IPC$', '/delete', '/y'], undefined, 30_000)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Copy one remote Windows file to a local path over SMB and verify the local
113
+ * SHA-256 matches the remote source.
114
+ */
115
+ export async function downloadSmb(entry: WinHostEntry, remotePath: string, localPath: string, timeoutMs = 300_000): Promise<SmbTransferResult> {
116
+ const unc = adminSharePath(entry.host, remotePath)
117
+ const mount = await runCmd(['net', 'use', '\\\\' + entry.host + '\\IPC$', entry.auth.password ?? '', '/user:' + entry.user], undefined, timeoutMs)
118
+ if (mount.code !== 0) throw new Error('SMB net use failed: ' + (mount.stderr || mount.stdout))
119
+ try {
120
+ await copyFile(unc, localPath)
121
+ const local = await stat(localPath)
122
+ const localHash = await sha256File(localPath)
123
+ const remoteHash = await remoteSha256(entry.host, remotePath)
124
+ if (remoteHash !== localHash) throw new Error('SMB download hash mismatch: remote ' + remoteHash + ', local ' + localHash)
125
+ return { bytes: local.size, channel: 'smb' }
126
+ } finally {
127
+ await runCmd(['net', 'use', '\\\\' + entry.host + '\\IPC$', '/delete', '/y'], undefined, 30_000)
128
+ }
129
+ }