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
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
|
+
}
|