dsh-vpn 0.1.6 → 0.1.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-vpn
2
2
 
3
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:在**你自己的 VPS** 上一键部署加密的 VPN 服务,保护你的网络连接安全。在 dsh 里填好 VPS 的 IP、端口、用户名和密码,一键部署 sing-box,然后扫二维码导入手机或电脑上的客户端。
3
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:在**你自己的 VPS** 上一键部署加密的 VPN 服务,保护你的网络连接安全。在 dsh 里填好 VPS 的 IP、端口、用户名和密码(或用密钥登录),一键部署 sing-box,然后扫二维码导入手机或电脑上的客户端。
4
4
 
5
5
  ## 用途:保护你的网络安全
6
6
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## 特性
15
15
 
16
- - **不依赖本机的 ssh 命令**:内置纯 JS 的 SSH 客户端([ssh2](https://github.com/mscdex/ssh2)),用密码登录。
16
+ - **不依赖本机的 ssh 命令**:内置纯 JS 的 SSH 客户端([ssh2](https://github.com/mscdex/ssh2)),支持密码登录和密钥登录。
17
17
  - **两种用法**:在设置页点按钮,或者直接对 agent 说“帮我装个节点”。
18
18
  - **五种协议可选**:VLESS Reality、Hysteria2(默认)、TUIC v5、AnyTLS、Shadowsocks 2022。
19
19
  - **免域名的正规证书**:可选 Let's Encrypt 为服务器 IP 签发的证书,所有客户端都能正常校验证书。
@@ -33,11 +33,20 @@ dsh plugin --profile web add dsh-vpn
33
33
 
34
34
  在 VPN 设置里:
35
35
 
36
- 1. 填写服务器 IP、SSH 端口、用户名、密码,点「保存」和「测试连接」。
36
+ 1. 填写服务器 IP、SSH 端口、用户名、密码,点「保存」和「测试连接」。服务器只允许密钥登录的,见下面的「密钥登录」。
37
37
  2. 勾选要安装的协议,选择证书类型(推荐「IP 证书」),保存后点「安装 / 更新」。
38
38
  3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。
39
39
  4. 服务器防火墙的端口由插件自动放行,不用手动操作;安装完成后提示框会列出放行了哪些端口。
40
40
 
41
+ **密钥登录**:服务器只允许密钥登录(或者你不想保存密码)时,把「登录方式」选成「密钥」。插件会生成一把专用钥匙(纯 JS 生成,不需要本机的 ssh-keygen),页面上显示它的公钥,任选一种办法放到服务器上:
42
+
43
+ - A. 粘到服务商后台的「SSH 密钥」里,重装系统时勾选它(新机器推荐);
44
+ - B. 已经能登录服务器的(网页控制台或别的终端),复制页面上的一行命令粘贴执行。
45
+
46
+ 放好后点「保存」和「测试连接」。已经有自己的私钥、服务器上也放好了对应公钥的,展开「改用我自己已有的私钥」粘贴进去即可(支持带密码短语的私钥)。用非 root 用户时,「sudo 密码」一栏填该用户的 sudo 密码;root 或免密 sudo 不用填。
47
+
48
+ **找回已有节点**:如果重装过 dsh 或 dsh-vpn,本地保存的设置会丢失,但 VPS 上的节点还在。重新填写服务器 IP、端口、用户名和密码后点「保存」,插件会自动检查这台服务器上是否已有安装(也可以随时点「检查已有安装」)。找到后直接显示原来的节点二维码,并按服务器上的实际情况勾选协议和证书类型,已经导入手机或电脑客户端的配置可以继续使用,不用重新扫码。
49
+
41
50
  页面第一行显示插件版本,有新版本时旁边会提示,点「更新」即可升级插件,重启 dsh 后生效(需要 dsh 0.2 及以上;更早的版本请在「设置 → 插件」中更新)。为了供应链安全,发布不足 24 小时的新版本会等满 24 小时再提供更新。
42
51
 
43
52
  ## 语言
@@ -82,7 +91,8 @@ IP 证书由 sing-box 1.14 内置的 ACME 向 Let's Encrypt 申请([2026 年
82
91
 
83
92
  | 方面 | 做法 |
84
93
  |---|---|
85
- | 密码存储 | 存在 dsh 凭据库(`$DSH_HOME/.credentials.yaml`,权限 0600),不写进配置文件;浏览器只能写入,读不回来 |
94
+ | 密码和私钥 | 存在 dsh 凭据库(`$DSH_HOME/.credentials.yaml`,权限 0600),不写进配置文件;浏览器只能写入,读不回来(专用钥匙只向页面提供公钥) |
95
+ | 登录方式 | 只用你选定的一种:选了密钥就只用密钥,不会退回密码;不使用键盘交互登录或 ssh-agent |
86
96
  | 接口鉴权 | 设置页的接口挂在 dsh 自己的 `/api` 下,复用 dsh 的登录 cookie 和防跨站、防 DNS rebinding 检查 |
87
97
  | 主机指纹 | 首次连接时记录 SSH 主机指纹,之后指纹变化就拒绝连接,防止中间人截获密码 |
88
98
  | sudo | 非 root 用户用 `sudo -S`:密码只经 stdin 传入,不会出现在命令行,也不会传给脚本 |
package/assets/vpn-ops.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # vpn-ops
2
2
 
3
- Deploy and operate a personal, encrypted VPN (sing-box) on the user's own VPS. The plugin logs in over SSH with the password saved in dsh Settings (bottom-left gear) → VPN 设置. No local ssh client is needed.
3
+ Deploy and operate a personal, encrypted VPN (sing-box) on the user's own VPS. The plugin logs in over SSH with the password or key saved in dsh Settings (bottom-left gear) → VPN 设置. No local ssh client is needed.
4
4
 
5
5
  ## Purpose
6
6
 
@@ -33,7 +33,8 @@ The settings page offers **self-signed** (default) or **IP certificate** (recomm
33
33
 
34
34
  | Error / symptom | Likely cause | What to do |
35
35
  |---|---|---|
36
- | `auth_failed` | Wrong username or password | User fixes it on the settings page |
36
+ | `auth_failed` | Wrong username or password, or the server only allows key login | User fixes it on the settings page (Login method → Key) |
37
+ | `key_rejected` | The server did not accept the key | User puts the public key shown on the settings page into authorized_keys |
37
38
  | `host_key_mismatch` | Server reinstalled or replaced, or a man-in-the-middle | Ask whether the server was reinstalled. Only then may the user click 重置指纹 (Reset fingerprint) |
38
39
  | `connect_failed` / `timeout` | Wrong IP or port, SSH not reachable, server down | Check the IP and SSH port on the settings page |
39
40
  | `sudo_failed` | User is not root and lacks sudo, or the sudo password differs | Use root, or grant sudo |
package/lib/client.js CHANGED
@@ -50,10 +50,14 @@ async function post(path, body = {}) {
50
50
  var vpnApi = {
51
51
  getSettings: () => post("/settings/get"),
52
52
  saveSettings: (form) => post("/settings/save", form),
53
+ /** Public half of the plugin's dedicated key and the line that installs it. */
54
+ sshKey: () => post("/ssh-key"),
53
55
  forgetHostKey: () => post("/host-key/forget"),
54
56
  test: () => post("/test"),
55
57
  status: () => post("/status"),
56
58
  links: () => post("/links"),
59
+ /** Find an earlier installation on the server and adopt its protocols and certificate mode. */
60
+ detect: () => post("/detect"),
57
61
  install: (regenerate) => post("/install", { regenerate }),
58
62
  uninstall: () => post("/uninstall"),
59
63
  /** Installed version; with `check`, also the newest release on npm. */
@@ -87,6 +91,26 @@ var zh = {
87
91
  port: "SSH \u7AEF\u53E3",
88
92
  username: "\u7528\u6237\u540D",
89
93
  usernameHint: "\u5EFA\u8BAE\u7528 root\uFF1B\u5176\u4ED6\u7528\u6237\u9700\u8981\u6709 sudo \u6743\u9650\u3002",
94
+ authMethod: "\u767B\u5F55\u65B9\u5F0F",
95
+ authPassword: "\u5BC6\u7801",
96
+ authKey: "\u5BC6\u94A5\uFF08\u670D\u52A1\u5668\u53EA\u5141\u8BB8\u5BC6\u94A5\u767B\u5F55\u65F6\u9009\u8FD9\u4E2A\uFF09",
97
+ sudoPassword: "sudo \u5BC6\u7801\uFF08\u9009\u586B\uFF09",
98
+ sudoPasswordEmpty: "\u7528 root \u6216\u514D\u5BC6 sudo \u65F6\u4E0D\u7528\u586B",
99
+ keyIntro: "\u7528\u63D2\u4EF6\u7684\u4E13\u7528\u94A5\u5319\u767B\u5F55\u3002\u628A\u4E0B\u9762\u7684\u516C\u94A5\u653E\u5230\u670D\u52A1\u5668\u4E0A\uFF0C\u4EFB\u9009\u4E00\u79CD\u529E\u6CD5\uFF1A",
100
+ keyWayConsole: "A. \u670D\u52A1\u5546\u540E\u53F0\u6DFB\u52A0\uFF08\u65B0\u673A\u5668\u63A8\u8350\uFF09",
101
+ keyWayConsoleHint: "\u7C98\u5230\u670D\u52A1\u5546\u7684\u300CSSH \u5BC6\u94A5\u300D\u91CC\uFF0C\u91CD\u88C5\u7CFB\u7EDF\u65F6\u52FE\u9009\u5B83\u3002",
102
+ keyWayCommand: "B. \u5DF2\u7ECF\u80FD\u767B\u5F55\uFF08\u7F51\u9875\u63A7\u5236\u53F0\u6216\u522B\u7684\u7EC8\u7AEF\uFF09",
103
+ keyWayCommandHint: "\u4EE5 {user} \u8EAB\u4EFD\u767B\u5F55\u670D\u52A1\u5668\uFF0C\u7C98\u8D34\u6267\u884C\u8FD9\u4E00\u884C\u3002",
104
+ keyThen: "\u653E\u597D\u540E\u70B9\u300C\u4FDD\u5B58\u300D\uFF0C\u518D\u70B9\u300C\u6D4B\u8BD5\u8FDE\u63A5\u300D\u3002",
105
+ copyPublicKey: "\u590D\u5236\u516C\u94A5",
106
+ copyCommand: "\u590D\u5236\u547D\u4EE4",
107
+ ownKeyToggle: "\u6539\u7528\u6211\u81EA\u5DF1\u5DF2\u6709\u7684\u79C1\u94A5",
108
+ ownKeyBack: "\u6539\u56DE\u63D2\u4EF6\u4E13\u7528\u94A5\u5319",
109
+ ownKey: "\u6211\u81EA\u5DF1\u7684\u79C1\u94A5",
110
+ ownKeyHint: "\u7C98\u8D34 OpenSSH \u6216 PEM \u683C\u5F0F\u7684\u79C1\u94A5\uFF08\u670D\u52A1\u5668\u4E0A\u5DF2\u7ECF\u653E\u4E86\u5B83\u5BF9\u5E94\u7684\u516C\u94A5\uFF09\u3002\u53EA\u4FDD\u5B58\u5728\u672C\u673A dsh \u7684\u51ED\u636E\u5E93\u91CC\uFF0C\u4E0D\u4F1A\u663E\u793A\u56DE\u9875\u9762\u3002",
111
+ ownKeySaved: "\u5DF2\u4FDD\u5B58\uFF08\u7559\u7A7A\u5219\u4E0D\u4FEE\u6539\uFF09",
112
+ passphrase: "\u79C1\u94A5\u5BC6\u7801\u77ED\u8BED\uFF08\u9009\u586B\uFF09",
113
+ passphraseHint: "\u79C1\u94A5\u6CA1\u6709\u52A0\u5BC6\u5C31\u4E0D\u7528\u586B",
90
114
  password: "\u5BC6\u7801",
91
115
  passwordSaved: "\u5DF2\u4FDD\u5B58\uFF08\u7559\u7A7A\u5219\u4E0D\u4FEE\u6539\uFF09",
92
116
  passwordEmpty: "\u767B\u5F55 VPS \u7684\u5BC6\u7801",
@@ -132,6 +156,10 @@ var zh = {
132
156
  working: "\u6267\u884C\u4E2D\u2026",
133
157
  qrSection: "\u8282\u70B9\u4E8C\u7EF4\u7801",
134
158
  qrHint: "\u7528 v2rayN\u3001NekoBox\u3001Shadowrocket\u3001sing-box\u3001Clash Meta \u7B49\u5BA2\u6237\u7AEF\u626B\u63CF\u5BFC\u5165\u3002\u4E8C\u7EF4\u7801\u7B49\u540C\u4E8E\u5BC6\u7801\uFF0C\u8BF7\u52FF\u5916\u4F20\u3002",
159
+ detect: "\u68C0\u67E5\u5DF2\u6709\u5B89\u88C5",
160
+ detectFound: "\u5DF2\u627E\u5230\u4E4B\u524D\u7684\u5B89\u88C5\uFF1Asing-box {version}\uFF0C\u534F\u8BAE\uFF1A{protocols}\uFF08{cert}\uFF09\u3002\u8282\u70B9\u4E8C\u7EF4\u7801\u5DF2\u663E\u793A\u5728\u4E0B\u65B9\uFF0C\u539F\u6709\u7684\u5BA2\u6237\u7AEF\u914D\u7F6E\u53EF\u4EE5\u7EE7\u7EED\u4F7F\u7528\u3002",
161
+ detectSynced: "\u5DF2\u6309\u670D\u52A1\u5668\u4E0A\u7684\u5B9E\u9645\u60C5\u51B5\u66F4\u65B0\u4E86\u534F\u8BAE\u52FE\u9009\u548C\u8BC1\u4E66\u7C7B\u578B\u3002",
162
+ detectNone: "\u8FD9\u53F0\u670D\u52A1\u5668\u4E0A\u6CA1\u6709\u627E\u5230\u4E4B\u524D\u7684\u5B89\u88C5\uFF0C\u53EF\u4EE5\u76F4\u63A5\u70B9\u300C\u5B89\u88C5 / \u66F4\u65B0\u300D\u3002",
135
163
  refresh: "\u5237\u65B0\u4E8C\u7EF4\u7801",
136
164
  refreshing: "\u6B63\u5728\u5237\u65B0\u2026",
137
165
  refreshedAt: "\u4E0A\u6B21\u5237\u65B0 {time}",
@@ -146,7 +174,8 @@ var zh = {
146
174
  fwStatus: "\u670D\u52A1\u5668\u9632\u706B\u5899\uFF08{backend}\uFF09\u5DF2\u653E\u884C {count} \u4E2A\u7AEF\u53E3\uFF0C\u91CD\u542F\u540E\u81EA\u52A8\u6062\u590D\u3002",
147
175
  cloudHint: "\u5982\u679C\u4E91\u670D\u52A1\u5546\u6709\u5B89\u5168\u7EC4\uFF08\u963F\u91CC\u4E91\u3001\u817E\u8BAF\u4E91\u3001AWS \u7B49\uFF09\uFF0C\u8FD9\u4E9B\u7AEF\u53E3\u8FD8\u9700\u5728\u5176\u63A7\u5236\u53F0\u653E\u884C\u3002",
148
176
  "err.not_configured": "\u8BF7\u5148\u586B\u5199\u5E76\u4FDD\u5B58\u670D\u52A1\u5668\u4FE1\u606F\u3002",
149
- "err.auth_failed": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF\u3002",
177
+ "err.auth_failed": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF\u3002\u5982\u679C\u670D\u52A1\u5668\u53EA\u5141\u8BB8\u5BC6\u94A5\u767B\u5F55\uFF0C\u8BF7\u628A\u300C\u767B\u5F55\u65B9\u5F0F\u300D\u6539\u6210\u300C\u5BC6\u94A5\u300D\u3002",
178
+ "err.key_rejected": "SSH \u767B\u5F55\u5931\u8D25\uFF1A\u670D\u52A1\u5668\u6CA1\u6709\u63A5\u53D7\u8FD9\u628A\u94A5\u5319\u3002\u8BF7\u786E\u8BA4\u516C\u94A5\u5DF2\u653E\u5230\u8BE5\u7528\u6237\u7684 ~/.ssh/authorized_keys \u91CC\uFF0C\u7528\u6237\u540D\u586B\u5199\u6B63\u786E\u3002",
150
179
  "err.host_key_mismatch": "\u670D\u52A1\u5668\u6307\u7EB9\u4E0E\u4E0A\u6B21\u4E0D\u540C\uFF0C\u5DF2\u62D2\u7EDD\u8FDE\u63A5\u3002\u5982\u679C\u4F60\u91CD\u88C5\u4E86\u7CFB\u7EDF\u6216\u6362\u4E86\u673A\u5668\uFF0C\u8BF7\u70B9\u300C\u91CD\u7F6E\u6307\u7EB9\u300D\u540E\u91CD\u8BD5\u3002",
151
180
  "err.connect_failed": "\u65E0\u6CD5\u8FDE\u63A5\u670D\u52A1\u5668\uFF1A{message}",
152
181
  "err.timeout": "\u64CD\u4F5C\u8D85\u65F6\u3002",
@@ -164,6 +193,8 @@ var zh = {
164
193
  "field.port": "SSH \u7AEF\u53E3\u9700\u5728 1\u201365535 \u4E4B\u95F4\u3002",
165
194
  "field.username": "\u7528\u6237\u540D\u683C\u5F0F\u4E0D\u6B63\u786E\u3002",
166
195
  "field.password": "\u8BF7\u586B\u5199\u5BC6\u7801\u3002",
196
+ "field.auth": "\u767B\u5F55\u65B9\u5F0F\u4E0D\u6B63\u786E\u3002",
197
+ "field.privateKey": "\u79C1\u94A5\u65E0\u6CD5\u4F7F\u7528\uFF1A\u683C\u5F0F\u4E0D\u5BF9\uFF0C\u6216\u8005\u5BC6\u7801\u77ED\u8BED\u4E0D\u6B63\u786E\u3002",
167
198
  "field.protocols": "\u8BF7\u81F3\u5C11\u52FE\u9009\u4E00\u4E2A\u534F\u8BAE\u3002",
168
199
  "field.linkAddress": "\u8282\u70B9\u5730\u5740\u683C\u5F0F\u4E0D\u6B63\u786E\u3002",
169
200
  "field.certMode": "IP \u8BC1\u4E66\u9700\u8981\u7528 IP \u5730\u5740\u4F5C\u4E3A\u8282\u70B9\u5730\u5740\uFF1A\u8BF7\u628A\u300C\u8282\u70B9\u5730\u5740\u300D\u7559\u7A7A\uFF08\u4F7F\u7528\u670D\u52A1\u5668 IP\uFF09\u6216\u586B\u5199 IP\uFF0C\u6216\u6539\u7528\u81EA\u7B7E\u8BC1\u4E66\u3002"
@@ -189,6 +220,26 @@ var en = {
189
220
  port: "SSH port",
190
221
  username: "Username",
191
222
  usernameHint: "root is recommended; other users need sudo.",
223
+ authMethod: "Login method",
224
+ authPassword: "Password",
225
+ authKey: "Key (choose this if the server only allows key login)",
226
+ sudoPassword: "sudo password (optional)",
227
+ sudoPasswordEmpty: "Not needed for root or passwordless sudo",
228
+ keyIntro: "Log in with the plugin's dedicated key. Put the public key below on the server, either way:",
229
+ keyWayConsole: "A. Add it in your provider's console (recommended for new machines)",
230
+ keyWayConsoleHint: `Paste it into your provider's "SSH keys" and select it when reinstalling the OS.`,
231
+ keyWayCommand: "B. If you can already log in (web console or another terminal)",
232
+ keyWayCommandHint: "Log in to the server as {user} and paste and run this line.",
233
+ keyThen: "Then click Save, then Test connection.",
234
+ copyPublicKey: "Copy public key",
235
+ copyCommand: "Copy command",
236
+ ownKeyToggle: "Use my own existing private key instead",
237
+ ownKeyBack: "Back to the plugin's dedicated key",
238
+ ownKey: "My own private key",
239
+ ownKeyHint: "Paste an OpenSSH or PEM private key whose public key is already on the server. It is kept only in dsh's local credential store and never shown again.",
240
+ ownKeySaved: "Saved (leave blank to keep)",
241
+ passphrase: "Key passphrase (optional)",
242
+ passphraseHint: "Leave empty if the key is not encrypted",
192
243
  password: "Password",
193
244
  passwordSaved: "Saved (leave blank to keep)",
194
245
  passwordEmpty: "Password for the VPS",
@@ -234,6 +285,10 @@ var en = {
234
285
  working: "Working\u2026",
235
286
  qrSection: "Node QR codes",
236
287
  qrHint: "Scan with v2rayN, NekoBox, Shadowrocket, sing-box, Clash Meta and similar apps. A QR code is as secret as a password.",
288
+ detect: "Find existing install",
289
+ detectFound: "Found an earlier installation: sing-box {version}, protocols: {protocols} ({cert}). Its QR codes are shown below; clients you already set up keep working.",
290
+ detectSynced: "The protocol selection and certificate type now match the server.",
291
+ detectNone: "No earlier installation was found on this server. Click Install / Update to set one up.",
237
292
  refresh: "Refresh QR codes",
238
293
  refreshing: "Refreshing\u2026",
239
294
  refreshedAt: "Last refreshed {time}",
@@ -248,7 +303,8 @@ var en = {
248
303
  fwStatus: "Server firewall ({backend}) has {count} ports opened; re-applied after reboots.",
249
304
  cloudHint: "If your cloud provider has a security group (Aliyun, Tencent Cloud, AWS\u2026), also open these ports in its console.",
250
305
  "err.not_configured": "Fill in and save the server details first.",
251
- "err.auth_failed": "SSH login failed: wrong username or password.",
306
+ "err.auth_failed": "SSH login failed: wrong username or password. If the server only allows key login, set Login method to Key.",
307
+ "err.key_rejected": "SSH login failed: the server did not accept the key. Check that the public key is in that user's ~/.ssh/authorized_keys and the username is right.",
252
308
  "err.host_key_mismatch": 'The server fingerprint changed, so the connection was refused. If you reinstalled or replaced the server, click "Reset fingerprint" and retry.',
253
309
  "err.connect_failed": "Cannot connect to the server: {message}",
254
310
  "err.timeout": "The operation timed out.",
@@ -266,6 +322,8 @@ var en = {
266
322
  "field.port": "The SSH port must be 1\u201365535.",
267
323
  "field.username": "The username is not valid.",
268
324
  "field.password": "Enter the password.",
325
+ "field.auth": "Unknown login method.",
326
+ "field.privateKey": "The private key cannot be used: wrong format or wrong passphrase.",
269
327
  "field.protocols": "Select at least one protocol.",
270
328
  "field.linkAddress": "The node address is not valid.",
271
329
  "field.certMode": 'An IP certificate needs an IP address as the node address: leave "Node address" empty (uses the server IP) or enter an IP, or use self-signed.'
@@ -307,6 +365,20 @@ var STYLES = `
307
365
  .dshvpn-qr { background: #fff; padding: 6px; border-radius: 6px; line-height: 0; align-self: center; }
308
366
  .dshvpn-qr svg { width: 180px; height: 180px; }
309
367
  .dshvpn-link { display: block; max-width: 100%; max-height: 3.2em; overflow: hidden; color: var(--dsw-alias-label-secondary, #666); }
368
+ .dshvpn-auth { display: flex; flex-direction: column; gap: 6px; }
369
+ .dshvpn-auth-options { display: flex; flex-wrap: wrap; gap: 18px; }
370
+ .dshvpn-grid .dshvpn-auth-options label.dshvpn-radio { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }
371
+ .dshvpn-auth-options .dshvpn-radio input { margin: 0; }
372
+ .dshvpn-keypanel { display: flex; flex-direction: column; gap: 10px; }
373
+ .dshvpn-keypanel p { margin: 0; }
374
+ .dshvpn-keycard { display: flex; flex-direction: column; gap: 6px; padding: 12px; border-radius: 10px;
375
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
376
+ .dshvpn-copyable { display: flex; align-items: flex-start; gap: 8px; }
377
+ .dshvpn-copyable code { flex: 1; min-width: 0; font-size: 12px; word-break: break-all; white-space: pre-wrap; padding: 6px 8px; border-radius: 6px;
378
+ background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12)); }
379
+ .dshvpn-disclosure { align-self: flex-start; border: none; background: transparent; color: inherit; padding: 0; cursor: pointer; font-size: 12.5px; opacity: .75; }
380
+ .dshvpn-textarea { width: 100%; box-sizing: border-box; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 8px; border-radius: 8px; resize: vertical;
381
+ color: inherit; background: transparent; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
310
382
  .dshvpn-section { display: flex; flex-direction: column; gap: 16px; }
311
383
  .dshvpn-section-head h2 { margin: 0; font-size: 18px; font-weight: 600; }
312
384
  .dshvpn-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 4px; }
@@ -1034,6 +1106,7 @@ var import_jsx_runtime = require("react/jsx-runtime");
1034
1106
  var ERROR_KEYS = /* @__PURE__ */ new Set([
1035
1107
  "not_configured",
1036
1108
  "auth_failed",
1109
+ "key_rejected",
1037
1110
  "host_key_mismatch",
1038
1111
  "connect_failed",
1039
1112
  "timeout",
@@ -1047,16 +1120,28 @@ var ERROR_KEYS = /* @__PURE__ */ new Set([
1047
1120
  "port80_in_use",
1048
1121
  "cert_failed"
1049
1122
  ]);
1050
- var FIELD_KEYS = /* @__PURE__ */ new Set(["host", "port", "username", "password", "protocols", "linkAddress", "certMode"]);
1123
+ var FIELD_KEYS = /* @__PURE__ */ new Set(["host", "port", "username", "password", "auth", "privateKey", "protocols", "linkAddress", "certMode"]);
1051
1124
  function errorText(t, f) {
1052
1125
  if (f.error === "invalid" && f.field !== void 0 && FIELD_KEYS.has(f.field)) return t(`field.${f.field}`);
1053
1126
  const key = ERROR_KEYS.has(f.error) ? `err.${f.error}` : "err.other";
1054
1127
  return t(key, { message: f.message ?? f.error });
1055
1128
  }
1056
1129
  function formOf(s) {
1057
- return { host: s.host, port: s.port, username: s.username, password: "", protocols: [...s.protocols], certMode: s.certMode, linkAddress: s.linkAddress };
1130
+ return {
1131
+ host: s.host,
1132
+ port: s.port,
1133
+ username: s.username,
1134
+ password: "",
1135
+ auth: s.auth,
1136
+ privateKey: "",
1137
+ passphrase: "",
1138
+ clearPrivateKey: false,
1139
+ protocols: [...s.protocols],
1140
+ certMode: s.certMode,
1141
+ linkAddress: s.linkAddress
1142
+ };
1058
1143
  }
1059
- var sameForm = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username && a.password === b.password && a.linkAddress === b.linkAddress && a.certMode === b.certMode && a.protocols.join() === [...b.protocols].join();
1144
+ var sameForm = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username && a.password === b.password && a.auth === b.auth && a.privateKey === b.privateKey && a.passphrase === b.passphrase && a.clearPrivateKey === b.clearPrivateKey && a.linkAddress === b.linkAddress && a.certMode === b.certMode && a.protocols.join() === [...b.protocols].join();
1060
1145
  function Checkbox({ label, checked, disabled, onChange }) {
1061
1146
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-checkbox", children: [
1062
1147
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked, disabled, onChange: (e) => {
@@ -1065,6 +1150,93 @@ function Checkbox({ label, checked, disabled, onChange }) {
1065
1150
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: label })
1066
1151
  ] });
1067
1152
  }
1153
+ function Copyable({ text, label, t }) {
1154
+ const [copied, setCopied] = (0, import_react.useState)(false);
1155
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-copyable", children: [
1156
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: text }),
1157
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
1158
+ void (0, import_dsh_client_ui_primitives.writeClipboard)(text).then(() => {
1159
+ setCopied(true);
1160
+ setTimeout(() => {
1161
+ setCopied(false);
1162
+ }, 1500);
1163
+ });
1164
+ }, children: copied ? t("copied") : label })
1165
+ ] });
1166
+ }
1167
+ function KeyPanel({ t, api, form, saved, set, setForm }) {
1168
+ const [key, setKey] = (0, import_react.useState)(null);
1169
+ const ownStored = saved.hasPrivateKey && !form.clearPrivateKey;
1170
+ const [own, setOwn] = (0, import_react.useState)(ownStored);
1171
+ (0, import_react.useEffect)(() => {
1172
+ let live = true;
1173
+ void api.sshKey().then((r) => {
1174
+ if (live && r.ok) setKey(r);
1175
+ });
1176
+ return () => {
1177
+ live = false;
1178
+ };
1179
+ }, [api]);
1180
+ const preparing = t("working");
1181
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keypanel dshvpn-wide", children: [
1182
+ !own && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1183
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dshvpn-muted", children: t("keyIntro") }),
1184
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
1185
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keyWayConsole") }),
1186
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyWayConsoleHint") }),
1187
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Copyable, { t, text: key?.publicKey ?? preparing, label: t("copyPublicKey") })
1188
+ ] }),
1189
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
1190
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keyWayCommand") }),
1191
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyWayCommandHint", { user: form.username || "root" }) }),
1192
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Copyable, { t, text: key?.command ?? preparing, label: t("copyCommand") })
1193
+ ] }),
1194
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("keyThen") })
1195
+ ] }),
1196
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dshvpn-disclosure", onClick: () => {
1197
+ if (own) {
1198
+ setOwn(false);
1199
+ setForm({ ...form, privateKey: "", passphrase: "", clearPrivateKey: saved.hasPrivateKey });
1200
+ } else {
1201
+ setOwn(true);
1202
+ setForm({ ...form, clearPrivateKey: false });
1203
+ }
1204
+ }, children: own ? `\u25BE ${t("ownKeyBack")}` : `\u25B8 ${t("ownKeyToggle")}` }),
1205
+ own && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
1206
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("ownKey") }),
1207
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("ownKeyHint") }),
1208
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1209
+ "textarea",
1210
+ {
1211
+ className: "dshvpn-textarea",
1212
+ rows: 5,
1213
+ spellCheck: false,
1214
+ autoComplete: "off",
1215
+ value: form.privateKey,
1216
+ placeholder: ownStored ? t("ownKeySaved") : "-----BEGIN OPENSSH PRIVATE KEY-----",
1217
+ onChange: (e) => {
1218
+ set("privateKey", e.target.value);
1219
+ }
1220
+ }
1221
+ ),
1222
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1223
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("passphrase") }),
1224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1225
+ import_dsh_client_ui_primitives.Input,
1226
+ {
1227
+ type: "password",
1228
+ value: form.passphrase,
1229
+ autoComplete: "new-password",
1230
+ placeholder: t("passphraseHint"),
1231
+ onChange: (e) => {
1232
+ set("passphrase", e.target.value);
1233
+ }
1234
+ }
1235
+ )
1236
+ ] })
1237
+ ] })
1238
+ ] });
1239
+ }
1068
1240
  function QrCard({ item, t }) {
1069
1241
  const [copied, setCopied] = (0, import_react.useState)(false);
1070
1242
  const info = PROTOCOLS.find((p) => p.id === item.id);
@@ -1133,11 +1305,35 @@ function VpnSettingsPage({ view, t, api }) {
1133
1305
  setBusy(null);
1134
1306
  }
1135
1307
  }
1136
- const save = () => op("save", () => api.saveSettings(form), (r) => {
1137
- setSaved(r.settings);
1138
- setForm(formOf(r.settings));
1139
- return { tone: "success", text: t("saved") };
1140
- });
1308
+ const detect = async () => {
1309
+ await op("detect", api.detect, (r) => {
1310
+ const res = r.result ?? {};
1311
+ if (res.installed !== true) return { tone: "info", text: t("detectNone") };
1312
+ if (r.links) {
1313
+ setLinks(r.links);
1314
+ setRefreshedAt(/* @__PURE__ */ new Date());
1315
+ }
1316
+ const protocols = (res.protocols ?? []).map((id) => PROTOCOLS.find((p) => p.id === id)?.name ?? id).join("\u3001");
1317
+ const cert = res.certMode === "ip" ? t("certIp") : t("certSelf");
1318
+ const text = t("detectFound", { version: String(res.version ?? ""), protocols, cert });
1319
+ return { tone: "success", text: res.synced === true ? `${text}
1320
+ ${t("detectSynced")}` : text };
1321
+ });
1322
+ const s = await api.getSettings();
1323
+ if (s.ok) {
1324
+ setSaved(s.settings);
1325
+ setForm(formOf(s.settings));
1326
+ }
1327
+ };
1328
+ const save = async () => {
1329
+ const first = !saved.configured;
1330
+ const r = await op("save", () => api.saveSettings(form), (r2) => {
1331
+ setSaved(r2.settings);
1332
+ setForm(formOf(r2.settings));
1333
+ return { tone: "success", text: t("saved") };
1334
+ });
1335
+ if (r.ok && first) await detect();
1336
+ };
1141
1337
  const refreshSettings = async () => {
1142
1338
  const r = await api.getSettings();
1143
1339
  if (r.ok) {
@@ -1198,20 +1394,30 @@ ${t("cloudHint")}`;
1198
1394
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("usernameHint") })
1199
1395
  ] }),
1200
1396
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1201
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("password") }),
1397
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: form.auth === "key" ? t("sudoPassword") : t("password") }),
1202
1398
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1203
1399
  import_dsh_client_ui_primitives.Input,
1204
1400
  {
1205
1401
  type: "password",
1206
1402
  value: form.password,
1207
1403
  autoComplete: "new-password",
1208
- placeholder: saved.hasPassword ? t("passwordSaved") : t("passwordEmpty"),
1404
+ placeholder: saved.hasPassword ? t("passwordSaved") : form.auth === "key" ? t("sudoPasswordEmpty") : t("passwordEmpty"),
1209
1405
  onChange: (e) => {
1210
1406
  set("password", e.target.value);
1211
1407
  }
1212
1408
  }
1213
1409
  )
1214
1410
  ] }),
1411
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-wide dshvpn-auth", children: [
1412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("authMethod") }),
1413
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-auth-options", children: ["password", "key"].map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-radio", children: [
1414
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "radio", name: "dshvpn-auth", checked: form.auth === m, disabled, onChange: () => {
1415
+ set("auth", m);
1416
+ } }),
1417
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: m === "key" ? t("authKey") : t("authPassword") })
1418
+ ] }, m)) })
1419
+ ] }),
1420
+ form.auth === "key" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyPanel, { t, api, form, saved, set, setForm }),
1215
1421
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { className: "dshvpn-wide", children: [
1216
1422
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("linkAddress") }),
1217
1423
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Input, { value: form.linkAddress, placeholder: form.host, onChange: (e) => {
@@ -1300,6 +1506,9 @@ ${t("cloudHint")}`;
1300
1506
  children: t("test")
1301
1507
  }
1302
1508
  ),
1509
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "outline", disabled: disabled || needsSave, onClick: () => {
1510
+ void detect();
1511
+ }, children: busy === "detect" ? t("working") : t("detect") }),
1303
1512
  dirty && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-warn", children: t("unsaved") })
1304
1513
  ] }),
1305
1514
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { children: [
package/lib/index.js CHANGED
@@ -20164,6 +20164,150 @@ var require_lib2 = __commonJS({
20164
20164
  import { defineTool } from "@deepseek-ai/dsh-tools";
20165
20165
  import Schema from "@deepseek-ai/schemastery";
20166
20166
 
20167
+ // src/ssh.ts
20168
+ var import_ssh2 = __toESM(require_lib2(), 1);
20169
+ import { createHash } from "node:crypto";
20170
+ var SshError = class extends Error {
20171
+ constructor(code, message, details = {}) {
20172
+ super(message);
20173
+ this.code = code;
20174
+ this.details = details;
20175
+ this.name = "SshError";
20176
+ }
20177
+ code;
20178
+ details;
20179
+ };
20180
+ function privateKeyProblem(key, passphrase) {
20181
+ const parsed = import_ssh2.utils.parseKey(key, passphrase || void 0);
20182
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
20183
+ if (first instanceof Error) return first.message;
20184
+ if (!first || !first.isPrivateKey()) return "not a private key";
20185
+ return void 0;
20186
+ }
20187
+ function generateKeyPair(comment) {
20188
+ for (let i = 0; i < 20; i++) {
20189
+ const k = import_ssh2.utils.generateKeyPairSync("ed25519", { comment });
20190
+ try {
20191
+ if (privateKeyProblem(k.private) === void 0) return { privateKey: k.private, publicKey: k.public.trim() };
20192
+ } catch {
20193
+ }
20194
+ }
20195
+ throw new Error("could not generate a usable SSH key");
20196
+ }
20197
+ function fingerprint(key) {
20198
+ return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
20199
+ }
20200
+ var STDERR_TAIL = 8e3;
20201
+ var SshSession = class _SshSession {
20202
+ constructor(client, hostKey) {
20203
+ this.client = client;
20204
+ this.hostKey = hostKey;
20205
+ }
20206
+ client;
20207
+ hostKey;
20208
+ static async open(target, signal, timeoutMs = 2e4) {
20209
+ signal.throwIfAborted();
20210
+ const client = new import_ssh2.Client();
20211
+ let seen;
20212
+ const config = {
20213
+ host: target.host,
20214
+ port: target.port,
20215
+ username: target.username,
20216
+ // Exactly one method: the key, or the password. Never keyboard-interactive or agents.
20217
+ ...target.privateKey ? { privateKey: target.privateKey, ...target.passphrase ? { passphrase: target.passphrase } : {} } : { password: target.password },
20218
+ readyTimeout: timeoutMs,
20219
+ tryKeyboard: false,
20220
+ hostVerifier: (key) => {
20221
+ seen = fingerprint(key);
20222
+ return target.hostKey === void 0 || target.hostKey === seen;
20223
+ }
20224
+ };
20225
+ return await new Promise((resolve, reject) => {
20226
+ const onAbort = () => {
20227
+ client.end();
20228
+ reject(new SshError("cancelled", "cancelled"));
20229
+ };
20230
+ signal.addEventListener("abort", onAbort, { once: true });
20231
+ client.once("ready", () => {
20232
+ signal.removeEventListener("abort", onAbort);
20233
+ resolve(new _SshSession(client, seen ?? ""));
20234
+ });
20235
+ client.once("error", (err) => {
20236
+ signal.removeEventListener("abort", onAbort);
20237
+ client.end();
20238
+ if (target.hostKey !== void 0 && seen !== void 0 && seen !== target.hostKey) {
20239
+ reject(new SshError(
20240
+ "host_key_mismatch",
20241
+ `host key changed: expected ${target.hostKey}, got ${seen}`,
20242
+ { expected: target.hostKey, actual: seen }
20243
+ ));
20244
+ } else if (err.level === "client-authentication") {
20245
+ reject(target.privateKey ? new SshError("key_rejected", `SSH login failed: ${target.username}@${target.host} did not accept the key`) : new SshError("auth_failed", "SSH login failed: wrong username or password"));
20246
+ } else if (err.level === "client-timeout") {
20247
+ reject(new SshError("timeout", `timed out connecting to ${target.host}:${target.port}`));
20248
+ } else {
20249
+ reject(new SshError("connect_failed", `cannot connect to ${target.host}:${target.port}: ${err.message}`));
20250
+ }
20251
+ });
20252
+ client.connect(config);
20253
+ });
20254
+ }
20255
+ /** Run one command; `stdin` is written then closed. Never uses a pty. */
20256
+ async exec(command, stdin, signal) {
20257
+ signal.throwIfAborted();
20258
+ const stream = await new Promise((resolve, reject) => {
20259
+ this.client.exec(command, (err, s) => {
20260
+ if (err) reject(err);
20261
+ else resolve(s);
20262
+ });
20263
+ });
20264
+ let stdout = "";
20265
+ let stderr = "";
20266
+ stream.setEncoding("utf8");
20267
+ stream.on("data", (chunk) => {
20268
+ stdout += chunk;
20269
+ });
20270
+ stream.stderr.setEncoding("utf8");
20271
+ stream.stderr.on("data", (chunk) => {
20272
+ stderr = (stderr + chunk).slice(-STDERR_TAIL);
20273
+ });
20274
+ let cleanup = () => {
20275
+ };
20276
+ try {
20277
+ const code = await new Promise((resolve, reject) => {
20278
+ const onAbort = () => {
20279
+ reject(signal.reason ?? new SshError("cancelled", "cancelled"));
20280
+ stream.close();
20281
+ this.client.end();
20282
+ };
20283
+ const onGone = () => {
20284
+ reject(new SshError("connect_failed", "connection closed during command"));
20285
+ };
20286
+ cleanup = () => {
20287
+ signal.removeEventListener("abort", onAbort);
20288
+ this.client.off("close", onGone);
20289
+ };
20290
+ if (signal.aborted) {
20291
+ onAbort();
20292
+ return;
20293
+ }
20294
+ signal.addEventListener("abort", onAbort, { once: true });
20295
+ this.client.once("close", onGone);
20296
+ stream.once("close", (c) => {
20297
+ resolve(c ?? null);
20298
+ });
20299
+ stream.end(stdin);
20300
+ });
20301
+ return { code, stdout, stderr };
20302
+ } finally {
20303
+ cleanup();
20304
+ }
20305
+ }
20306
+ close() {
20307
+ this.client.end();
20308
+ }
20309
+ };
20310
+
20167
20311
  // src/protocols.ts
20168
20312
  var PROTOCOLS = [
20169
20313
  { id: "reality", name: "VLESS Reality", transport: "TCP", insecureLink: false, defaultSelected: true },
@@ -20203,6 +20347,8 @@ var EMPTY_VIEW = {
20203
20347
  port: 22,
20204
20348
  username: "root",
20205
20349
  hasPassword: false,
20350
+ auth: "password",
20351
+ hasPrivateKey: false,
20206
20352
  protocols: [...DEFAULT_PROTOCOLS],
20207
20353
  certMode: "self",
20208
20354
  linkAddress: "",
@@ -20216,6 +20362,8 @@ function publicView(s) {
20216
20362
  port: s.port,
20217
20363
  username: s.username,
20218
20364
  hasPassword: s.password !== "",
20365
+ auth: s.auth,
20366
+ hasPrivateKey: s.privateKey !== void 0,
20219
20367
  protocols: s.protocols,
20220
20368
  certMode: s.certMode,
20221
20369
  linkAddress: s.linkAddress ?? "",
@@ -20229,8 +20377,23 @@ function applyInput(current, input) {
20229
20377
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SettingsError("port", "port must be 1-65535");
20230
20378
  const username = String(input.username ?? "").trim();
20231
20379
  if (!USERNAME.test(username)) throw new SettingsError("username", `invalid username: ${username}`);
20380
+ const auth = input.auth ?? current?.auth ?? "password";
20381
+ if (auth !== "password" && auth !== "key") throw new SettingsError("auth", `unknown login method: ${auth}`);
20232
20382
  const password = input.password ? String(input.password) : current?.password ?? "";
20233
- if (password === "") throw new SettingsError("password", "password is required");
20383
+ if (auth === "password" && password === "") throw new SettingsError("password", "password is required");
20384
+ let privateKey = input.clearPrivateKey ? void 0 : current?.privateKey;
20385
+ let passphrase = input.clearPrivateKey ? void 0 : current?.passphrase;
20386
+ if (input.privateKey && String(input.privateKey).trim() !== "") {
20387
+ privateKey = `${String(input.privateKey).trim()}
20388
+ `;
20389
+ passphrase = input.passphrase ? String(input.passphrase) : void 0;
20390
+ } else if (input.passphrase && privateKey) {
20391
+ passphrase = String(input.passphrase);
20392
+ }
20393
+ if (privateKey !== void 0) {
20394
+ const problem = privateKeyProblem(privateKey, passphrase);
20395
+ if (problem) throw new SettingsError("privateKey", `unusable private key: ${problem}`);
20396
+ }
20234
20397
  if (!Array.isArray(input.protocols)) throw new SettingsError("protocols", "protocols must be a list");
20235
20398
  const protocols = [...new Set(input.protocols)];
20236
20399
  if (protocols.length === 0) throw new SettingsError("protocols", "select at least one protocol");
@@ -20249,12 +20412,29 @@ function applyInput(current, input) {
20249
20412
  port,
20250
20413
  username,
20251
20414
  password,
20415
+ auth,
20416
+ privateKey,
20417
+ passphrase,
20252
20418
  protocols,
20253
20419
  certMode,
20254
20420
  linkAddress: linkAddress || void 0,
20255
20421
  hostKey: sameMachine ? current.hostKey : void 0
20256
20422
  };
20257
20423
  }
20424
+ var MemoryKeyStore = class {
20425
+ constructor(make) {
20426
+ this.make = make;
20427
+ }
20428
+ make;
20429
+ pair;
20430
+ get() {
20431
+ return Promise.resolve(this.pair ??= this.make());
20432
+ }
20433
+ };
20434
+ function authorizedKeysCommand(publicKey) {
20435
+ const safe = publicKey.trim().replace(/'/g, "'\\''");
20436
+ return `mkdir -p ~/.ssh && chmod 700 ~/.ssh && { grep -qxF '${safe}' ~/.ssh/authorized_keys 2>/dev/null || echo '${safe}' >> ~/.ssh/authorized_keys; } && chmod 600 ~/.ssh/authorized_keys`;
20437
+ }
20258
20438
 
20259
20439
  // src/api.ts
20260
20440
  var API_BASE = "/api/dsh-vpn";
@@ -20291,6 +20471,11 @@ function apiHandlers(service, uiLang, onUiLang, updater) {
20291
20471
  });
20292
20472
  return { ok: true, settings: publicView(saved) };
20293
20473
  },
20474
+ // The dedicated key's public half and the line that installs it; never the private key.
20475
+ "/ssh-key": async () => {
20476
+ const { publicKey } = await service.keys.get();
20477
+ return { ok: true, publicKey, command: authorizedKeysCommand(publicKey) };
20478
+ },
20294
20479
  "/host-key/forget": async () => {
20295
20480
  await service.store.update((cur) => cur && { ...cur, hostKey: void 0 });
20296
20481
  return { ok: true, settings: publicView(await service.store.get()) };
@@ -20298,6 +20483,7 @@ function apiHandlers(service, uiLang, onUiLang, updater) {
20298
20483
  "/test": (_body, signal) => service.test(signal),
20299
20484
  "/status": (_body, signal) => service.status(signal),
20300
20485
  "/links": (_body, signal) => service.links(signal),
20486
+ "/detect": (_body, signal) => service.detect(signal),
20301
20487
  "/install": (body, signal) => service.install({ regenerate: body.regenerate === true }, signal),
20302
20488
  "/uninstall": (_body, signal) => service.uninstall(signal)
20303
20489
  };
@@ -20334,135 +20520,6 @@ function registerApi(ctx, service, uiLang, onUiLang, updater) {
20334
20520
 
20335
20521
  // src/remote.ts
20336
20522
  import { readFile } from "node:fs/promises";
20337
-
20338
- // src/ssh.ts
20339
- var import_ssh2 = __toESM(require_lib2(), 1);
20340
- import { createHash } from "node:crypto";
20341
- var SshError = class extends Error {
20342
- constructor(code, message, details = {}) {
20343
- super(message);
20344
- this.code = code;
20345
- this.details = details;
20346
- this.name = "SshError";
20347
- }
20348
- code;
20349
- details;
20350
- };
20351
- function fingerprint(key) {
20352
- return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
20353
- }
20354
- var STDERR_TAIL = 8e3;
20355
- var SshSession = class _SshSession {
20356
- constructor(client, hostKey) {
20357
- this.client = client;
20358
- this.hostKey = hostKey;
20359
- }
20360
- client;
20361
- hostKey;
20362
- static async open(target, signal, timeoutMs = 2e4) {
20363
- signal.throwIfAborted();
20364
- const client = new import_ssh2.Client();
20365
- let seen;
20366
- const config = {
20367
- host: target.host,
20368
- port: target.port,
20369
- username: target.username,
20370
- password: target.password,
20371
- readyTimeout: timeoutMs,
20372
- // Password only: never fall back to keyboard-interactive prompts or agents.
20373
- tryKeyboard: false,
20374
- hostVerifier: (key) => {
20375
- seen = fingerprint(key);
20376
- return target.hostKey === void 0 || target.hostKey === seen;
20377
- }
20378
- };
20379
- return await new Promise((resolve, reject) => {
20380
- const onAbort = () => {
20381
- client.end();
20382
- reject(new SshError("cancelled", "cancelled"));
20383
- };
20384
- signal.addEventListener("abort", onAbort, { once: true });
20385
- client.once("ready", () => {
20386
- signal.removeEventListener("abort", onAbort);
20387
- resolve(new _SshSession(client, seen ?? ""));
20388
- });
20389
- client.once("error", (err) => {
20390
- signal.removeEventListener("abort", onAbort);
20391
- client.end();
20392
- if (target.hostKey !== void 0 && seen !== void 0 && seen !== target.hostKey) {
20393
- reject(new SshError(
20394
- "host_key_mismatch",
20395
- `host key changed: expected ${target.hostKey}, got ${seen}`,
20396
- { expected: target.hostKey, actual: seen }
20397
- ));
20398
- } else if (err.level === "client-authentication") {
20399
- reject(new SshError("auth_failed", "SSH login failed: wrong username or password"));
20400
- } else if (err.level === "client-timeout") {
20401
- reject(new SshError("timeout", `timed out connecting to ${target.host}:${target.port}`));
20402
- } else {
20403
- reject(new SshError("connect_failed", `cannot connect to ${target.host}:${target.port}: ${err.message}`));
20404
- }
20405
- });
20406
- client.connect(config);
20407
- });
20408
- }
20409
- /** Run one command; `stdin` is written then closed. Never uses a pty. */
20410
- async exec(command, stdin, signal) {
20411
- signal.throwIfAborted();
20412
- const stream = await new Promise((resolve, reject) => {
20413
- this.client.exec(command, (err, s) => {
20414
- if (err) reject(err);
20415
- else resolve(s);
20416
- });
20417
- });
20418
- let stdout = "";
20419
- let stderr = "";
20420
- stream.setEncoding("utf8");
20421
- stream.on("data", (chunk) => {
20422
- stdout += chunk;
20423
- });
20424
- stream.stderr.setEncoding("utf8");
20425
- stream.stderr.on("data", (chunk) => {
20426
- stderr = (stderr + chunk).slice(-STDERR_TAIL);
20427
- });
20428
- let cleanup = () => {
20429
- };
20430
- try {
20431
- const code = await new Promise((resolve, reject) => {
20432
- const onAbort = () => {
20433
- reject(signal.reason ?? new SshError("cancelled", "cancelled"));
20434
- stream.close();
20435
- this.client.end();
20436
- };
20437
- const onGone = () => {
20438
- reject(new SshError("connect_failed", "connection closed during command"));
20439
- };
20440
- cleanup = () => {
20441
- signal.removeEventListener("abort", onAbort);
20442
- this.client.off("close", onGone);
20443
- };
20444
- if (signal.aborted) {
20445
- onAbort();
20446
- return;
20447
- }
20448
- signal.addEventListener("abort", onAbort, { once: true });
20449
- this.client.once("close", onGone);
20450
- stream.once("close", (c) => {
20451
- resolve(c ?? null);
20452
- });
20453
- stream.end(stdin);
20454
- });
20455
- return { code, stdout, stderr };
20456
- } finally {
20457
- cleanup();
20458
- }
20459
- }
20460
- close() {
20461
- this.client.end();
20462
- }
20463
- };
20464
-
20465
- // src/remote.ts
20466
20523
  var SCRIPT_URL = new URL("../scripts/vpn.sh", import.meta.url);
20467
20524
  var RESULT_PREFIX = "DSH_VPN_RESULT=";
20468
20525
  var LOG_TAIL = 4e3;
@@ -20526,8 +20583,11 @@ var NotConfiguredError = class extends Error {
20526
20583
  this.name = "NotConfiguredError";
20527
20584
  }
20528
20585
  };
20529
- function targetOf(s) {
20530
- return { host: s.host, port: s.port, username: s.username, password: s.password, hostKey: s.hostKey };
20586
+ async function targetOf(s, keys) {
20587
+ const base = { host: s.host, port: s.port, username: s.username, password: s.password, hostKey: s.hostKey };
20588
+ if (s.auth !== "key") return base;
20589
+ if (s.privateKey !== void 0) return { ...base, privateKey: s.privateKey, passphrase: s.passphrase };
20590
+ return { ...base, privateKey: (await keys.get()).privateKey };
20531
20591
  }
20532
20592
  function resolveProtocols(requested, fallback) {
20533
20593
  const list = requested && requested.length > 0 ? [...new Set(requested)] : [...fallback];
@@ -20542,12 +20602,14 @@ function failure(err) {
20542
20602
  return { ok: false, error: "internal", message: err instanceof Error ? err.message : String(err) };
20543
20603
  }
20544
20604
  var VpnService = class {
20545
- constructor(store, config) {
20605
+ constructor(store, config, keys = new MemoryKeyStore(() => generateKeyPair("dsh-vpn"))) {
20546
20606
  this.store = store;
20547
20607
  this.config = config;
20608
+ this.keys = keys;
20548
20609
  }
20549
20610
  store;
20550
20611
  config;
20612
+ keys;
20551
20613
  queue = Promise.resolve();
20552
20614
  async settings() {
20553
20615
  const s = await this.store.get();
@@ -20568,7 +20630,7 @@ var VpnService = class {
20568
20630
  return this.exclusive(async () => {
20569
20631
  try {
20570
20632
  const s = await this.settings();
20571
- const res = await runVpnScript(action, args(s), targetOf(s), signal, this.config.timeoutMs);
20633
+ const res = await runVpnScript(action, args(s), await targetOf(s, this.keys), signal, this.config.timeoutMs);
20572
20634
  await this.pin(s, res.hostKey);
20573
20635
  if (!res.result) {
20574
20636
  const sudo = /incorrect password|a password is required|not in the sudoers|may not run sudo/i.test(res.log);
@@ -20615,6 +20677,34 @@ var VpnService = class {
20615
20677
  links(signal) {
20616
20678
  return this.run("links", (s) => ["--server", s.linkAddress ?? s.host], signal);
20617
20679
  }
20680
+ /**
20681
+ * Find an existing installation (e.g. after dsh-vpn was removed and its local
20682
+ * settings lost): read the server's protocols, certificate mode and links,
20683
+ * and adopt the protocols and certificate mode into local settings so a
20684
+ * later 安装 / 更新 keeps the node as it is.
20685
+ */
20686
+ async detect(signal) {
20687
+ const status = await this.status(signal);
20688
+ if (!status.ok || status.result?.installed !== true) return status.ok ? { ok: true, result: { installed: false } } : status;
20689
+ const r = status.result;
20690
+ const protocols = String(r.protocols ?? "").split(",").filter(isProtocolId);
20691
+ const mode = r.cert?.mode;
20692
+ const certMode = mode === "ip" || mode === "self" ? mode : void 0;
20693
+ let synced = false;
20694
+ await this.store.update((cur) => {
20695
+ if (!cur) return void 0;
20696
+ const next = { ...cur, ...protocols.length > 0 ? { protocols } : {}, ...certMode ? { certMode } : {} };
20697
+ synced = next.certMode !== cur.certMode || next.protocols.join() !== cur.protocols.join();
20698
+ return synced ? next : void 0;
20699
+ });
20700
+ const links = await this.links(signal);
20701
+ return {
20702
+ ok: links.ok,
20703
+ ...links.error ? { error: links.error, message: links.message } : {},
20704
+ result: { installed: true, active: r.active, version: r.version, protocols, certMode, synced },
20705
+ ...links.links ? { links: links.links } : {}
20706
+ };
20707
+ }
20618
20708
  uninstall(signal) {
20619
20709
  return this.run("uninstall", () => [], signal);
20620
20710
  }
@@ -20627,7 +20717,7 @@ var VpnService = class {
20627
20717
  let session;
20628
20718
  try {
20629
20719
  const s = await this.settings();
20630
- session = await SshSession.open(targetOf(s), signal);
20720
+ session = await SshSession.open(await targetOf(s, this.keys), signal);
20631
20721
  const asRoot = s.username === "root";
20632
20722
  const cmd = `echo "uid=$(${asRoot ? "" : `sudo -S -p '' `}id -u)"; echo "sys=$(uname -srm)"`;
20633
20723
  const res = await session.exec(cmd, asRoot ? "" : `${s.password}
@@ -20665,6 +20755,9 @@ function parse(payload) {
20665
20755
  port: p.port,
20666
20756
  username: p.username,
20667
20757
  password: p.password,
20758
+ auth: p.auth === "key" ? "key" : "password",
20759
+ privateKey: typeof p.privateKey === "string" ? p.privateKey : void 0,
20760
+ passphrase: typeof p.passphrase === "string" ? p.passphrase : void 0,
20668
20761
  protocols: protocols.length > 0 ? protocols : ["reality", "hy2"],
20669
20762
  certMode: p.certMode === "ip" ? "ip" : "self",
20670
20763
  linkAddress: typeof p.linkAddress === "string" ? p.linkAddress : void 0,
@@ -20688,6 +20781,26 @@ var CredentialSettingsStore = class {
20688
20781
  });
20689
20782
  }
20690
20783
  };
