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/index.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-winrm — host half. Mounts the WinRM engine (per-call PowerShell
|
|
3
|
+
* execution with a UTF-8 envelope, streaming console, service/process
|
|
4
|
+
* management, base64-chunked file transfer, cluster), the /api/dsh-winrm
|
|
5
|
+
* route family plus the console WebSocket upgrade, the agent tools
|
|
6
|
+
* (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_upload,
|
|
7
|
+
* winrm_download, winrm_cluster), and a system-prompt announcement. The
|
|
8
|
+
* browser half (./client) renders the host manager and operations panel.
|
|
9
|
+
* Everything rides official NPM SDK packages — no dsh source changes.
|
|
10
|
+
*
|
|
11
|
+
* Config is read live through `Volatile` references (`.get()`), so the
|
|
12
|
+
* Settings page can flip `enabled` / `announceToAgent` without remounting;
|
|
13
|
+
* `loader/volatile-update` re-runs the registration sync when they change.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context, Volatile } from '@deepseek-ai/cordis'
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
// Type-only: the loader's Events merge (`loader/volatile-update`).
|
|
19
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import { WinRmEngine } from './engine.ts'
|
|
24
|
+
import { makeRoutes } from './routes.ts'
|
|
25
|
+
import { HostStore } from './store.ts'
|
|
26
|
+
import {
|
|
27
|
+
winrmClusterTool,
|
|
28
|
+
winrmDownloadTool,
|
|
29
|
+
winrmExecTool,
|
|
30
|
+
winrmListTool,
|
|
31
|
+
winrmProcessTool,
|
|
32
|
+
winrmServiceTool,
|
|
33
|
+
winrmUploadTool,
|
|
34
|
+
} from './tools.ts'
|
|
35
|
+
import { mountOnce } from './mount-once.ts'
|
|
36
|
+
|
|
37
|
+
/** Stable cordis plugin name. */
|
|
38
|
+
export const name = 'winrm'
|
|
39
|
+
|
|
40
|
+
/** Services required before the WinRM surfaces can mount. */
|
|
41
|
+
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
42
|
+
|
|
43
|
+
/** Plugin config, validated by the same-named schemastery schema. Live fields are `Volatile`. */
|
|
44
|
+
export interface Config {
|
|
45
|
+
/** When true (default), a system-prompt section announces the plugin to every agent. */
|
|
46
|
+
announceToAgent?: Volatile<boolean>
|
|
47
|
+
/** Master switch for the plugin (routes, tools, prompt section). */
|
|
48
|
+
enabled?: Volatile<boolean>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const Config = z.object({
|
|
52
|
+
announceToAgent: z.boolean().default(true).volatile(),
|
|
53
|
+
enabled: z.boolean().default(true).volatile(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Schema default, re-read for hand-built test contexts. */
|
|
57
|
+
const DEFAULT_ANNOUNCE = true
|
|
58
|
+
|
|
59
|
+
/** Schema default for the master switch. */
|
|
60
|
+
const DEFAULT_ENABLED = true
|
|
61
|
+
|
|
62
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
63
|
+
const SECTION_ORDER = 151
|
|
64
|
+
|
|
65
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
66
|
+
export const WINRM_GUIDANCE = '本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(base64 分块,无 SMB 依赖)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mount the WinRM engine, routes, tools, and announcement.
|
|
70
|
+
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
71
|
+
* @param config - resolved plugin config (volatile fields read via `.get()`).
|
|
72
|
+
*/
|
|
73
|
+
export const apply = mountOnce('dsh-winrm', applyImpl)
|
|
74
|
+
|
|
75
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
76
|
+
const resolve = (): { announceToAgent: boolean; enabled: boolean } => ({
|
|
77
|
+
announceToAgent: config?.announceToAgent?.get() ?? DEFAULT_ANNOUNCE,
|
|
78
|
+
enabled: config?.enabled?.get() ?? DEFAULT_ENABLED,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const store = new HostStore()
|
|
82
|
+
const engine = new WinRmEngine(store)
|
|
83
|
+
|
|
84
|
+
const { routes, upgrade } = makeRoutes({ store, engine })
|
|
85
|
+
const tools = [
|
|
86
|
+
winrmListTool(engine),
|
|
87
|
+
winrmExecTool(engine),
|
|
88
|
+
winrmServiceTool(engine),
|
|
89
|
+
winrmProcessTool(engine),
|
|
90
|
+
winrmUploadTool(engine),
|
|
91
|
+
winrmDownloadTool(engine),
|
|
92
|
+
winrmClusterTool(engine),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
let disposeSection: (() => void) | undefined
|
|
96
|
+
let disposeRoutes: (() => void) | undefined
|
|
97
|
+
let disposeTools: (() => void) | undefined
|
|
98
|
+
|
|
99
|
+
const registerRoutes = (): (() => void) => {
|
|
100
|
+
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
101
|
+
const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade)
|
|
102
|
+
return () => {
|
|
103
|
+
for (const dispose of disposers) dispose()
|
|
104
|
+
upgradeDisposer()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const registerTools = (): (() => void) => {
|
|
108
|
+
const disposers = tools.map(tool => ctx.tools.register(tool))
|
|
109
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sync = (): void => {
|
|
113
|
+
disposeSection?.(); disposeSection = undefined
|
|
114
|
+
disposeRoutes?.(); disposeRoutes = undefined
|
|
115
|
+
disposeTools?.(); disposeTools = undefined
|
|
116
|
+
const value = resolve()
|
|
117
|
+
if (!value.enabled) return
|
|
118
|
+
if (value.announceToAgent) {
|
|
119
|
+
disposeSection = ctx.systemPrompt.section({
|
|
120
|
+
name: 'plugin:dsh-winrm',
|
|
121
|
+
order: SECTION_ORDER,
|
|
122
|
+
text: WINRM_GUIDANCE,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
disposeRoutes = registerRoutes()
|
|
126
|
+
disposeTools = registerTools()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Volatile config edits land on the running references, then notify the
|
|
130
|
+
// owning fiber here — re-sync the registrations against the new values.
|
|
131
|
+
ctx.on('loader/volatile-update', sync)
|
|
132
|
+
|
|
133
|
+
// Single teardown: dispose whatever the latest sync left registered.
|
|
134
|
+
ctx.effect(() => () => {
|
|
135
|
+
disposeSection?.()
|
|
136
|
+
disposeRoutes?.()
|
|
137
|
+
disposeTools?.()
|
|
138
|
+
engine.dispose()
|
|
139
|
+
}, 'dsh-winrm: teardown')
|
|
140
|
+
|
|
141
|
+
sync()
|
|
142
|
+
}
|
package/src/invariant.ts
ADDED
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PowerShell snippet builders. Every payload rides the UTF-8 base64
|
|
3
|
+
* envelope: the WinRM host prints only ASCII (marker + base64 of UTF-8 text)
|
|
4
|
+
* so Chinese / any codepage output survives the transport losslessly. One-shot
|
|
5
|
+
* commands execute directly in the PowerShell session created by WinRM.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
|
|
9
|
+
export const ENVELOPE_MARKER = '__DSH_WINRM__'
|
|
10
|
+
|
|
11
|
+
/** PS single-quoted literal (doubles embedded quotes). */
|
|
12
|
+
export function sq(value: string): string {
|
|
13
|
+
return "'" + value.replace(/'/g, "''") + "'"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Base64 of a UTF-16LE PowerShell script, for -EncodedCommand. */
|
|
17
|
+
export function encodeCommand(script: string): string {
|
|
18
|
+
return Buffer.from(script, 'utf16le').toString('base64')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The UTF-8 WinRM session script: runs `userScript` inside a script block,
|
|
23
|
+
* merges stderr, prints the exit-code marker and base64 of UTF-8 output.
|
|
24
|
+
*/
|
|
25
|
+
export function wrapEnvelope(userScript: string): string {
|
|
26
|
+
return [
|
|
27
|
+
"$ErrorActionPreference = 'Continue'",
|
|
28
|
+
"$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
|
|
29
|
+
"$__code = 0",
|
|
30
|
+
"if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
|
|
31
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
32
|
+
"'__DSH_WINRM__' + [string]$__code",
|
|
33
|
+
"[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
|
|
34
|
+
'',
|
|
35
|
+
].join('\r\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One-shot script body for the PowerShell session already created by WinRM. */
|
|
39
|
+
export function winrmPowerShellScript(userScript: string): string {
|
|
40
|
+
return wrapEnvelope(userScript)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The full powershell.exe command line (ASCII-safe) for a script body. */
|
|
44
|
+
export function powershellCommandLine(script: string): string {
|
|
45
|
+
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One-shot non-interactive PowerShell command line (envelope-wrapped). */
|
|
49
|
+
export function execCommandLine(userScript: string): string {
|
|
50
|
+
return powershellCommandLine(wrapEnvelope(userScript))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Interactive bootstrap for the streaming console (keeps the shell alive). */
|
|
54
|
+
export function consoleCommandLine(): string {
|
|
55
|
+
const bootstrap = [
|
|
56
|
+
"$ErrorActionPreference = 'Continue'",
|
|
57
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
58
|
+
"'__DSH_WINRM_CONSOLE_READY__'",
|
|
59
|
+
'',
|
|
60
|
+
].join('\r\n')
|
|
61
|
+
// -NoExit keeps powershell.exe alive reading stdin (interactive mode).
|
|
62
|
+
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '
|
|
63
|
+
+ encodeCommand(bootstrap) + ' -NoExit'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------ snippets
|
|
67
|
+
|
|
68
|
+
/** JSON: full service table (CIM). */
|
|
69
|
+
export function psListServices(): string {
|
|
70
|
+
return [
|
|
71
|
+
'$__s = Get-CimInstance Win32_Service | Select-Object Name, DisplayName, State, StartMode, StartName | ForEach-Object {',
|
|
72
|
+
' [PSCustomObject]@{ name=$_.Name; displayName=$_.DisplayName; status=[string]$_.State; startMode=[string]$_.StartMode; startName=[string]$_.StartName }',
|
|
73
|
+
'}',
|
|
74
|
+
'ConvertTo-Json -InputObject $__s -Compress -Depth 3',
|
|
75
|
+
'',
|
|
76
|
+
].join('\r\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** JSON: one service after an action. */
|
|
80
|
+
export function psServiceAction(name: string, action: 'start' | 'stop' | 'restart' | 'set-auto' | 'set-manual' | 'set-disabled'): string {
|
|
81
|
+
const statements: string[] = []
|
|
82
|
+
if (action === 'start') statements.push('Start-Service -Name ' + sq(name) + ' -ErrorAction Stop')
|
|
83
|
+
if (action === 'stop') statements.push('Stop-Service -Name ' + sq(name) + ' -Force -ErrorAction Stop')
|
|
84
|
+
if (action === 'restart') statements.push('Restart-Service -Name ' + sq(name) + ' -Force -ErrorAction Stop')
|
|
85
|
+
if (action === 'set-auto') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Automatic -ErrorAction Stop')
|
|
86
|
+
if (action === 'set-manual') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Manual -ErrorAction Stop')
|
|
87
|
+
if (action === 'set-disabled') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Disabled -ErrorAction Stop')
|
|
88
|
+
statements.push('$__s = Get-CimInstance Win32_Service -Filter ' + sq('Name=' + name.replace(/'/g, "''")) + ' | Select-Object Name, DisplayName, State, StartMode, StartName')
|
|
89
|
+
statements.push('ConvertTo-Json -InputObject $__s -Compress -Depth 3')
|
|
90
|
+
return statements.join('\r\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** JSON: process table. */
|
|
94
|
+
export function psListProcesses(): string {
|
|
95
|
+
return [
|
|
96
|
+
'$__p = Get-Process | Sort-Object Id | Select-Object Id, ProcessName, CPU, WS, StartTime, Path | ForEach-Object {',
|
|
97
|
+
' [PSCustomObject]@{ id=$_.Id; name=$_.ProcessName; cpu=if($null -eq $_.CPU){$null}else{[math]::Round([double]$_.CPU,1)}; memMB=[math]::Round($_.WS/1MB,1); startTime=if($_.StartTime){$_.StartTime.ToString(\'yyyy-MM-dd HH:mm:ss\')}else{$null}; path=[string]$_.Path }',
|
|
98
|
+
'}',
|
|
99
|
+
'ConvertTo-Json -InputObject $__p -Compress -Depth 3',
|
|
100
|
+
'',
|
|
101
|
+
].join('\r\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Kill one process by id. */
|
|
105
|
+
export function psKillProcess(id: number): string {
|
|
106
|
+
return 'Stop-Process -Id ' + String(id) + ' -Force -ErrorAction Stop; "killed " + ' + String(id)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** JSON: directory listing. */
|
|
110
|
+
export function psListDir(dir: string): string {
|
|
111
|
+
return [
|
|
112
|
+
'$__d = ' + sq(dir),
|
|
113
|
+
'$__items = Get-ChildItem -LiteralPath $__d -Force -ErrorAction Stop | Select-Object Name, PSIsContainer, Length, LastWriteTime | ForEach-Object {',
|
|
114
|
+
' [PSCustomObject]@{ name=[string]$_.Name; type=if($_.PSIsContainer){\'dir\'}elseif(-not $_.PSIsContainer -and $_.Length -ge 0){\'file\'}else{\'other\'}; size=if($_.PSIsContainer){0}else{[long]$_.Length}; mtimeMs=[long](([DateTimeOffset]$_.LastWriteTime).ToUnixTimeMilliseconds()) }',
|
|
115
|
+
'}',
|
|
116
|
+
'ConvertTo-Json -InputObject $__items -Compress -Depth 3',
|
|
117
|
+
'',
|
|
118
|
+
].join('\r\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Plain: remote file size in bytes (0 when missing). */
|
|
122
|
+
export function psFileSize(p: string): string {
|
|
123
|
+
return [
|
|
124
|
+
'$__f = Get-Item -LiteralPath ' + sq(p) + ' -ErrorAction SilentlyContinue',
|
|
125
|
+
'if ($null -eq $__f) { "0" } elseif ($__f.PSIsContainer) { "0" } else { [string]$__f.Length }',
|
|
126
|
+
'',
|
|
127
|
+
].join('\r\n')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Plain: base64 of up to `count` bytes at `offset` (download chunk). */
|
|
131
|
+
export function psReadChunk(p: string, offset: number, count: number): string {
|
|
132
|
+
return [
|
|
133
|
+
'$__p = ' + sq(p),
|
|
134
|
+
'$__fs = [System.IO.File]::OpenRead($__p)',
|
|
135
|
+
'try {',
|
|
136
|
+
' $__fs.Position = [long]' + String(offset),
|
|
137
|
+
' $__buf = New-Object byte[] ' + String(count),
|
|
138
|
+
' $__n = $__fs.Read($__buf, 0, $__buf.Length)',
|
|
139
|
+
' [System.Convert]::ToBase64String($__buf, 0, $__n)',
|
|
140
|
+
'} finally { $__fs.Dispose() }',
|
|
141
|
+
'',
|
|
142
|
+
].join('\r\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Plain: append `b64` bytes to `p` (Create on first chunk, Append after),
|
|
147
|
+
* prints the number of bytes written.
|
|
148
|
+
*/
|
|
149
|
+
export function psWriteChunk(p: string, b64: string, append: boolean): string {
|
|
150
|
+
return [
|
|
151
|
+
'$__p = ' + sq(p),
|
|
152
|
+
'$__d = [System.IO.Path]::GetDirectoryName($__p)',
|
|
153
|
+
'if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }',
|
|
154
|
+
'$__b = [System.Convert]::FromBase64String(' + sq(b64) + ')',
|
|
155
|
+
'$__mode = ' + (append ? '[System.IO.FileMode]::Append' : '[System.IO.FileMode]::Create'),
|
|
156
|
+
'$__fs = New-Object System.IO.FileStream($__p, $__mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)',
|
|
157
|
+
'try { $__fs.Write($__b, 0, $__b.Length) } finally { $__fs.Dispose() }',
|
|
158
|
+
'[string]$__b.Length',
|
|
159
|
+
'',
|
|
160
|
+
].join('\r\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse the envelope output: { exitCode, text } or null when absent.
|
|
165
|
+
*
|
|
166
|
+
* The base64 body is optional and the separating newline may be missing: a
|
|
167
|
+
* command that printed nothing emits the marker alone (an empty string has
|
|
168
|
+
* no base64 body), and a transport that trims each response drops the
|
|
169
|
+
* trailing newline. The exit code is signed because `$LASTEXITCODE` can be
|
|
170
|
+
* negative.
|
|
171
|
+
*/
|
|
172
|
+
export function parseEnvelope(output: string): { exitCode: number; text: string } | null {
|
|
173
|
+
const match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output)
|
|
174
|
+
if (match === null) return null
|
|
175
|
+
const exitCode = Number.parseInt(match[1], 10)
|
|
176
|
+
const payload = (match[2] ?? '').trim()
|
|
177
|
+
try {
|
|
178
|
+
const text = Buffer.from(payload, 'base64').toString('utf8')
|
|
179
|
+
return { exitCode, text }
|
|
180
|
+
} catch {
|
|
181
|
+
return { exitCode, text: payload }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
|
|
186
|
+
const CLIXML_HEADER = /^#< CLIXML\r?$/gm
|
|
187
|
+
|
|
188
|
+
/** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
|
|
189
|
+
const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Remove PowerShell's CLIXML host records from the WinRS stderr stream.
|
|
193
|
+
*
|
|
194
|
+
* PowerShell marks its stderr stream with a `#< CLIXML` header line and
|
|
195
|
+
* serializes the remote host's own progress/verbose records into `<Objs>`
|
|
196
|
+
* blocks. Both are removed here. Anything else on the stream is genuine
|
|
197
|
+
* process-level stderr and is preserved verbatim — text written between the
|
|
198
|
+
* header and a record block (for example `[Console]::Error`) is real output,
|
|
199
|
+
* not part of the CLIXML payload.
|
|
200
|
+
*
|
|
201
|
+
* The script's own error output is already merged into stdout by the
|
|
202
|
+
* envelope, so this only removes transport-level noise.
|
|
203
|
+
* @param stderr - raw stderr text captured from the WinRS stream.
|
|
204
|
+
* @returns the stderr text without CLIXML host records.
|
|
205
|
+
*/
|
|
206
|
+
export function stripClixml(stderr: string): string {
|
|
207
|
+
return stderr.replace(CLIXML_HEADER, '').replace(CLIXML_RECORDS, '').trim()
|
|
208
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract between the host half (routes.ts) and the browser half
|
|
3
|
+
* (client/api.ts). Pure types only — imported by both halves, bundled into
|
|
4
|
+
* each, no runtime identity to share.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** WinRM transport flavors. */
|
|
8
|
+
export type WinTransport = 'http' | 'https'
|
|
9
|
+
|
|
10
|
+
/** One stored host entry (the ~/.dsh/dsh-winrm.json store shape). */
|
|
11
|
+
export interface WinHostEntry {
|
|
12
|
+
/** Stable, user-chosen identifier used by every operation. */
|
|
13
|
+
alias: string
|
|
14
|
+
/** Hostname or IP of the target Windows machine. */
|
|
15
|
+
host: string
|
|
16
|
+
/** WinRM port (5985 HTTP, 5986 HTTPS). */
|
|
17
|
+
port: number
|
|
18
|
+
/** Login user. The transport auto-selects NTLM/Negotiate or Basic for local and domain accounts. */
|
|
19
|
+
user: string
|
|
20
|
+
/** Authentication (WinRM password; transport handles NTLM/Negotiate/Basic in-process). */
|
|
21
|
+
auth: {
|
|
22
|
+
kind: 'password'
|
|
23
|
+
password?: string
|
|
24
|
+
}
|
|
25
|
+
/** http (5985) or https (5986). */
|
|
26
|
+
transport: WinTransport
|
|
27
|
+
/** Whether self-signed HTTPS certificates are accepted. */
|
|
28
|
+
rejectUnauthorized?: boolean
|
|
29
|
+
/** Free-form note. */
|
|
30
|
+
description?: string
|
|
31
|
+
/** Deployment environment label (development / production / ...). */
|
|
32
|
+
environment?: string
|
|
33
|
+
/** Free-form tags. */
|
|
34
|
+
tags: string[]
|
|
35
|
+
/** Physical location note. */
|
|
36
|
+
location?: string
|
|
37
|
+
createdAt: number
|
|
38
|
+
updatedAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Public (secret-free) projection of an entry, safe for the browser/agent. */
|
|
42
|
+
export interface WinHostSummary {
|
|
43
|
+
alias: string
|
|
44
|
+
host: string
|
|
45
|
+
port: number
|
|
46
|
+
user: string
|
|
47
|
+
auth: 'password'
|
|
48
|
+
transport: WinTransport
|
|
49
|
+
rejectUnauthorized?: boolean
|
|
50
|
+
description?: string
|
|
51
|
+
environment?: string
|
|
52
|
+
tags: string[]
|
|
53
|
+
location?: string
|
|
54
|
+
createdAt: number
|
|
55
|
+
updatedAt: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of one PowerShell command execution. */
|
|
59
|
+
export interface ExecResult {
|
|
60
|
+
success: boolean
|
|
61
|
+
/** Remote exit code, or null when the channel died without one. */
|
|
62
|
+
exitCode: number | null
|
|
63
|
+
timedOut: boolean
|
|
64
|
+
stdout: string
|
|
65
|
+
stderr: string
|
|
66
|
+
/** Wall-clock duration of the round trip in ms. */
|
|
67
|
+
durationMs: number
|
|
68
|
+
/** Connection error message when the command never ran. */
|
|
69
|
+
error?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One Windows service entry. */
|
|
73
|
+
export interface ServiceInfo {
|
|
74
|
+
name: string
|
|
75
|
+
displayName: string
|
|
76
|
+
status: string
|
|
77
|
+
startMode: string
|
|
78
|
+
startName?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One Windows process entry. */
|
|
82
|
+
export interface ProcessInfo {
|
|
83
|
+
id: number
|
|
84
|
+
name: string
|
|
85
|
+
cpu?: number
|
|
86
|
+
memMB?: number
|
|
87
|
+
startTime?: string
|
|
88
|
+
path?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One directory listing entry (remote file browser). */
|
|
92
|
+
export interface RemoteDirEntry {
|
|
93
|
+
name: string
|
|
94
|
+
type: 'dir' | 'file' | 'other'
|
|
95
|
+
size: number
|
|
96
|
+
mtimeMs: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One server entry in a cluster run. */
|
|
100
|
+
export interface ClusterResult {
|
|
101
|
+
alias: string
|
|
102
|
+
ok: boolean
|
|
103
|
+
exitCode?: number | null
|
|
104
|
+
timedOut?: boolean
|
|
105
|
+
stdout?: string
|
|
106
|
+
stderr?: string
|
|
107
|
+
durationMs?: number
|
|
108
|
+
error?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Transfer progress frame (upload/download). */
|
|
112
|
+
export interface TransferProgress {
|
|
113
|
+
phase: 'connecting' | 'transferring' | 'done' | 'error'
|
|
114
|
+
file: string
|
|
115
|
+
transferred: number
|
|
116
|
+
total: number
|
|
117
|
+
percent: number
|
|
118
|
+
speedBps?: number
|
|
119
|
+
error?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test-connection outcome. */
|
|
123
|
+
export interface TestResult {
|
|
124
|
+
ok: boolean
|
|
125
|
+
latencyMs?: number
|
|
126
|
+
error?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Host edit payload (create/update); 'alias' comes from the URL for updates. */
|
|
130
|
+
export interface WinHostPayload {
|
|
131
|
+
alias?: string
|
|
132
|
+
host: string
|
|
133
|
+
port?: number
|
|
134
|
+
user: string
|
|
135
|
+
/** Authentication. Required on create; on update an omitted auth keeps the stored password. */
|
|
136
|
+
auth?: WinHostEntry['auth']
|
|
137
|
+
transport?: WinTransport
|
|
138
|
+
rejectUnauthorized?: boolean
|
|
139
|
+
description?: string
|
|
140
|
+
environment?: string
|
|
141
|
+
tags?: string[]
|
|
142
|
+
location?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** JSON error body used by every route. */
|
|
146
|
+
export interface ApiErrorBody {
|
|
147
|
+
error: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** WebSocket console protocol frames (host -> client and client -> host). */
|
|
151
|
+
export type ConsoleServerFrame =
|
|
152
|
+
| { type: 'ready'; alias: string }
|
|
153
|
+
| { type: 'output'; data: string }
|
|
154
|
+
| { type: 'exit'; code: number | null; error?: string }
|
|
155
|
+
|
|
156
|
+
export type ConsoleClientFrame =
|
|
157
|
+
| { type: 'input'; data: string }
|
|
158
|
+
|
|
159
|
+
/** Route paths the client calls (shared literals). */
|
|
160
|
+
export const WINRM_API_BASE = '/api/dsh-winrm' as const
|
|
161
|
+
|
|
162
|
+
export const WINRM_API = {
|
|
163
|
+
hosts: WINRM_API_BASE + '/hosts',
|
|
164
|
+
test: WINRM_API_BASE + '/test',
|
|
165
|
+
exec: WINRM_API_BASE + '/exec',
|
|
166
|
+
cluster: WINRM_API_BASE + '/cluster',
|
|
167
|
+
services: WINRM_API_BASE + '/services',
|
|
168
|
+
processes: WINRM_API_BASE + '/processes',
|
|
169
|
+
ls: WINRM_API_BASE + '/ls',
|
|
170
|
+
upload: WINRM_API_BASE + '/upload',
|
|
171
|
+
download: WINRM_API_BASE + '/download',
|
|
172
|
+
console: WINRM_API_BASE + '/console',
|
|
173
|
+
} as const
|
|
174
|
+
|
|
175
|
+
/** NDJSON transfer stream line shapes (upload). */
|
|
176
|
+
export type TransferStreamLine =
|
|
177
|
+
| { type: 'progress'; progress: TransferProgress }
|
|
178
|
+
| { type: 'result'; ok: boolean; transferredBytes?: number; error?: string }
|