dsh-winrm 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/README.zh.md +8 -5
- package/lib/client.js +70 -70
- package/lib/index.js +206 -62
- package/lib/types/engine/client.d.ts +5 -5
- package/lib/types/powershell.d.ts +84 -26
- package/package.json +16 -15
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +22 -20
- package/src/powershell.ts +194 -59
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
仿照 [dsh-ssh](https://github.com/zhu1090093659/dsh-web-ui/tree/main/packages/dsh-ssh) 开发的 DSH 插件:用 Windows 原生的 **WinRM / PowerShell Remoting** 协议远程管理 Windows 服务器,**目标机不需要装 OpenSSH**。
|
|
6
6
|
|
|
7
|
+
> **来源**:本项目是 [andyfan1094/dsh-winrm](https://github.com/andyfan1094/dsh-winrm)(Apache-2.0)的分支,把后端的 WinRM 实现从 Python + pywinrm 换成原生 Node.js 客户端([winrm-client](https://github.com/shide1989/winrm-client)),因此**本机不再需要 Python**。GUI、Agent 工具与功能范围沿用上游设计。
|
|
8
|
+
|
|
7
9
|
## 功能
|
|
8
10
|
|
|
9
11
|
| 面 | 说明 |
|
|
@@ -22,8 +24,9 @@
|
|
|
22
24
|
- 本地账户可写 `Administrator`;域账户可写 `DOMAIN\user` 或 `user@domain`
|
|
23
25
|
- 本机无需 Python —— WinRM 走纯 Node 依赖(winrm-client),随插件一起安装
|
|
24
26
|
- 传输:HTTP(5985) 或 HTTPS(5986);HTTPS 可勾选「接受自签名证书」
|
|
25
|
-
- 中文输出不乱码:所有命令走 **UTF-8 base64
|
|
27
|
+
- 中文输出不乱码:所有命令走 **UTF-8 base64 信封**(`Out-String -Stream` 包装 + 三条带标签的信封行),绕过 WinRM 传输的代码页问题
|
|
26
28
|
- 命令结果的 `stderr` 已自动剥离 PowerShell 的 CLIXML 宿主机记录;真实的进程级 stderr(如 `[Console]::Error`)原样保留
|
|
29
|
+
- **脚本走 WinRS stdin,不走命令行**:WinRS 把命令行交给 `cmd.exe`,上限约 8191 字符,而 `-EncodedCommand` 会把脚本膨胀 2.67 倍 —— 命令行长脚本此前会直接报 `The command line is too long.`。现在命令行是**常量**,脚本按 UTF-8 字节数分块送到 stdin,实测 200 KB 脚本正常
|
|
27
30
|
|
|
28
31
|
## 目标机准备(一次性)
|
|
29
32
|
|
|
@@ -39,12 +42,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\enable-winrm.ps1
|
|
|
39
42
|
|
|
40
43
|
## 安装
|
|
41
44
|
|
|
42
|
-
> 需要 DeepSeek Harness **0.1.7
|
|
45
|
+
> 需要 DeepSeek Harness **0.1.7-alpha.2 及以后**(含 `0.1.7-rc.1`、`0.2.0-rc.*`、`0.2.x`)。插件的 `peerDependencies` 声明为 `^0.1.7-alpha.2 || ^0.2.0-rc.1`,DSH 启动时会据此校验:0.1.7 与 0.2.x 各版本可加载,`0.3.0` 起会被拒绝(确需冒险可用 `dsh plugin allow-version` 逐版本放行)。
|
|
43
46
|
|
|
44
47
|
从 [Releases](https://github.com/MoRanYue/dsh-winrm/releases) 下载最新的 `dsh-winrm-*.tgz`,加入 profile:
|
|
45
48
|
|
|
46
49
|
```powershell
|
|
47
|
-
dsh plugin --profile web add D:\downloads\dsh-winrm-0.
|
|
50
|
+
dsh plugin --profile web add D:\downloads\dsh-winrm-0.2.0.tgz
|
|
48
51
|
```
|
|
49
52
|
|
|
50
53
|
也可以直接从 npm 安装:
|
|
@@ -90,7 +93,7 @@ src/
|
|
|
90
93
|
engine.ts WinRmEngine 门面:exec / services / processes / ls / upload / download / console / cluster / test
|
|
91
94
|
store.ts ~/.dsh/dsh-winrm.json 主机存储(原子写,0600)
|
|
92
95
|
protocol.ts 宿主↔浏览器 wire 类型
|
|
93
|
-
powershell.ts PS
|
|
96
|
+
powershell.ts PS 片段构造器 + stdin 信封(outer/child 包装 / 服务 / 进程 / 目录 / 分块读写)
|
|
94
97
|
routes.ts /api/dsh-winrm 路由族 + 控制台 WebSocket(loopback 围栏)
|
|
95
98
|
tools.ts 7 个 winrm_* agent 工具
|
|
96
99
|
engine/client.ts winrm-client 传输层:进程内认证(NTLM/Basic)、UTF-8 信封、分块传输
|
|
@@ -106,7 +109,7 @@ scripts/
|
|
|
106
109
|
## 已知限制
|
|
107
110
|
|
|
108
111
|
- 控制台是命令会话,不保持 PowerShell 变量和当前目录状态;每条命令独立执行
|
|
109
|
-
-
|
|
112
|
+
- 传输按 48KB 分块,大文件较慢(每块一次往返);分块大小由 `TRANSFER_CHUNK` 决定,脚本本身已不再受命令行长度限制
|
|
110
113
|
- 单命令默认 60s 超时,可传 `timeoutMs`
|
|
111
114
|
- 目标机需已启用 WinRM(见上);HTTP 明文 Basic 不应用于公网
|
|
112
115
|
- 面板关闭再打开会回到「主机」页签:面板是 `main` 插槽的占用者,切回对话时卸载,页签状态不跨开关保留
|
package/README.zh.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
仿照 [dsh-ssh](https://github.com/zhu1090093659/dsh-web-ui/tree/main/packages/dsh-ssh) 开发的 DSH 插件:用 Windows 原生的 **WinRM / PowerShell Remoting** 协议远程管理 Windows 服务器,**目标机不需要装 OpenSSH**。
|
|
4
4
|
|
|
5
|
+
> **来源**:本项目是 [andyfan1094/dsh-winrm](https://github.com/andyfan1094/dsh-winrm)(Apache-2.0)的分支,把后端的 WinRM 实现从 Python + pywinrm 换成原生 Node.js 客户端([winrm-client](https://github.com/shide1989/winrm-client)),因此**本机不再需要 Python**。GUI、Agent 工具与功能范围沿用上游设计。
|
|
6
|
+
|
|
5
7
|
## 功能
|
|
6
8
|
|
|
7
9
|
| 面 | 说明 |
|
|
@@ -20,8 +22,9 @@
|
|
|
20
22
|
- 本地账户可写 `Administrator`;域账户可写 `DOMAIN\\user` 或 `user@domain`
|
|
21
23
|
- 本机无需 Python —— WinRM 走纯 Node 依赖(winrm-client),随插件一起安装
|
|
22
24
|
- 传输:HTTP(5985) 或 HTTPS(5986);HTTPS 可勾选「接受自签名证书」
|
|
23
|
-
- 中文输出不乱码:所有命令走 **UTF-8 base64
|
|
25
|
+
- 中文输出不乱码:所有命令走 **UTF-8 base64 信封**(`Out-String -Stream` 包装 + 三条带标签的信封行),绕过 WinRM 传输的代码页问题
|
|
24
26
|
- 命令结果的 `stderr` 已自动剥离 PowerShell 的 CLIXML 宿主机记录;真实的进程级 stderr(如 `[Console]::Error`)原样保留
|
|
27
|
+
- **脚本走 WinRS stdin,不走命令行**:WinRS 把命令行交给 `cmd.exe`,上限约 8191 字符,而 `-EncodedCommand` 会把脚本膨胀 2.67 倍 —— 命令行长脚本此前会直接报 `The command line is too long.`。现在命令行是**常量**,脚本按 UTF-8 字节数分块送到 stdin,实测 200 KB 脚本正常
|
|
25
28
|
|
|
26
29
|
## 目标机准备(一次性)
|
|
27
30
|
|
|
@@ -42,12 +45,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\enable-winrm.ps1
|
|
|
42
45
|
|
|
43
46
|
## 安装
|
|
44
47
|
|
|
45
|
-
> 需要 DeepSeek Harness **0.1.7
|
|
48
|
+
> 需要 DeepSeek Harness **0.1.7-alpha.2 及以后**(含 `0.1.7-rc.1`、`0.2.0-rc.*`、`0.2.x`)。插件的 `peerDependencies` 声明为 `^0.1.7-alpha.2 || ^0.2.0-rc.1`,DSH 启动时会据此校验:0.1.7 与 0.2.x 各版本可加载,`0.3.0` 起会被拒绝(确需冒险可用 `dsh plugin allow-version` 逐版本放行)。
|
|
46
49
|
|
|
47
50
|
从 [Releases](https://github.com/MoRanYue/dsh-winrm/releases) 下载最新的 `dsh-winrm-*.tgz`,加入 profile:
|
|
48
51
|
|
|
49
52
|
```powershell
|
|
50
|
-
dsh plugin --profile web add D:\downloads\dsh-winrm-0.
|
|
53
|
+
dsh plugin --profile web add D:\downloads\dsh-winrm-0.2.0.tgz
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
也可以直接从 npm 安装:
|
|
@@ -93,7 +96,7 @@ src/
|
|
|
93
96
|
engine.ts WinRmEngine 门面:exec / services / processes / ls / upload / download / console / cluster / test
|
|
94
97
|
store.ts ~/.dsh/dsh-winrm.json 主机存储(原子写,0600)
|
|
95
98
|
protocol.ts 宿主↔浏览器 wire 类型
|
|
96
|
-
powershell.ts PS
|
|
99
|
+
powershell.ts PS 片段构造器 + stdin 信封(outer/child 包装 / 服务 / 进程 / 目录 / 分块读写)
|
|
97
100
|
routes.ts /api/dsh-winrm 路由族 + 控制台 WebSocket(loopback 围栏)
|
|
98
101
|
tools.ts 7 个 winrm_* agent 工具
|
|
99
102
|
engine/client.ts winrm-client 传输层:进程内认证(NTLM/Basic)、UTF-8 信封、分块传输
|
|
@@ -109,7 +112,7 @@ scripts/
|
|
|
109
112
|
## 已知限制
|
|
110
113
|
|
|
111
114
|
- 控制台是命令会话,不保持 PowerShell 变量和当前目录状态;每条命令独立执行
|
|
112
|
-
-
|
|
115
|
+
- 传输按 48KB 分块,大文件较慢(每块一次往返);分块大小由 `TRANSFER_CHUNK` 决定,脚本本身已不再受命令行长度限制
|
|
113
116
|
- 单命令默认 60s 超时,可传 `timeoutMs`
|
|
114
117
|
- 目标机需已启用 WinRM(见上);HTTP 明文 Basic 不应用于公网
|
|
115
118
|
- 面板关闭再打开会回到「主机」页签:面板是 `main` 插槽的占用者,切回对话时卸载,页签状态不跨开关保留
|
package/lib/client.js
CHANGED
|
@@ -565,93 +565,93 @@ if (typeof document !== "undefined" && document.querySelector("style[data-plugin
|
|
|
565
565
|
document.head.appendChild(tag);
|
|
566
566
|
}
|
|
567
567
|
var panel_module_css_default = {
|
|
568
|
-
"
|
|
569
|
-
"
|
|
570
|
-
"termBody": "hAbY3W_termBody",
|
|
571
|
-
"cellDetails": "hAbY3W_cellDetails",
|
|
568
|
+
"modalBackdrop": "hAbY3W_modalBackdrop",
|
|
569
|
+
"panel": "hAbY3W_panel",
|
|
572
570
|
"groupCount": "hAbY3W_groupCount",
|
|
573
|
-
"
|
|
574
|
-
"
|
|
571
|
+
"tab": "hAbY3W_tab",
|
|
572
|
+
"iconButton": "hAbY3W_iconButton",
|
|
573
|
+
"banner": "hAbY3W_banner",
|
|
574
|
+
"panelHeader": "hAbY3W_panelHeader",
|
|
575
575
|
"utf8Note": "hAbY3W_utf8Note",
|
|
576
|
-
"checkRow": "hAbY3W_checkRow",
|
|
577
|
-
"fieldHint": "hAbY3W_fieldHint",
|
|
578
|
-
"linkButton": "hAbY3W_linkButton",
|
|
579
|
-
"groupToggle": "hAbY3W_groupToggle",
|
|
580
|
-
"entryIcon": "hAbY3W_entryIcon",
|
|
581
|
-
"modalTitle": "hAbY3W_modalTitle",
|
|
582
|
-
"empty": "hAbY3W_empty",
|
|
583
|
-
"hiddenFile": "hAbY3W_hiddenFile",
|
|
584
|
-
"panelTitle": "hAbY3W_panelTitle",
|
|
585
576
|
"cellPre": "hAbY3W_cellPre",
|
|
586
|
-
"
|
|
587
|
-
"
|
|
577
|
+
"dialog": "hAbY3W_dialog",
|
|
578
|
+
"hiddenFile": "hAbY3W_hiddenFile",
|
|
579
|
+
"linkButton": "hAbY3W_linkButton",
|
|
580
|
+
"tabBody": "hAbY3W_tabBody",
|
|
581
|
+
"dirName": "hAbY3W_dirName",
|
|
582
|
+
"fillBody": "hAbY3W_fillBody",
|
|
583
|
+
"backButton": "hAbY3W_backButton",
|
|
584
|
+
"mono": "hAbY3W_mono",
|
|
585
|
+
"actions": "hAbY3W_actions",
|
|
586
|
+
"tunnelRow": "hAbY3W_tunnelRow",
|
|
587
|
+
"commandInput": "hAbY3W_commandInput",
|
|
588
|
+
"ghostButton": "hAbY3W_ghostButton",
|
|
589
|
+
"dialogOverlay": "hAbY3W_dialogOverlay",
|
|
588
590
|
"table": "hAbY3W_table",
|
|
589
|
-
"
|
|
590
|
-
"browsePath": "hAbY3W_browsePath",
|
|
591
|
+
"termBody": "hAbY3W_termBody",
|
|
591
592
|
"browseList": "hAbY3W_browseList",
|
|
592
593
|
"radioRow": "hAbY3W_radioRow",
|
|
594
|
+
"progressBar": "hAbY3W_progressBar",
|
|
595
|
+
"dialogTitle": "hAbY3W_dialogTitle",
|
|
596
|
+
"dirRow": "hAbY3W_dirRow",
|
|
597
|
+
"groupHeader": "hAbY3W_groupHeader",
|
|
598
|
+
"consoleOutput": "hAbY3W_consoleOutput",
|
|
599
|
+
"spinner": "hAbY3W_spinner",
|
|
593
600
|
"transferBlock": "hAbY3W_transferBlock",
|
|
594
|
-
"
|
|
595
|
-
"termPlaceholder": "hAbY3W_termPlaceholder",
|
|
596
|
-
"search": "hAbY3W_search",
|
|
597
|
-
"progressMeta": "hAbY3W_progressMeta",
|
|
598
|
-
"consoleInputRow": "hAbY3W_consoleInputRow",
|
|
599
|
-
"mono": "hAbY3W_mono",
|
|
600
|
-
"entryLabel": "hAbY3W_entryLabel",
|
|
601
|
-
"tableWrap": "hAbY3W_tableWrap",
|
|
602
|
-
"hint": "hAbY3W_hint",
|
|
603
|
-
"tunnelLabel": "hAbY3W_tunnelLabel",
|
|
604
|
-
"fieldLabel": "hAbY3W_fieldLabel",
|
|
601
|
+
"consoleHeader": "hAbY3W_consoleHeader",
|
|
605
602
|
"progressTrack": "hAbY3W_progressTrack",
|
|
603
|
+
"termContainer": "hAbY3W_termContainer",
|
|
604
|
+
"loading": "hAbY3W_loading",
|
|
605
|
+
"tunnelList": "hAbY3W_tunnelList",
|
|
606
|
+
"cellDetails": "hAbY3W_cellDetails",
|
|
607
|
+
"consoleWrap": "hAbY3W_consoleWrap",
|
|
606
608
|
"modal": "hAbY3W_modal",
|
|
607
|
-
"dshSshSpin": "hAbY3W_dshSshSpin",
|
|
608
|
-
"iconButton": "hAbY3W_iconButton",
|
|
609
|
-
"backButton": "hAbY3W_backButton",
|
|
610
|
-
"ghostButton": "hAbY3W_ghostButton",
|
|
611
|
-
"tabBody": "hAbY3W_tabBody",
|
|
612
|
-
"clusterForm": "hAbY3W_clusterForm",
|
|
613
|
-
"dirSize": "hAbY3W_dirSize",
|
|
614
|
-
"groupHeader": "hAbY3W_groupHeader",
|
|
615
|
-
"fillBody": "hAbY3W_fillBody",
|
|
616
|
-
"panel": "hAbY3W_panel",
|
|
617
|
-
"spinner": "hAbY3W_spinner",
|
|
618
|
-
"cellMuted": "hAbY3W_cellMuted",
|
|
619
|
-
"groupChevron": "hAbY3W_groupChevron",
|
|
620
|
-
"field": "hAbY3W_field",
|
|
621
609
|
"formRow": "hAbY3W_formRow",
|
|
622
|
-
"
|
|
623
|
-
"
|
|
624
|
-
"
|
|
625
|
-
"
|
|
626
|
-
"
|
|
627
|
-
"tunnelList": "hAbY3W_tunnelList",
|
|
610
|
+
"progressMeta": "hAbY3W_progressMeta",
|
|
611
|
+
"controls": "hAbY3W_controls",
|
|
612
|
+
"modalFooter": "hAbY3W_modalFooter",
|
|
613
|
+
"groupName": "hAbY3W_groupName",
|
|
614
|
+
"modalTitle": "hAbY3W_modalTitle",
|
|
628
615
|
"radioLabel": "hAbY3W_radioLabel",
|
|
629
|
-
"
|
|
630
|
-
"browseHeader": "hAbY3W_browseHeader",
|
|
631
|
-
"primaryButton": "hAbY3W_primaryButton",
|
|
632
|
-
"banner": "hAbY3W_banner",
|
|
616
|
+
"toolbarSpacer": "hAbY3W_toolbarSpacer",
|
|
633
617
|
"browsePanel": "hAbY3W_browsePanel",
|
|
634
|
-
"
|
|
635
|
-
"
|
|
636
|
-
"
|
|
637
|
-
"dialogOverlay": "hAbY3W_dialogOverlay",
|
|
638
|
-
"termWrap": "hAbY3W_termWrap",
|
|
639
|
-
"tab": "hAbY3W_tab",
|
|
618
|
+
"checkRow": "hAbY3W_checkRow",
|
|
619
|
+
"entryIcon": "hAbY3W_entryIcon",
|
|
620
|
+
"groupSection": "hAbY3W_groupSection",
|
|
640
621
|
"input": "hAbY3W_input",
|
|
641
|
-
"consoleHeader": "hAbY3W_consoleHeader",
|
|
642
|
-
"badge": "hAbY3W_badge",
|
|
643
622
|
"formError": "hAbY3W_formError",
|
|
644
|
-
"
|
|
645
|
-
"
|
|
646
|
-
"
|
|
623
|
+
"empty": "hAbY3W_empty",
|
|
624
|
+
"entry": "hAbY3W_entry",
|
|
625
|
+
"badge": "hAbY3W_badge",
|
|
626
|
+
"field": "hAbY3W_field",
|
|
627
|
+
"browsePath": "hAbY3W_browsePath",
|
|
628
|
+
"dshSshSpin": "hAbY3W_dshSshSpin",
|
|
647
629
|
"toolbar": "hAbY3W_toolbar",
|
|
648
|
-
"
|
|
630
|
+
"dirType": "hAbY3W_dirType",
|
|
631
|
+
"panelTitle": "hAbY3W_panelTitle",
|
|
649
632
|
"formCard": "hAbY3W_formCard",
|
|
650
|
-
"
|
|
651
|
-
"
|
|
652
|
-
"
|
|
633
|
+
"browseHeader": "hAbY3W_browseHeader",
|
|
634
|
+
"entryLabel": "hAbY3W_entryLabel",
|
|
635
|
+
"groupChevron": "hAbY3W_groupChevron",
|
|
636
|
+
"panelContent": "hAbY3W_panelContent",
|
|
637
|
+
"termWrap": "hAbY3W_termWrap",
|
|
638
|
+
"termPlaceholder": "hAbY3W_termPlaceholder",
|
|
639
|
+
"fieldHint": "hAbY3W_fieldHint",
|
|
640
|
+
"groupBySelect": "hAbY3W_groupBySelect",
|
|
641
|
+
"groupToggle": "hAbY3W_groupToggle",
|
|
642
|
+
"clusterForm": "hAbY3W_clusterForm",
|
|
643
|
+
"fieldLabel": "hAbY3W_fieldLabel",
|
|
644
|
+
"cellMuted": "hAbY3W_cellMuted",
|
|
645
|
+
"tabBar": "hAbY3W_tabBar",
|
|
646
|
+
"tableWrap": "hAbY3W_tableWrap",
|
|
647
|
+
"consoleInputRow": "hAbY3W_consoleInputRow",
|
|
648
|
+
"search": "hAbY3W_search",
|
|
653
649
|
"inlineTest": "hAbY3W_inlineTest",
|
|
654
|
-
"
|
|
650
|
+
"hint": "hAbY3W_hint",
|
|
651
|
+
"tunnelLabel": "hAbY3W_tunnelLabel",
|
|
652
|
+
"dirSize": "hAbY3W_dirSize",
|
|
653
|
+
"clusterFilters": "hAbY3W_clusterFilters",
|
|
654
|
+
"primaryButton": "hAbY3W_primaryButton"
|
|
655
655
|
};
|
|
656
656
|
//#endregion
|
|
657
657
|
//#region src/client/panel/ConsoleTab.tsx
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,50 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
9
|
import { WebSocket, WebSocketServer } from "ws";
|
|
10
10
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
11
|
//#region src/powershell.ts
|
|
12
|
+
/**
|
|
13
|
+
* PowerShell snippet builders and the WinRM UTF-8 envelope.
|
|
14
|
+
*
|
|
15
|
+
* The user script is delivered on the WinRS **stdin** stream, not on the
|
|
16
|
+
* command line. WinRS hands the command line to cmd.exe, which caps it at
|
|
17
|
+
* ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
|
|
18
|
+
* (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
|
|
19
|
+
* script. That silently broke every large payload — most visibly the 48 KiB
|
|
20
|
+
* transfer chunk, whose embedded base64 alone is ~65K characters.
|
|
21
|
+
*
|
|
22
|
+
* So the command line is now a *constant* outer script and the user script
|
|
23
|
+
* rides stdin, which has no such limit (verified to 200 KB).
|
|
24
|
+
*
|
|
25
|
+
* The outer script reads a byte count, then that many bytes, starts a child
|
|
26
|
+
* `powershell.exe` and feeds it the script on its stdin. The process boundary
|
|
27
|
+
* is what makes `exit N` and an uncaught `throw` survivable: they terminate
|
|
28
|
+
* the *child*, and the outer script is still alive to print the envelope.
|
|
29
|
+
* `-EncodedCommand` for the child also sidesteps the execution policy
|
|
30
|
+
* entirely — the policy governs `.ps1` files, not encoded commands — so no
|
|
31
|
+
* temp file is written and no policy block can lose the envelope.
|
|
32
|
+
*
|
|
33
|
+
* The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
|
|
34
|
+
* `2>&1` pulls error records into the success stream, `Out-String -Stream`
|
|
35
|
+
* stringifies them onto stdout *line by line*. Plain `Out-String` would also
|
|
36
|
+
* stringify them but buffers the whole pipeline, discarding everything
|
|
37
|
+
* produced before a mid-script `exit`; bare `2>&1` without `Out-String`
|
|
38
|
+
* leaves the records to be re-serialized as CLIXML on stderr, where the
|
|
39
|
+
* envelope's CLIXML stripping deletes the text outright.
|
|
40
|
+
*
|
|
41
|
+
* The envelope is three lines — a marker with the exit code, then one base64
|
|
42
|
+
* line per stream:
|
|
43
|
+
*
|
|
44
|
+
* __DSH_WINRM__<code>
|
|
45
|
+
* O:<base64 of UTF-8 stdout>
|
|
46
|
+
* E:<base64 of UTF-8 stderr>
|
|
47
|
+
*
|
|
48
|
+
* Every payload is ASCII, so the transport cannot corrupt it and Chinese
|
|
49
|
+
* output survives losslessly. The `O:` / `E:` labels are load-bearing: the
|
|
50
|
+
* WinRM client trims each response chunk, which can eat the newline between
|
|
51
|
+
* two lines, and base64 never contains `:`, so the labels stay parseable
|
|
52
|
+
* even when the separators vanish.
|
|
53
|
+
*/
|
|
54
|
+
/** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
|
|
55
|
+
const ENVELOPE_MARKER = "__DSH_WINRM__";
|
|
12
56
|
/** PS single-quoted literal (doubles embedded quotes). */
|
|
13
57
|
function sq(value) {
|
|
14
58
|
return "'" + value.replace(/'/g, "''") + "'";
|
|
@@ -18,28 +62,121 @@ function encodeCommand(script) {
|
|
|
18
62
|
return Buffer.from(script, "utf16le").toString("base64");
|
|
19
63
|
}
|
|
20
64
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
65
|
+
* Outer script (constant, so the command line never grows with the payload):
|
|
66
|
+
* read `<byteCount>\n<utf8 script>` from stdin, run it in a child
|
|
67
|
+
* powershell.exe, then print the three-line envelope.
|
|
68
|
+
*
|
|
69
|
+
* The child is started with redirected streams *before* anything is written to
|
|
70
|
+
* its stdin, and both readers are started before the write, so a child that
|
|
71
|
+
* fills a pipe buffer cannot deadlock the outer script. Closing the writer is
|
|
72
|
+
* what gives the child a real EOF — without it the child would block forever
|
|
73
|
+
* on `CopyTo`.
|
|
23
74
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"$
|
|
29
|
-
"
|
|
30
|
-
"[Console]::
|
|
31
|
-
"
|
|
32
|
-
"
|
|
75
|
+
const OUTER_SCRIPT = [
|
|
76
|
+
"$ErrorActionPreference='Continue'",
|
|
77
|
+
"$z=@'",
|
|
78
|
+
[
|
|
79
|
+
"$ErrorActionPreference='Continue'",
|
|
80
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
81
|
+
"$i=[Console]::OpenStandardInput()",
|
|
82
|
+
"$m=New-Object System.IO.MemoryStream",
|
|
83
|
+
"$i.CopyTo($m)",
|
|
84
|
+
"$s=[Text.Encoding]::UTF8.GetString($m.ToArray())",
|
|
85
|
+
"if($s.Length -gt 0 -and [int]$s[0] -eq 0xFEFF){$s=$s.Substring(1)}",
|
|
86
|
+
"$c=0",
|
|
87
|
+
"try{ &([scriptblock]::Create($s)) 2>&1 | Out-String -Stream -Width 4096",
|
|
88
|
+
"if($null -ne $LASTEXITCODE){$c=$LASTEXITCODE}",
|
|
89
|
+
"}catch{ $_|Out-String; $c=1 }",
|
|
90
|
+
"exit $c",
|
|
33
91
|
""
|
|
34
|
-
].join("\r\n")
|
|
92
|
+
].join("\r\n"),
|
|
93
|
+
"'@",
|
|
94
|
+
"$a=''",
|
|
95
|
+
"$e=''",
|
|
96
|
+
"$c=0",
|
|
97
|
+
"try{",
|
|
98
|
+
" $i=[Console]::OpenStandardInput()",
|
|
99
|
+
" $d=''",
|
|
100
|
+
" while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}",
|
|
101
|
+
" $n=[int]$d",
|
|
102
|
+
" $u=New-Object byte[] $n",
|
|
103
|
+
" $r=0",
|
|
104
|
+
" while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}",
|
|
105
|
+
" $s=[Text.Encoding]::UTF8.GetString($u,0,$r)",
|
|
106
|
+
" $p=New-Object System.Diagnostics.ProcessStartInfo",
|
|
107
|
+
" $p.FileName='powershell.exe'",
|
|
108
|
+
" $p.UseShellExecute=$false",
|
|
109
|
+
" $p.RedirectStandardOutput=$true",
|
|
110
|
+
" $p.RedirectStandardError=$true",
|
|
111
|
+
" $p.RedirectStandardInput=$true",
|
|
112
|
+
" $p.StandardOutputEncoding=[Text.Encoding]::UTF8",
|
|
113
|
+
" $p.StandardErrorEncoding=[Text.Encoding]::UTF8",
|
|
114
|
+
" $p.CreateNoWindow=$true",
|
|
115
|
+
" $p.Arguments='-NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '+[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($z))",
|
|
116
|
+
" $q=[System.Diagnostics.Process]::Start($p)",
|
|
117
|
+
" $x=$q.StandardOutput.ReadToEndAsync()",
|
|
118
|
+
" $y=$q.StandardError.ReadToEndAsync()",
|
|
119
|
+
" $w=New-Object System.IO.StreamWriter($q.StandardInput.BaseStream,(New-Object System.Text.UTF8Encoding($false)))",
|
|
120
|
+
" $w.Write($s)",
|
|
121
|
+
" $w.Flush()",
|
|
122
|
+
" $w.Close()",
|
|
123
|
+
" $q.WaitForExit()",
|
|
124
|
+
" $a=[string]$x.Result",
|
|
125
|
+
" $e=[string]$y.Result",
|
|
126
|
+
" $c=$q.ExitCode",
|
|
127
|
+
"}catch{",
|
|
128
|
+
" $a=[string]$a+[string]($_|Out-String)",
|
|
129
|
+
" $c=1",
|
|
130
|
+
"}",
|
|
131
|
+
sq(ENVELOPE_MARKER) + "+[string]$c",
|
|
132
|
+
"'O:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$a))",
|
|
133
|
+
"'E:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$e))",
|
|
134
|
+
""
|
|
135
|
+
].join("\r\n");
|
|
136
|
+
/**
|
|
137
|
+
* The constant powershell.exe command line that runs the envelope.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately takes no payload: keeping the command line under cmd.exe's
|
|
140
|
+
* ~8191-character cap is the whole point of the stdin channel.
|
|
141
|
+
*/
|
|
142
|
+
function powershellCommandLine() {
|
|
143
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(OUTER_SCRIPT);
|
|
35
144
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The stdin payload for one command: the UTF-8 **byte** count, a newline, then
|
|
147
|
+
* the raw script.
|
|
148
|
+
*
|
|
149
|
+
* The count is in bytes, not characters, because the outer script sizes its
|
|
150
|
+
* receive buffer from it — a script with multi-byte characters would otherwise
|
|
151
|
+
* be truncated mid-character.
|
|
152
|
+
* @param userScript - the PowerShell script to run.
|
|
153
|
+
* @returns the payload to send on WinRS stdin.
|
|
154
|
+
*/
|
|
155
|
+
function scriptPayload(userScript) {
|
|
156
|
+
return String(Buffer.byteLength(userScript, "utf8")) + "\n" + userScript;
|
|
39
157
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Split a payload into Send-sized pieces without cutting a surrogate pair.
|
|
160
|
+
*
|
|
161
|
+
* `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
|
|
162
|
+
* surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
|
|
163
|
+
* script. Iterating with `for…of` yields whole code points, so a pair always
|
|
164
|
+
* stays in one piece.
|
|
165
|
+
* @param payload - the full stdin payload.
|
|
166
|
+
* @returns the pieces to send in order; never empty.
|
|
167
|
+
*/
|
|
168
|
+
function splitScriptPayload(payload) {
|
|
169
|
+
const pieces = [];
|
|
170
|
+
let piece = "";
|
|
171
|
+
for (const ch of payload) {
|
|
172
|
+
if (piece.length + ch.length > 16384) {
|
|
173
|
+
pieces.push(piece);
|
|
174
|
+
piece = "";
|
|
175
|
+
}
|
|
176
|
+
piece += ch;
|
|
177
|
+
}
|
|
178
|
+
if (piece !== "" || pieces.length === 0) pieces.push(piece);
|
|
179
|
+
return pieces;
|
|
43
180
|
}
|
|
44
181
|
/** JSON: full service table (CIM). */
|
|
45
182
|
function psListServices() {
|
|
@@ -128,38 +265,39 @@ function psWriteChunk(p, b64, append) {
|
|
|
128
265
|
""
|
|
129
266
|
].join("\r\n");
|
|
130
267
|
}
|
|
268
|
+
/** Decode one base64 envelope field, tolerating whitespace the transport added. */
|
|
269
|
+
function decodeField(base64) {
|
|
270
|
+
return Buffer.from(base64.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
271
|
+
}
|
|
131
272
|
/**
|
|
132
|
-
* Parse the envelope
|
|
273
|
+
* Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
|
|
133
274
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* negative.
|
|
275
|
+
* Fields are located by their `O:` / `E:` labels rather than by splitting on
|
|
276
|
+
* newlines, because the WinRM client trims every response chunk and can drop
|
|
277
|
+
* the newline that separated two lines. base64's alphabet has no colon, so a
|
|
278
|
+
* label can only ever be a delimiter and the search cannot land on payload
|
|
279
|
+
* data. The exit code is signed because `$LASTEXITCODE` can be negative.
|
|
280
|
+
* @param output - the raw stdout accumulated from the WinRS stdout stream.
|
|
281
|
+
* @returns the decoded envelope, or null when the marker is missing.
|
|
139
282
|
*/
|
|
140
283
|
function parseEnvelope(output) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
exitCode,
|
|
153
|
-
text: payload
|
|
154
|
-
};
|
|
155
|
-
}
|
|
284
|
+
const text = output.trim();
|
|
285
|
+
const head = (/* @__PURE__ */ new RegExp("^__DSH_WINRM__(-?\\d+)")).exec(text);
|
|
286
|
+
if (head === null) return null;
|
|
287
|
+
const stdoutAt = text.indexOf("O:");
|
|
288
|
+
const stderrAt = stdoutAt === -1 ? -1 : text.indexOf("E:", stdoutAt + 2);
|
|
289
|
+
return {
|
|
290
|
+
exitCode: Number.parseInt(head[1], 10),
|
|
291
|
+
stdout: stdoutAt === -1 ? "" : decodeField(text.slice(stdoutAt + 2, stderrAt === -1 ? void 0 : stderrAt)),
|
|
292
|
+
stderr: stderrAt === -1 ? "" : decodeField(text.slice(stderrAt + 2))
|
|
293
|
+
};
|
|
156
294
|
}
|
|
157
295
|
/** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
|
|
158
296
|
const CLIXML_HEADER = /^#< CLIXML\r?$/gm;
|
|
159
297
|
/** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
|
|
160
298
|
const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
|
|
161
299
|
/**
|
|
162
|
-
* Remove PowerShell's CLIXML host records from
|
|
300
|
+
* Remove PowerShell's CLIXML host records from a stderr stream.
|
|
163
301
|
*
|
|
164
302
|
* PowerShell marks its stderr stream with a `#< CLIXML` header line and
|
|
165
303
|
* serializes the remote host's own progress/verbose records into `<Objs>`
|
|
@@ -168,9 +306,10 @@ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
|
|
|
168
306
|
* header and a record block (for example `[Console]::Error`) is real output,
|
|
169
307
|
* not part of the CLIXML payload.
|
|
170
308
|
*
|
|
171
|
-
* The script's own
|
|
172
|
-
* envelope
|
|
173
|
-
*
|
|
309
|
+
* The script's own PowerShell errors are already stringified into the
|
|
310
|
+
* envelope's stdout by `Out-String -Stream`, so this only removes
|
|
311
|
+
* transport-level noise such as module-load progress.
|
|
312
|
+
* @param stderr - raw stderr text captured from the WinRS stream or the envelope.
|
|
174
313
|
* @returns the stderr text without CLIXML host records.
|
|
175
314
|
*/
|
|
176
315
|
function stripClixml(stderr) {
|
|
@@ -182,11 +321,11 @@ function stripClixml(stderr) {
|
|
|
182
321
|
* WinRM transport bridge.
|
|
183
322
|
*
|
|
184
323
|
* Runs each PowerShell payload over a native Node.js WinRM client
|
|
185
|
-
* (winrm-client) — no Python/pywinrm subprocess. The script
|
|
186
|
-
*
|
|
187
|
-
* any codepage output survives the
|
|
188
|
-
*
|
|
189
|
-
*
|
|
324
|
+
* (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
|
|
325
|
+
* the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
|
|
326
|
+
* Chinese and any codepage output survives the transport losslessly and the
|
|
327
|
+
* command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
|
|
328
|
+
* mangle. See powershell.ts for why the command line cannot carry the script.
|
|
190
329
|
*
|
|
191
330
|
* Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
|
|
192
331
|
* first, then Basic) with fallback, so a host that only enables one scheme
|
|
@@ -245,17 +384,17 @@ function baseParams(conn, authMethod) {
|
|
|
245
384
|
};
|
|
246
385
|
}
|
|
247
386
|
/**
|
|
248
|
-
* One shell lifecycle: create → run the
|
|
249
|
-
* command completes → delete. Bounded by
|
|
250
|
-
* delete is fired without blocking
|
|
387
|
+
* One shell lifecycle: create → run the constant envelope script → push the
|
|
388
|
+
* payload on stdin → receive until the command completes → delete. Bounded by
|
|
389
|
+
* an overall deadline; on timeout the delete is fired without blocking.
|
|
251
390
|
* @param conn - connection parameters.
|
|
252
|
-
* @param
|
|
391
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
253
392
|
* @param timeoutMs - command execution budget measured from just after Execute.
|
|
254
393
|
* @param authMethod - the auth scheme for this attempt.
|
|
255
394
|
* @returns stdout/stderr captured from the WinRS streams.
|
|
256
395
|
* @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
|
|
257
396
|
*/
|
|
258
|
-
async function attemptOnce(conn,
|
|
397
|
+
async function attemptOnce(conn, payload, timeoutMs, authMethod) {
|
|
259
398
|
const base = baseParams(conn, authMethod);
|
|
260
399
|
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
261
400
|
const state = {
|
|
@@ -273,11 +412,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
|
|
|
273
412
|
}).catch(() => void 0);
|
|
274
413
|
throw new WinrmTimedOut();
|
|
275
414
|
}
|
|
276
|
-
const command = powershellCommandLine(envelope);
|
|
277
415
|
const commandId = await Command.doExecuteCommand({
|
|
278
416
|
...base,
|
|
279
417
|
shellId,
|
|
280
|
-
command
|
|
418
|
+
command: powershellCommandLine()
|
|
419
|
+
});
|
|
420
|
+
for (const piece of splitScriptPayload(payload)) await Command.doSendInput({
|
|
421
|
+
...base,
|
|
422
|
+
shellId,
|
|
423
|
+
commandId,
|
|
424
|
+
input: piece
|
|
281
425
|
});
|
|
282
426
|
const receive = {
|
|
283
427
|
...base,
|
|
@@ -324,16 +468,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
|
|
|
324
468
|
* succeeds. A timeout aborts immediately (retrying auth would waste the
|
|
325
469
|
* remaining budget on a reachable-but-slow host).
|
|
326
470
|
* @param conn - connection parameters.
|
|
327
|
-
* @param
|
|
471
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
328
472
|
* @param timeoutMs - command execution budget.
|
|
329
473
|
* @returns captured stdout/stderr.
|
|
330
474
|
* @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
|
|
331
475
|
*/
|
|
332
|
-
async function runWinrm(conn,
|
|
476
|
+
async function runWinrm(conn, payload, timeoutMs) {
|
|
333
477
|
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
334
478
|
let lastError;
|
|
335
479
|
for (const authMethod of candidates) try {
|
|
336
|
-
return await attemptOnce(conn,
|
|
480
|
+
return await attemptOnce(conn, payload, timeoutMs, authMethod);
|
|
337
481
|
} catch (error) {
|
|
338
482
|
if (error instanceof WinrmTimedOut) throw error;
|
|
339
483
|
lastError = error;
|
|
@@ -343,10 +487,10 @@ async function runWinrm(conn, envelope, timeoutMs) {
|
|
|
343
487
|
async function runScript(conn, script, options = {}) {
|
|
344
488
|
const started = Date.now();
|
|
345
489
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
346
|
-
const
|
|
490
|
+
const payload = scriptPayload(script);
|
|
347
491
|
let outcome;
|
|
348
492
|
try {
|
|
349
|
-
outcome = await runWinrm(conn,
|
|
493
|
+
outcome = await runWinrm(conn, payload, timeoutMs);
|
|
350
494
|
} catch (error) {
|
|
351
495
|
if (error instanceof WinrmTimedOut) return {
|
|
352
496
|
stdout: "",
|
|
@@ -360,13 +504,13 @@ async function runScript(conn, script, options = {}) {
|
|
|
360
504
|
const raw = outcome.stdout;
|
|
361
505
|
const parsed = parseEnvelope(raw);
|
|
362
506
|
const exitCode = parsed !== null ? parsed.exitCode : 1;
|
|
363
|
-
const stderr = parsed !== null ? stripClixml(
|
|
507
|
+
const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr;
|
|
364
508
|
options.onChunk?.({
|
|
365
509
|
output: raw,
|
|
366
510
|
stderr
|
|
367
511
|
});
|
|
368
512
|
return {
|
|
369
|
-
stdout: parsed !== null ? parsed.
|
|
513
|
+
stdout: parsed !== null ? parsed.stdout : raw,
|
|
370
514
|
stderr,
|
|
371
515
|
exitCode,
|
|
372
516
|
timedOut: false,
|