20784
+ var SSH_KEY = credentialKey("dsh-vpn", "ssh-key");
20785
+ var CredentialKeyStore = class {
20786
+ constructor(ctx) {
20787
+ this.ctx = ctx;
20788
+ }
20789
+ ctx;
20790
+ async get() {
20791
+ let pair;
20792
+ await this.ctx.credentials.modifyRecord(SSH_KEY, async (record) => {
20793
+ const p = record?.kind === "grant" ? record.payload : null;
20794
+ if (typeof p?.privateKey === "string" && typeof p.publicKey === "string") {
20795
+ pair = { privateKey: p.privateKey, publicKey: p.publicKey };
20796
+ return void 0;
20797
+ }
20798
+ pair = generateKeyPair("dsh-vpn");
20799
+ return { kind: "grant", payload: { ...pair } };
20800
+ });
20801
+ return pair;
20802
+ }
20803
+ };
20691
20804
  var UI_LANG_KEY = credentialKey("dsh-vpn", "ui-locale");
20692
20805
  async function readUiLang(ctx) {
20693
20806
  const record = await ctx.credentials.readRecord(UI_LANG_KEY);
@@ -20913,7 +21026,11 @@ function toToolValue(r, uiLang) {
20913
21026
  };
20914
21027
  }
20915
21028
  function apply(ctx, config) {
20916
- const service = new VpnService(new CredentialSettingsStore(ctx), { timeoutMs: config.timeoutMs, acmeProvider: config.acmeProvider });
21029
+ const service = new VpnService(
21030
+ new CredentialSettingsStore(ctx),
21031
+ { timeoutMs: config.timeoutMs, acmeProvider: config.acmeProvider },
21032
+ new CredentialKeyStore(ctx)
21033
+ );
20917
21034
  const uiLang = new UiLang();
20918
21035
  readUiLang(ctx).then((lang) => {
20919
21036
  if (lang && !uiLang.reported) uiLang.set(lang);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vpn",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "DeepSeek Harness plugin: deploy an encrypted VPN (sing-box) to your own VPS in one click, to protect your connections and privacy on untrusted networks",
5
5
  "keywords": [
6
6
  "anytls",